Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit 528d5d8cc18811f8b1a7f9ce41a68a5b985fa752
truex <truex@equwal.com>
2026-09-26 18:33:40 -0700

Open the last book at its page with its subtitles

On the phone, Back, a force stop, or Android stopping the reader while
the user was in the dictionary lost everything: after the relaunch the
reader showed "No PDF" and no cues.

- Each open keeps the PDF as the last book in IndexedDB. At start the
  reader opens the last book at its page, with its subtitles, without a
  picker. The start card shows only when there is no last book.
- The page goes to the meta data of the book 1 s after it changes, and
  at once when the page is hidden or another book opens.
- Each subtitle file and each SubRead result goes to the meta data of
  the open book.
- Subtitles belong to a book. Another book loads its own subtitles, or
  has none: the cue list, the mark, the .srt row and "audio to page"
  clear. Subtitles that were loaded while no book was open stay, and
  are kept for the book that opens next (subtitlesOnOpen).
- A picked book shows its first page and starts the reading at once.
  Its meta data applies when it comes. The first open after the upgrade
  from the dictionary build waited about 30 s with no text. When the
  database is not open after 2 s, the strip says "Updating the page
  cache..." and the start card shows.
- When the copy of the last book cannot be read or opened, the reader
  forgets it and shows the start card.

getSrt and putSrt are gone. clearLastBook and dbReady are new, with
tests.

 index.html                  |   2 +-
 src/book-subtitles.ts       |  28 +++++
 src/main.ts                 | 257 +++++++++++++++++++++++++++++++++++++++-----
 src/messages.ts             |   6 ++
 src/status.ts               |   6 +-
 src/token-cache.ts          |  34 +++---
 test/book-subtitles.test.ts |  28 ++++-
 test/token-cache.test.ts    |  35 ++++--
 8 files changed, 337 insertions(+), 59 deletions(-)
diff --git a/index.html b/index.html
index 45c4eaf..7aadff2 100644
--- a/index.html
+++ b/index.html
@@ -148,7 +148,7 @@
 
     <main id="main">
       <div id="viewer">
-        <section id="empty" class="card">
+        <section id="empty" class="card" hidden>
           <h2>Scan Subread</h2>
           <p>
             Read a PDF along with its audiobook. The page follows the narrator, and a tap on a word
diff --git a/src/book-subtitles.ts b/src/book-subtitles.ts
index 899bfff..906f12d 100644
--- a/src/book-subtitles.ts
+++ b/src/book-subtitles.ts
@@ -4,3 +4,31 @@
 export function srtName(bookName: string): string {
   return `${bookName.replace(/\.pdf$/i, '')}.srt`;
 }
+
+/**
+ * What happens to the loaded subtitles when a book opens:
+ *
+ * - `keep`: they stay, and they are saved for the book.
+ * - `load`: the saved subtitles of the book replace them.
+ * - `clear`: they go, and the book has no subtitles.
+ */
+export type OpenStep = 'keep' | 'load' | 'clear';
+
+/**
+ * What happens to the loaded subtitles when the book `book` opens (see
+ * bookKey). `loaded` is the book of the loaded subtitles: null when they
+ * were loaded while no book was open, undefined when no subtitles are
+ * loaded. `saved` is true when the book has saved subtitles.
+ *
+ * Subtitles belong to a book. Subtitles that were loaded while no book was
+ * open are for the book that opens next, so they stay also when that book
+ * has saved subtitles.
+ */
+export function subtitlesOnOpen(
+  loaded: string | null | undefined,
+  book: string,
+  saved: boolean,
+): OpenStep {
+  if (loaded === null || loaded === book) return 'keep';
+  return saved ? 'load' : 'clear';
+}
diff --git a/src/main.ts b/src/main.ts
index 5809dfe..ea24bba 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,7 +6,7 @@
 // (the strip) and view.ts (the page).
 
 import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align';
-import { srtName } from './book-subtitles';
+import { srtName, subtitlesOnOpen } from './book-subtitles';
 import { bookText } from './book-text';
 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
 import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages';
@@ -18,6 +18,7 @@ import {
   clockText,
   EMPTY_FILE_TEXT,
   errorMessage,
+  LAST_BOOK_GONE_TEXT,
   matchedText,
   noCuesText,
   notReadText,
@@ -26,6 +27,8 @@ import {
   pdfOpenText,
   playerMessage,
   readingText,
+  UPDATING_CACHE_TEXT,
+  type Message,
 } from './messages';
 import { setupNav } from './nav';
 import type { Ocr } from './ocr';
@@ -38,12 +41,17 @@ import { isAndroid, SubRead } from './subread';
 import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
 import {
   bookKey,
+  clearLastBook,
   clearPages,
+  dbReady,
+  getLastBook,
+  getMeta,
   getPage,
-  getSrt,
   pageKey,
+  putLastBook,
+  putMeta,
   putPage,
-  putSrt,
+  type BookMeta,
   type BookSubtitles,
   type PageEntry,
 } from './token-cache';
@@ -55,6 +63,12 @@ const OCR_WIDTH = 1600;
 /** Milliseconds to wait after a page is read before the cues are aligned again. */
 const ALIGN_DEBOUNCE = 300;
 
+/** Milliseconds after a page change before the page goes to the meta data of the book. */
+const PAGE_SAVE_MS = 1000;
+
+/** After this many milliseconds, the strip tells that the database upgrade holds the reading. */
+const SLOW_DB_MS = 2000;
+
 const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest';
 
 function el<T extends HTMLElement>(id: string): T {
@@ -106,6 +120,8 @@ const ui = {
 const state = {
   pdf: null as PdfDoc | null,
   file: null as File | null,
+  /** The key of the open book (see bookKey), or null. */
+  book: null as string | null,
   /** The tokens of each page that is read. Index = page. */
   pages: [] as (PageEntry | undefined)[],
   /** The tokens of the pages read so far, in page order. */
@@ -126,6 +142,14 @@ const state = {
   alignTimer: null as ReturnType<typeof setTimeout> | null,
   /** The subtitles that are loaded, or null. */
   subtitles: null as BookSubtitles | null,
+  /** The book of the loaded subtitles. Null when they were loaded while no book was open. */
+  subtitlesBook: null as string | null,
+  /** The count of subtitle loads. The meta data of a book does not replace later subtitles. */
+  subtitlesSeq: 0,
+  /** The last message of the reading of the pages, or null. */
+  reading: null as Message | string | null,
+  /** True while the database is not open after SLOW_DB_MS. */
+  dbSlow: false,
   /** True while the player waits for a dictionary lookup that this app paused. */
   lookupPaused: false,
   /** True while a page turn by the user holds the follow. */
@@ -234,19 +258,32 @@ const opens = { tried: 0, shown: 0 };
 /**
  * Opens `file` as the book. The book that is open stays until the new
  * file loads: a file that is not a PDF, or is damaged, changes nothing.
- * Gives true when the new book is open.
+ *
+ * `meta` is what the reader keeps for the book, when the caller has it:
+ * the book opens at its page. Else the meta data comes from IndexedDB
+ * after the open. The page shows and the reading starts at once, and the
+ * meta data applies when it comes: an upgrade of the database can hold
+ * IndexedDB for many seconds. Gives true when the new book is open.
  */
-async function openBook(file: File): Promise<boolean> {
+async function openBook(file: File, meta?: BookMeta): Promise<boolean> {
   if (file.size === 0) {
     say(EMPTY_FILE_TEXT);
     return false;
   }
   const attempt = ++opens.tried;
+  let bytes: ArrayBuffer;
+  try {
+    bytes = await file.arrayBuffer();
+  } catch (err) {
+    // The copy of the last book is gone, for example after the storage was cleared.
+    say(meta ? LAST_BOOK_GONE_TEXT : `Cannot read ${file.name}: ${String(err)}`);
+    return false;
+  }
   let pdf: PdfDoc;
   try {
     // pdf.js loads with the first PDF, so the start card shows sooner.
     const { loadPdf } = await import('./pdf');
-    pdf = await loadPdf(await file.arrayBuffer());
+    pdf = await loadPdf(bytes);
   } catch (err) {
     say(pdfOpenText(err));
     return false;
@@ -257,9 +294,13 @@ async function openBook(file: File): Promise<boolean> {
     return false;
   }
   opens.shown = attempt;
+  const key = bookKey(file);
+  // The page of the book before goes to its meta data now.
+  flushPageSave();
   const old = state.pdf;
   state.pdf = pdf;
   state.file = file;
+  state.book = key;
   state.pages = [];
   state.tokens = [];
   state.spans = [];
@@ -267,27 +308,149 @@ async function openBook(file: File): Promise<boolean> {
   state.activeCue = -1;
   state.markedCue = -1;
   state.held = false;
+  // The subtitles of another book go at once. Subtitles that were loaded
+  // while no book was open wait for the meta data of this book.
+  if (state.subtitles && state.subtitlesBook !== null && state.subtitlesBook !== key) {
+    clearSubtitles();
+  }
+  const subtitlesSeq = state.subtitlesSeq;
   ui.makeSubs.disabled = false;
   ui.empty.hidden = true;
   ui.page.hidden = false;
   if (state.clock) showPlayerStatus(state.clock);
   say(pdfLoadedText(pdf.numPages));
   // showPage sets the current page at once, so the reading starts there.
-  void showPage(0);
+  void showPage(validPage(meta?.page, pdf.numPages) ?? 0);
   // readBook stops the reading of the old book before its document closes.
   void readBook();
   void old?.destroy();
   showSrtRow();
-  const saved = await getSrt(bookKey(file));
-  if (saved && state.file === file) {
-    loadSubtitles(
-      { name: srtName(file.name), text: saved, source: 'subread' },
-      'the last SubRead run',
-    );
+  if (meta) {
+    applyMeta(meta, attempt, subtitlesSeq);
+  } else {
+    void putLastBook(file);
+    void getMeta(key).then((m) => applyMeta(m, attempt, subtitlesSeq));
   }
   return true;
 }
 
+/** `page` when it is a page of a book of `pages` pages, else undefined. */
+function validPage(page: number | undefined, pages: number): number | undefined {
+  return page !== undefined && Number.isInteger(page) && page >= 0 && page < pages
+    ? page
+    : undefined;
+}
+
+/**
+ * Applies the meta data of the book that open attempt `attempt` opened.
+ * `subtitlesSeq` is the count of subtitle loads at the open: subtitles
+ * that the user loaded after the open stay. The page applies only while
+ * the first page shows, so a page turn after the open stays.
+ */
+function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void {
+  const key = state.book;
+  const pdf = state.pdf;
+  if (attempt !== opens.shown || key === null || !pdf) return;
+  const page = validPage(meta.page, pdf.numPages);
+  if (page !== undefined && page !== state.currentPage && state.currentPage === 0) {
+    void showPage(page);
+  }
+  if (state.subtitlesSeq !== subtitlesSeq) return;
+  const loaded = state.subtitles ? state.subtitlesBook : undefined;
+  switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) {
+    case 'keep':
+      if (state.subtitles) {
+        state.subtitlesBook = key;
+        void putMeta(key, { subtitles: state.subtitles });
+      }
+      break;
+    case 'load':
+      if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false });
+      break;
+    case 'clear':
+      clearSubtitles();
+      break;
+  }
+}
+
+// --- The page of the book, in its meta data ---
+
+/** The page that goes to the meta data of its book after PAGE_SAVE_MS. */
+let pageSave: { book: string; page: number; timer: ReturnType<typeof setTimeout> } | null = null;
+
+/** Keeps the current page for the book, after PAGE_SAVE_MS without another page change. */
+function savePageSoon(): void {
+  const book = state.book;
+  if (book === null) return;
+  if (pageSave) clearTimeout(pageSave.timer);
+  pageSave = { book, page: state.currentPage, timer: setTimeout(flushPageSave, PAGE_SAVE_MS) };
+}
+
+/** Keeps the page that waits now. */
+function flushPageSave(): void {
+  const save = pageSave;
+  if (!save) return;
+  pageSave = null;
+  clearTimeout(save.timer);
+  void putMeta(save.book, { page: save.page });
+}
+
+// A reload or a closed tab does not lose the last page turn.
+window.addEventListener('pagehide', flushPageSave);
+document.addEventListener('visibilitychange', () => {
+  if (document.hidden) flushPageSave();
+});
+
+// --- The last book ---
+
+/** Shows the start card, while no book is open. */
+function showStart(): void {
+  if (!state.pdf) ui.empty.hidden = false;
+}
+
+/**
+ * Opens the last book at its page, with its subtitles. Android stops the
+ * reader while the user is in the dictionary or in the player app, and a
+ * reload forgets the file that the user opened.
+ */
+async function restoreLastBook(): Promise<void> {
+  const file = await getLastBook();
+  // The user opened a book while the database was busy.
+  if (state.pdf) return;
+  if (!file) {
+    showStart();
+    return;
+  }
+  const meta = await getMeta(bookKey(file));
+  if (state.pdf) return;
+  if (await openBook(file, meta)) return;
+  // The copy cannot be read, or it is no PDF now: forget it.
+  await clearLastBook();
+  showStart();
+}
+
+/**
+ * The first open of the database after an upgrade can take many seconds.
+ * The strip tells so after SLOW_DB_MS, and the start card shows, so that
+ * the user can open a book.
+ */
+function watchDb(): void {
+  let open = false;
+  const timer = setTimeout(() => {
+    if (open) return;
+    state.dbSlow = true;
+    showReadingPart();
+    showStart();
+  }, SLOW_DB_MS);
+  void dbReady().then(() => {
+    open = true;
+    clearTimeout(timer);
+    if (!state.dbSlow) return;
+    state.dbSlow = false;
+    showReadingPart();
+  });
+}
+
 /** The tokens of one page: from the cache, else the text layer, else OCR. */
 async function readPage(
   pdf: PdfDoc,
@@ -341,7 +504,7 @@ async function readBook(): Promise<void> {
   let page = -1;
   const onOcrProgress = (p: number): void => {
     if (seq === state.readSeq) {
-      showReading(readingText(read(), total, `page ${page + 1}: OCR ${Math.round(p * 100)}%`));
+      setReading(readingText(read(), total, `page ${page + 1}: OCR ${Math.round(p * 100)}%`));
     }
   };
   // The OCR worker, and tesseract.js itself, load on the first page that needs them.
@@ -355,7 +518,7 @@ async function readBook(): Promise<void> {
     while (pending.size > 0) {
       page = nextPage(pending, state.currentPage);
       pending.delete(page);
-      showReading(readingText(read(), total, `page ${page + 1}`));
+      setReading(readingText(read(), total, `page ${page + 1}`));
       const entry = await readPage(pdf, file, page, getOcr);
       if (seq !== state.readSeq) return; // Another book or another setting took over.
       counts[entry.source]++;
@@ -364,7 +527,7 @@ async function readBook(): Promise<void> {
       const at = state.pages.slice(0, page).reduce((n, p) => n + (p?.tokens.length ?? 0), 0);
       state.tokens.splice(at, 0, ...entry.tokens);
       const how = entry.source === 'text' ? 'text layer' : 'OCR';
-      showReading(readingText(read(), total, `page ${page + 1}: ${how}`));
+      setReading(readingText(read(), total, `page ${page + 1}: ${how}`));
       if (page === state.currentPage) view.mark();
       scheduleAlign();
     }
@@ -377,10 +540,20 @@ async function readBook(): Promise<void> {
     if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`);
   } finally {
     if (ocr.started) await (await ocr.started).terminate().catch(() => undefined);
-    if (seq === state.readSeq) showReading(null);
+    if (seq === state.readSeq) setReading(null);
   }
 }
 
+/** Shows the reading part of the strip: the database notice, else the reading of the pages. */
+function showReadingPart(): void {
+  showReading(state.dbSlow ? UPDATING_CACHE_TEXT : state.reading);
+}
+
+function setReading(m: Message | string | null): void {
+  state.reading = m;
+  showReadingPart();
+}
+
 /** True when every page of the book is read. */
 function allPagesRead(): boolean {
   const pdf = state.pdf;
@@ -395,18 +568,25 @@ ui.clearCache.addEventListener('click', async () => {
 // --- Subtitles ---
 
 /**
- * Loads subtitles from a file or from SubRead. `from` names where they
- * came from in the message. `cues` are the cues of the text, when the
- * caller parsed them already.
+ * Loads subtitles from a file, from SubRead or from the meta data of the
+ * book. They belong to the open book, or to the book that opens next.
+ *
+ * - `from` names where they came from in the message.
+ * - `cues` are the cues of the text, when the caller parsed them already.
+ * - `save` keeps them in the meta data of the open book. The default is
+ *   true: each file and each SubRead result is kept.
  */
 function loadSubtitles(
   subtitles: BookSubtitles,
-  from = subtitles.name,
-  cues = parseSubtitles(subtitles.text),
+  opts: { from?: string; cues?: Cue[]; save?: boolean } = {},
 ): void {
+  const cues = opts.cues ?? parseSubtitles(subtitles.text);
   state.cues = cues;
   state.aligner = createAligner(cues.map((c) => c.text));
   state.subtitles = subtitles;
+  state.subtitlesBook = state.book;
+  state.subtitlesSeq++;
+  if (opts.save !== false && state.book !== null) void putMeta(state.book, { subtitles });
   state.spans = [];
   state.cueParts = [];
   state.activeCue = -1;
@@ -415,12 +595,30 @@ function loadSubtitles(
   renderCueList();
   const matched = align();
   say(
-    `${cues.length} cues loaded from ${from}.` +
+    `${cues.length} cues loaded from ${opts.from ?? subtitles.name}.` +
       (matched === null ? '' : ` ${matchedText(matched, cues.length, allPagesRead())}`),
   );
   showSrtRow();
 }
 
+/** Removes the subtitles: the cues, the cue list, the mark, the .srt row. */
+function clearSubtitles(): void {
+  state.cues = [];
+  state.aligner = null;
+  state.subtitles = null;
+  state.subtitlesBook = null;
+  state.spans = [];
+  state.cueParts = [];
+  state.activeCue = -1;
+  state.markedCue = -1;
+  updateSyncPage();
+  renderCueList();
+  showSrtRow();
+  view.mark();
+  // The Follow button goes: with no cues there is no page to follow.
+  runFollow('realign');
+}
+
 // A browser download of an .srt often has the type application/octet-stream,
 // and a type filter makes such a file grey in the Android picker. So the
 // field takes each file, and the parser decides.
@@ -447,7 +645,7 @@ ui.subFile.addEventListener('change', async () => {
     say(noCuesText(file.name));
     return;
   }
-  loadSubtitles({ name: file.name, text, source: 'file' }, file.name, cues);
+  loadSubtitles({ name: file.name, text, source: 'file' }, { cues });
 });
 
 /**
@@ -490,6 +688,8 @@ ui.makeSubs.addEventListener('click', async () => {
     bookText: bookText(state.tokens),
     language: ui.subLang.value,
   });
+  // The subtitles are for the book of the job, not for a book that opened since.
+  if (state.file !== file) return;
   if (result.error === 'not_installed') {
     ui.makeStatus.replaceChildren('SubRead is not installed. ');
     const a = document.createElement('a');
@@ -511,8 +711,10 @@ ui.makeSubs.addEventListener('click', async () => {
     (rate !== null && rate < 0.8
       ? ' Under 80% usually means another edition or the wrong language.'
       : '');
-  await putSrt(bookKey(file), result.srt);
-  loadSubtitles({ name: srtName(file.name), text: result.srt, source: 'subread' }, 'SubRead');
+  loadSubtitles(
+    { name: srtName(file.name), text: result.srt, source: 'subread' },
+    { from: 'SubRead' },
+  );
 });
 
 // --- Alignment ---
@@ -709,6 +911,7 @@ async function showPage(index: number): Promise<void> {
   if (!pdf || index < 0 || index >= pdf.numPages) return;
   state.currentPage = index;
   nav.update();
+  savePageSoon();
   try {
     await view.show(pdf, index);
   } catch (err) {
@@ -817,3 +1020,5 @@ ui.audioRow.hidden = isAndroid;
 ui.dictRow.hidden = !isAndroid;
 ui.pauseRow.hidden = !isAndroid;
 if (isAndroid) void loadDictionaries().catch(sayError);
+watchDb();
+void restoreLastBook();
diff --git a/src/messages.ts b/src/messages.ts
index 7b6573b..3745483 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -85,6 +85,12 @@ export function notReadText(read: number, total: number): string {
   return `This page is not read yet (${read}/${total} read).`;
 }
 
+/** The strip says this while the database upgrade holds the reading. */
+export const UPDATING_CACHE_TEXT = 'Updating the page cache...';
+
+/** The copy of the last book cannot be read, for example after the storage was cleared. */
+export const LAST_BOOK_GONE_TEXT = 'The last book is no longer in the app storage. Open it again.';
+
 /** A picked file of 0 bytes, for example a file that another app still writes. */
 export const EMPTY_FILE_TEXT = 'The file is empty or not ready. Try again in a moment.';
 
diff --git a/src/status.ts b/src/status.ts
index cb25cfb..e4b74c9 100644
--- a/src/status.ts
+++ b/src/status.ts
@@ -49,9 +49,9 @@ export function showPlayer(m: Message | null): void {
 }
 
 /** Shows how far the reading of the pages is. Null hides this part. */
-export function showReading(text: string | null): void {
-  reading.hidden = text === null;
-  reading.textContent = text ?? '';
+export function showReading(m: Message | string | null): void {
+  reading.hidden = m === null;
+  fill(reading, typeof m === 'string' ? { text: m } : (m ?? { text: '' }));
 }
 
 /** Shows an event, for example a lookup or an error. It fades after EVENT_MS. */
diff --git a/src/token-cache.ts b/src/token-cache.ts
index 73c528d..1b502a6 100644
--- a/src/token-cache.ts
+++ b/src/token-cache.ts
@@ -22,6 +22,7 @@ import {
   type StoreNames,
 } from 'idb';
 import type { OcrToken } from './align';
+import { srtName } from './book-subtitles';
 
 export type TextSource = 'text' | 'ocr';
 
@@ -124,8 +125,7 @@ async function moveSrtToMeta(
 /** Subtitles from SubRead for the book `key`. "book.pdf|123" gives the name "book.srt". */
 function subreadSubtitles(key: string, text: string): BookSubtitles {
   const bar = key.lastIndexOf('|');
-  const file = bar < 0 ? key : key.slice(0, bar);
-  return { name: `${file.replace(/\.pdf$/i, '')}.srt`, text, source: 'subread' };
+  return { name: srtName(bar < 0 ? key : key.slice(0, bar)), text, source: 'subread' };
 }
 
 /** Runs `op` on the database. Gives undefined when IndexedDB fails, and warns once. */
@@ -193,6 +193,20 @@ export function getLastBook(): Promise<File | undefined> {
   });
 }
 
+/** Removes the copy of the last book, for example when its data cannot be read. */
+export async function clearLastBook(): Promise<void> {
+  await tryDb((d) => d.delete('books', LAST));
+}
+
+/**
+ * Resolves when the database is open, or when IndexedDB failed. The first
+ * open after an upgrade can take long: an earlier build kept about 86 MB of
+ * dictionary data, and the upgrade deletes it.
+ */
+export async function dbReady(): Promise<void> {
+  await tryDb(() => Promise.resolve());
+}
+
 /** What the reader keeps for the book `key`. An empty object when there is nothing, or IndexedDB fails. */
 export async function getMeta(key: string): Promise<BookMeta> {
   return (await tryDb((d) => d.get('meta', key))) ?? {};
@@ -217,19 +231,3 @@ export async function putMeta(key: string, patch: Partial<BookMeta>): Promise<vo
     return Promise.all([merge(), tx.done]);
   });
 }
-
-/**
- * The text of the subtitles of the book `key`.
- * @deprecated Use getMeta. The next change of main.ts removes this function.
- */
-export async function getSrt(key: string): Promise<string | undefined> {
-  return (await getMeta(key)).subtitles?.text;
-}
-
-/**
- * Keeps the subtitles that SubRead made for the book `key`.
- * @deprecated Use putMeta. The next change of main.ts removes this function.
- */
-export async function putSrt(key: string, srt: string): Promise<void> {
-  await putMeta(key, { subtitles: subreadSubtitles(key, srt) });
-}
diff --git a/test/book-subtitles.test.ts b/test/book-subtitles.test.ts
index 962a237..452ea59 100644
--- a/test/book-subtitles.test.ts
+++ b/test/book-subtitles.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { srtName } from '../src/book-subtitles';
+import { srtName, subtitlesOnOpen } from '../src/book-subtitles';
 
 describe('srtName', () => {
   it('puts .srt in place of .pdf', () => {
@@ -13,3 +13,29 @@ describe('srtName', () => {
     expect(srtName('a.pdf.txt')).toBe('a.pdf.txt.srt');
   });
 });
+
+describe('subtitlesOnOpen', () => {
+  const eng = 'sample-eng-2p.pdf|4053';
+  const jpn = 'sample-jpn.pdf|20410';
+
+  it('loads the saved subtitles of the book, else clears', () => {
+    expect(subtitlesOnOpen(undefined, jpn, true)).toBe('load');
+    expect(subtitlesOnOpen(undefined, jpn, false)).toBe('clear');
+  });
+
+  it('does not keep the subtitles of another book', () => {
+    // The web finding: the cues of the English book stayed on the Japanese book.
+    expect(subtitlesOnOpen(eng, jpn, false)).toBe('clear');
+    expect(subtitlesOnOpen(eng, jpn, true)).toBe('load');
+  });
+
+  it('keeps subtitles that were loaded while no book was open, for the book that opens', () => {
+    expect(subtitlesOnOpen(null, jpn, false)).toBe('keep');
+    expect(subtitlesOnOpen(null, jpn, true)).toBe('keep');
+  });
+
+  it('keeps the subtitles of the same book', () => {
+    expect(subtitlesOnOpen(jpn, jpn, true)).toBe('keep');
+    expect(subtitlesOnOpen(jpn, jpn, false)).toBe('keep');
+  });
+});
diff --git a/test/token-cache.test.ts b/test/token-cache.test.ts
index ff7e0b9..716cc97 100644
--- a/test/token-cache.test.ts
+++ b/test/token-cache.test.ts
@@ -137,16 +137,31 @@ describe('token cache', () => {
     expect(await cache.getMeta('neko.pdf|10')).toEqual({ page: 1, forceOcr: true });
   });
 
-  it('keeps the subtitles of putSrt in the meta data', async () => {
+  it('forgets the last book, and keeps the meta data and the pages', async () => {
     const cache = await freshCache();
-    await cache.putMeta('neko.pdf|10', { page: 2 });
-    await cache.putSrt('neko.pdf|10', srt);
-    expect(await cache.getSrt('neko.pdf|10')).toBe(srt);
-    expect(await cache.getMeta('neko.pdf|10')).toEqual({
-      page: 2,
-      subtitles: { name: 'neko.srt', text: srt, source: 'subread' },
+    const neko = new File(['%PDF-1.7 猫'], 'neko.pdf', { type: 'application/pdf' });
+    await cache.putLastBook(neko);
+    await cache.putMeta(cache.bookKey(neko), { page: 2 });
+    await cache.putPage(cache.pageKey(neko, 0, 'jpn', 'ocr'), entry);
+    await cache.clearLastBook();
+    expect(await cache.getLastBook()).toBeUndefined();
+    expect(await cache.getMeta(cache.bookKey(neko))).toEqual({ page: 2 });
+    expect(await cache.getPage(cache.pageKey(neko, 0, 'jpn', 'ocr'))).toEqual(entry);
+    // No last book: nothing to forget.
+    await expect(cache.clearLastBook()).resolves.toBeUndefined();
+  });
+
+  it('tells when the database is open, after the upgrade', async () => {
+    const old = await openDB('scan-subread', 1, {
+      upgrade(d) {
+        d.createObjectStore('dictionaries');
+        d.createObjectStore('terms');
+      },
     });
-    expect(await cache.getSrt('inu.pdf|20')).toBeUndefined();
+    old.close();
+    const cache = await freshCache();
+    await cache.dbReady();
+    expect(await schema()).toEqual({ version: 3, stores: ['books', 'meta', 'pages'] });
   });
 
   it('clears the pages of one book, or the pages of all books', async () => {
@@ -205,8 +220,8 @@ describe('token cache', () => {
     await expect(cache.putMeta('b', { page: 1 })).resolves.toBeUndefined();
     await expect(cache.putLastBook(new File(['x'], 'b.pdf'))).resolves.toBe(false);
     await expect(cache.getLastBook()).resolves.toBeUndefined();
-    await expect(cache.getSrt('b')).resolves.toBeUndefined();
-    await expect(cache.putSrt('b', srt)).resolves.toBeUndefined();
+    await expect(cache.clearLastBook()).resolves.toBeUndefined();
+    await expect(cache.dbReady()).resolves.toBeUndefined();
     expect(warn).toHaveBeenCalledTimes(1);
   });