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


src/main.ts (51478 bytes)

1 // UI wiring: the state of the book, the subtitles and the follow. The
2 // logic lives in the pure modules (align.ts, cue-pages.ts, follower.ts,
3 // hit-test.ts, line-boxes.ts, messages.ts, paging.ts, read-order.ts,
4 // scroll.ts, text-layer.ts, player-state.ts, play-clock.ts). The UI parts
5 // are in drawer.ts (the menu), nav.ts (page turns by the user), status.ts
6 // (the strip) and view.ts (the page).
7 
8 import { App } from '@capacitor/app';
9 import { createAligner, shiftSpans, type Aligner, type OcrToken, type TokenSpan } from './align';
10 import { ankiCard, cardSource } from './anki-card';
11 import { backStep, keepScreenOn } from './app-state';
12 import {
13   beginOpen,
14   createOpens,
15   endOpen,
16   failureShows,
17   lateMeta,
18   subtitlesOwner,
19   userOpened,
20   type Changes,
21 } from './book-open';
22 import {
23   keepMakeStatus,
24   resultName,
25   srtName,
26   subreadConcerns,
27   subtitlesOnOpen,
28 } from './book-subtitles';
29 import { bookText } from './book-text';
30 import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source';
31 import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages';
32 import { setupDrawer } from './drawer';
33 import { follow, type FollowEvent, type FollowMode } from './follower';
34 import { lookupText, tapTolerance, tokenAt } from './hit-test';
35 import { lineBoxes, padBox } from './line-boxes';
36 import {
37   ANKI_WEB_TEXT,
38   ankiText,
39   CLEAR_ALL_QUESTION,
40   clearBookQuestion,
41   clockText,
42   EMPTY_FILE_TEXT,
43   errorMessage,
44   keptResultText,
45   LAST_BOOK_GONE_TEXT,
46   matchedText,
47   noCuesText,
48   notReadText,
49   pagesReadText,
50   pdfLoadedText,
51   pdfOpenText,
52   playerCommandsOff,
53   playerMessage,
54   readingEndMessage,
55   readingText,
56   shownInPlayerPart,
57   SUBREAD_NOT_INSTALLED,
58   subreadErrorText,
59   subreadQuestion,
60   subreadResultText,
61   UPDATING_CACHE_TEXT,
62   waitMessage,
63   withForceOcr,
64   type Message,
65 } from './messages';
66 import { setupNav } from './nav';
67 import { createOcrJob, OcrStartError, pageTimeLimit, type OcrJob } from './ocr-job';
68 import { defaultRtl } from './paging';
69 import type { PdfDoc } from './pdf';
70 import { nextPage } from './read-order';
71 import { scrollTarget } from './scroll';
72 import { fill, onAction, say, showPlayer, showReading } from './status';
73 import { isAndroid, SubRead, type AnkiCard, type SubtitlesResult, type SuiteApps } from './subread';
74 import { suiteChecklist } from './suite';
75 import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles';
76 import {
77   bookKey,
78   bookName,
79   clearLastBook,
80   clearPages,
81   dbReady,
82   getLastBook,
83   getMeta,
84   getPage,
85   pageKey,
86   putLastBook,
87   putMeta,
88   putPage,
89   type BookMeta,
90   type BookSubtitles,
91   type KeptResult,
92   type PageEntry,
93 } from './token-cache';
94 import { createPageView, type Marks } from './view';
95 
96 /** Page width in pixels for OCR and the text layer. Boxes are stored in this scale. */
97 const OCR_WIDTH = 1600;
98 
99 /** Milliseconds to wait after a page is read before the cues are aligned again. */
100 const ALIGN_DEBOUNCE = 300;
101 
102 /** Milliseconds after a page change before the page goes to the meta data of the book. */
103 const PAGE_SAVE_MS = 1000;
104 
105 /** After this many milliseconds, the strip tells that the database upgrade holds the reading. */
106 const SLOW_DB_MS = 2000;
107 
108 function el<T extends HTMLElement>(id: string): T {
109   const e = document.getElementById(id);
110   if (!e) throw new Error(`Missing element #${id}`);
111   return e as T;
112 }
113 
114 const ui = {
115   menu: el<HTMLButtonElement>('menu'),
116   panel: el<HTMLElement>('panel'),
117   pdfFile: el<HTMLInputElement>('pdf-file'),
118   subFile: el<HTMLInputElement>('sub-file'),
119   make: el<HTMLElement>('make'),
120   makeSubs: el<HTMLButtonElement>('make-subs'),
121   subLang: el<HTMLSelectElement>('sub-lang'),
122   makeStatus: el<HTMLDivElement>('make-status'),
123   srtRow: el<HTMLDivElement>('srt-row'),
124   srtDownload: el<HTMLAnchorElement>('srt-download'),
125   srtShare: el<HTMLButtonElement>('srt-share'),
126   audioRow: el<HTMLElement>('audio-row'),
127   audioFile: el<HTMLInputElement>('audio-file'),
128   audio: el<HTMLAudioElement>('audio'),
129   follow: el<HTMLFieldSetElement>('follow'),
130   lang: el<HTMLSelectElement>('lang'),
131   forceOcr: el<HTMLInputElement>('force-ocr'),
132   dictRow: el<HTMLLabelElement>('dict-row'),
133   dict: el<HTMLSelectElement>('dict'),
134   pauseRow: el<HTMLLabelElement>('pause-row'),
135   pauseLookup: el<HTMLInputElement>('pause-lookup'),
136   clearBook: el<HTMLButtonElement>('clear-book'),
137   clearAll: el<HTMLButtonElement>('clear-all'),
138   suite: el<HTMLElement>('suite'),
139   suiteList: el<HTMLUListElement>('suite-list'),
140   suiteChecklist: el<HTMLDivElement>('suite-checklist'),
141   cues: el<HTMLOListElement>('cues'),
142   pageLeft: el<HTMLButtonElement>('page-left'),
143   pageRight: el<HTMLButtonElement>('page-right'),
144   play: el<HTMLButtonElement>('play'),
145   syncPage: el<HTMLButtonElement>('sync-page'),
146   pageLabel: el<HTMLButtonElement>('page-label'),
147   pageInput: el<HTMLInputElement>('page-input'),
148   followNow: el<HTMLButtonElement>('follow-now'),
149   followNote: el<HTMLSpanElement>('follow-note'),
150   rtl: el<HTMLInputElement>('rtl'),
151   viewer: el<HTMLDivElement>('viewer'),
152   empty: el<HTMLElement>('empty'),
153   openPdf: el<HTMLButtonElement>('open-pdf'),
154   page: el<HTMLDivElement>('page'),
155   overlay: el<HTMLDivElement>('overlay'),
156 };
157 
158 const state = {
159   pdf: null as PdfDoc | null,
160   file: null as File | null,
161   /** The key of the open book (see bookKey), or null. */
162   book: null as string | null,
163   /** The tokens of each page that is read. Index = page. */
164   pages: [] as (PageEntry | undefined)[],
165   /** The tokens of the pages read so far, in page order. */
166   tokens: [] as OcrToken[],
167   cues: [] as Cue[],
168   /** The aligner of the loaded subtitles. It keeps the result of each page. */
169   aligner: null as Aligner | null,
170   spans: [] as TokenSpan[],
171   /** The parts of each cue on the pages. Empty for an unmatched cue. */
172   cueParts: [] as CueParts[],
173   currentPage: -1,
174   /** The cue of the last clock state, or -1. */
175   activeCue: -1,
176   /** The cue whose box is drawn on the page, or -1. */
177   markedCue: -1,
178   clock: null as ClockState | null,
179   readSeq: 0,
180   alignTimer: null as ReturnType<typeof setTimeout> | null,
181   /** The subtitles that are loaded, or null. */
182   subtitles: null as BookSubtitles | null,
183   /** The book of the loaded subtitles. Null when they were loaded while no book was open. */
184   subtitlesBook: null as string | null,
185   /** The count of subtitle loads. The meta data of a book does not replace later subtitles. */
186   subtitlesSeq: 0,
187   /** The last message of the reading of the pages, or null. */
188   reading: null as Message | string | null,
189   /** True while the database is not open after SLOW_DB_MS. */
190   dbSlow: false,
191   /** "Force OCR" of the open book: the reading skips the text layer. */
192   forceOcr: false,
193   /** What changed after the open of the book. Its meta data does not undo it (lateMeta). */
194   changed: { page: false, forceOcr: false } as Changes,
195   /** A SubRead job that waits until all pages of its book are read. */
196   makeWait: null as { audio: { uri: string; name: string }; book: string } | null,
197   /** The book of the SubRead job that runs, or null. */
198   making: null as string | null,
199   /** True while the player waits for a dictionary lookup that this app paused. */
200   lookupPaused: false,
201   /** False while the app is in the background. */
202   active: true,
203   /** True while the plugin keeps the screen on. */
204   awake: false,
205   /** The apps of the SubRead suite on the device, or null before the first answer. */
206   suite: null as SuiteApps | null,
207   /** The player error that the checklist shows. Undefined before the first player state. */
208   suiteError: undefined as string | null | undefined,
209   /** True while a page turn by the user holds the follow. */
210   held: false,
211 };
212 
213 const view = createPageView(ui, marks);
214 
215 // The cue list can show the cue of now only when the drawer opens.
216 const drawer = setupDrawer(ui, () => scrollCueList(state.activeCue));
217 
218 const nav = setupNav(
219   ui,
220   {
221     pages: () => state.pdf?.numPages ?? 0,
222     current: () => state.currentPage,
223     rtl: () => ui.rtl.checked,
224     drawerOpen: drawer.covers,
225   },
226   turnByUser,
227   longPress,
228 );
229 
230 // --- Settings ---
231 
232 const settings = {
233   get(key: string, fallback: string): string {
234     return localStorage.getItem(`scan-subread.${key}`) ?? fallback;
235   },
236   set(key: string, value: string): void {
237     localStorage.setItem(`scan-subread.${key}`, value);
238   },
239 };
240 
241 function followMode(): FollowMode {
242   const checked = ui.follow.querySelector<HTMLInputElement>('input:checked');
243   return (checked?.value as FollowMode | undefined) ?? 'highlight';
244 }
245 
246 function loadSettings(): void {
247   const mode = settings.get('follow', 'highlight');
248   for (const input of ui.follow.querySelectorAll<HTMLInputElement>('input')) {
249     input.checked = input.value === mode;
250   }
251   ui.lang.value = settings.get('lang', 'eng');
252   ui.subLang.value = settings.get('subLang', 'auto');
253   ui.pauseLookup.checked = settings.get('pauseLookup', '1') === '1';
254   loadRtl();
255 }
256 
257 /**
258  * The reading direction: the setting of the user, else the default for
259  * the OCR language, so that vertical Japanese turns right to left.
260  */
261 function loadRtl(): void {
262   const saved = settings.get('rtl', '');
263   ui.rtl.checked = saved === '' ? defaultRtl(ui.lang.value) : saved === '1';
264   nav.update();
265 }
266 
267 ui.rtl.addEventListener('change', () => {
268   settings.set('rtl', ui.rtl.checked ? '1' : '0');
269   nav.update();
270 });
271 
272 // A new follow mode follows the audio at once. "Off" lets the screen turn off.
273 ui.follow.addEventListener('change', () => {
274   settings.set('follow', followMode());
275   runFollow('follow');
276   updateAwake();
277 });
278 ui.pauseLookup.addEventListener('change', () =>
279   settings.set('pauseLookup', ui.pauseLookup.checked ? '1' : '0'),
280 );
281 ui.subLang.addEventListener('change', () => settings.set('subLang', ui.subLang.value));
282 ui.lang.addEventListener('change', () => {
283   settings.set('lang', ui.lang.value);
284   loadRtl();
285   void readBook();
286 });
287 // Force OCR is a setting of the book. As a global setting it sent each
288 // later text PDF through the slow OCR.
289 ui.forceOcr.addEventListener('change', () => {
290   const book = state.book;
291   if (book === null) return;
292   // Meta data of the book that comes later does not undo this (lateMeta).
293   state.changed.forceOcr = true;
294   setForceOcr(ui.forceOcr.checked);
295   void putMeta(book, { forceOcr: state.forceOcr });
296   void readBook();
297 });
298 
299 /** Sets Force OCR of the open book: the state, the checkbox and the strip. */
300 function setForceOcr(on: boolean): void {
301   state.forceOcr = on;
302   ui.forceOcr.checked = on;
303   showReadingPart();
304 }
305 
306 /**
307  * Shows a command that failed in the strip. The player part shows the
308  * problem of the player while a book is open, so the event part does not
309  * say it again.
310  */
311 function sayError(err: unknown): void {
312   if (state.pdf && shownInPlayerPart(err, state.clock?.error)) return;
313   say(errorMessage(err, isAndroid, overlayInstalled()));
314 }
315 
316 // --- The PDF ---
317 
318 ui.openPdf.addEventListener('click', () => ui.pdfFile.click());
319 
320 ui.pdfFile.addEventListener('change', () => {
321   const file = ui.pdfFile.files?.[0];
322   // The same file can be picked again.
323   ui.pdfFile.value = '';
324   if (!file) return;
325   drawer.closeOnPhone();
326   void openBook(file);
327 });
328 
329 /** The attempts to open a book, and the attempt of the book that is open. */
330 const opens = createOpens();
331 
332 /** The restore of the last book: its meta data, and opens.byUser when the restore started. */
333 interface Restore {
334   meta: BookMeta;
335   since: number;
336 }
337 
338 /**
339  * Opens `file` as the book. The book that is open stays until the new
340  * file loads: a file that is not a PDF, or is damaged, changes nothing.
341  *
342  * `restore` comes from the restore of the last book: the book opens at the
343  * page of its meta data. An open without it is a file that the user
344  * picked, and its meta data comes from IndexedDB after the open. The page
345  * shows and the reading starts at once, and the meta data applies when it
346  * comes: an upgrade of the database can hold IndexedDB for many seconds.
347  * Gives true when the new book is open.
348  */
349 async function openBook(file: File, restore?: Restore): Promise<boolean> {
350   const meta = restore?.meta;
351   const attempt = beginOpen(opens, restore === undefined);
352   /** Ends the attempt that failed. The strip shows `text` only when failureShows allows it. */
353   const fail = (text: string): false => {
354     endOpen(opens, attempt, false);
355     claimSubtitles();
356     if (failureShows(opens, attempt, restore?.since)) say(text);
357     return false;
358   };
359   if (file.size === 0) return fail(EMPTY_FILE_TEXT);
360   let bytes: ArrayBuffer;
361   try {
362     bytes = await file.arrayBuffer();
363   } catch (err) {
364     // The copy of the last book is gone, for example after the storage was cleared.
365     return fail(restore ? LAST_BOOK_GONE_TEXT : `Cannot read ${file.name}: ${String(err)}`);
366   }
367   let pdf: PdfDoc;
368   try {
369     // pdf.js loads with the first PDF, so the start card shows sooner.
370     const { loadPdf } = await import('./pdf');
371     pdf = await loadPdf(bytes);
372   } catch (err) {
373     return fail(pdfOpenText(err));
374   }
375   // A file that the user picked later is open already.
376   if (!endOpen(opens, attempt, true)) {
377     void pdf.destroy();
378     claimSubtitles();
379     return false;
380   }
381   const key = bookKey(file);
382   // The page of the book before goes to its meta data now.
383   flushPageSave();
384   const old = state.pdf;
385   state.pdf = pdf;
386   state.file = file;
387   state.book = key;
388   state.pages = [];
389   state.tokens = [];
390   state.spans = [];
391   state.cueParts = [];
392   state.activeCue = -1;
393   state.markedCue = -1;
394   state.held = false;
395   state.changed = { page: false, forceOcr: false };
396   // The subtitles of another book go at once. Subtitles of no book, loaded
397   // while no book was open or while this book loaded, are for this book.
398   if (state.subtitles && state.subtitlesBook !== null && state.subtitlesBook !== key) {
399     clearSubtitles();
400   }
401   claimSubtitles();
402   const subtitlesSeq = state.subtitlesSeq;
403   // The SubRead status of the book before goes, unless a job runs.
404   if (!keepMakeStatus(key, state.making, state.makeWait?.book ?? null)) {
405     ui.makeStatus.textContent = '';
406   }
407   // A SubRead job that waits for the pages of another book does not start.
408   if (state.makeWait && state.makeWait.book !== key) state.makeWait = null;
409   setForceOcr(meta?.forceOcr ?? false);
410   ui.forceOcr.disabled = false;
411   ui.clearBook.disabled = false;
412   // One SubRead job at a time.
413   ui.makeSubs.disabled = state.making !== null;
414   ui.empty.hidden = true;
415   ui.page.hidden = false;
416   if (state.clock) showPlayerStatus(state.clock);
417   say(pdfLoadedText(pdf.numPages));
418   // showPage sets the current page at once, so the reading starts there.
419   // This page is the saved page, or page 1 until the meta data comes: no
420   // save, so page 1 does not go over the saved page of the book.
421   void showPage(validPage(meta?.page, pdf.numPages) ?? 0, false);
422   // readBook stops the reading of the old book before its document closes.
423   void readBook();
424   void old?.destroy();
425   showSrtRow();
426   if (meta) {
427     applyMeta(meta, attempt, subtitlesSeq);
428   } else {
429     void putLastBook(file);
430     void getMeta(key).then((m) => applyMeta(m, attempt, subtitlesSeq));
431   }
432   return true;
433 }
434 
435 /** `page` when it is a page of a book of `pages` pages, else undefined. */
436 function validPage(page: number | undefined, pages: number): number | undefined {
437   return page !== undefined && Number.isInteger(page) && page >= 0 && page < pages
438     ? page
439     : undefined;
440 }
441 
442 /**
443  * Applies the meta data of the book that open attempt `attempt` opened.
444  * `subtitlesSeq` is the count of subtitle loads at the open: subtitles
445  * that the user loaded after the open stay. A page turn and a change of
446  * "Force OCR" after the open stay too (lateMeta).
447  */
448 function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void {
449   const key = state.book;
450   const pdf = state.pdf;
451   if (attempt !== opens.shown || key === null || !pdf) return;
452   const late = lateMeta(meta, state.changed);
453   const page = validPage(late.page, pdf.numPages);
454   // The saved page needs no new save.
455   if (page !== undefined && page !== state.currentPage) void showPage(page, false);
456   if (late.forceOcr !== undefined && late.forceOcr !== state.forceOcr) {
457     setForceOcr(late.forceOcr);
458     void readBook();
459   }
460   if (state.subtitlesSeq === subtitlesSeq) {
461     const loaded = state.subtitles ? state.subtitlesBook : undefined;
462     switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) {
463       case 'keep':
464         claimSubtitles();
465         break;
466       case 'load':
467         if (meta.subtitles) loadSubtitles(meta.subtitles, { book: key, save: false });
468         break;
469       case 'clear':
470         clearSubtitles();
471         break;
472     }
473   }
474   // After the saved subtitles, so that a SubRead result asks first when it
475   // replaces a file of the user. A result that came while another book was
476   // open gets the same offer as the result for the open book.
477   const kept = meta.subread;
478   if (kept) {
479     void putMeta(key, { subread: undefined });
480     if (offerSubreadSubtitles(kept.srt, kept)) ui.makeStatus.textContent = subreadResultText(kept);
481   }
482   void checkPendingSubtitles();
483 }
484 
485 // --- The page of the book, in its meta data ---
486 
487 /** The page that goes to the meta data of its book after PAGE_SAVE_MS. */
488 let pageSave: { book: string; page: number; timer: ReturnType<typeof setTimeout> } | null = null;
489 
490 /** Keeps the current page for the book, after PAGE_SAVE_MS without another page change. */
491 function savePageSoon(): void {
492   const book = state.book;
493   if (book === null) return;
494   if (pageSave) clearTimeout(pageSave.timer);
495   pageSave = { book, page: state.currentPage, timer: setTimeout(flushPageSave, PAGE_SAVE_MS) };
496 }
497 
498 /** Keeps the page that waits now. */
499 function flushPageSave(): void {
500   const save = pageSave;
501   if (!save) return;
502   pageSave = null;
503   clearTimeout(save.timer);
504   void putMeta(save.book, { page: save.page });
505 }
506 
507 // A reload or a closed tab does not lose the last page turn.
508 window.addEventListener('pagehide', flushPageSave);
509 document.addEventListener('visibilitychange', () => {
510   if (document.hidden) flushPageSave();
511 });
512 
513 // --- The last book ---
514 
515 /** Shows the start card, while no book is open. */
516 function showStart(): void {
517   if (!state.pdf) ui.empty.hidden = false;
518 }
519 
520 /**
521  * Opens the last book at its page, with its subtitles. Android stops the
522  * reader while the user is in the dictionary or in the player app, and a
523  * reload forgets the file that the user opened.
524  *
525  * A book that the user opens while the restore waits for the database
526  * wins, also while it still loads (userOpened).
527  */
528 async function restoreLastBook(): Promise<void> {
529   const since = opens.byUser;
530   const file = await getLastBook();
531   if (!file || userOpened(opens, since)) {
532     showStart();
533     return;
534   }
535   const meta = await getMeta(bookKey(file));
536   if (userOpened(opens, since)) {
537     showStart();
538     return;
539   }
540   if (await openBook(file, { meta, since })) return;
541   // The copy cannot be read, or it is no PDF now: forget it. After an open
542   // by the user, the copy can be of the user's book, so it stays.
543   if (!userOpened(opens, since)) await clearLastBook(bookKey(file));
544   showStart();
545 }
546 
547 /**
548  * The first open of the database after an upgrade can take many seconds.
549  * The strip tells so after SLOW_DB_MS, and the start card shows, so that
550  * the user can open a book.
551  */
552 function watchDb(): void {
553   let open = false;
554   const timer = setTimeout(() => {
555     if (open) return;
556     state.dbSlow = true;
557     showReadingPart();
558     showStart();
559   }, SLOW_DB_MS);
560   void dbReady().then(() => {
561     open = true;
562     clearTimeout(timer);
563     if (!state.dbSlow) return;
564     state.dbSlow = false;
565     showReadingPart();
566   });
567 }
568 
569 /**
570  * The tokens of one page: from the cache, else the text layer, else OCR.
571  * `force` skips the text layer. Rejects with OcrStartError when the page
572  * needs OCR and OCR cannot start or its start takes too long, and with
573  * OcrTimeoutError when the OCR of the page takes too long.
574  */
575 async function readPage(
576   pdf: PdfDoc,
577   file: File,
578   page: number,
579   lang: string,
580   force: boolean,
581   ocr: OcrJob<HTMLCanvasElement>,
582 ): Promise<PageEntry> {
583   if (!force) {
584     const cached = await getPage(pageKey(file, page, lang, 'text'));
585     if (cached) return cached;
586   }
587   const cachedOcr = await getPage(pageKey(file, page, lang, 'ocr'));
588   if (cachedOcr) return cachedOcr;
589   if (!force) {
590     const text = await pdf.pageText(page, OCR_WIDTH);
591     if (text.tokens) {
592       const entry: PageEntry = { ...text, tokens: text.tokens, source: 'text' };
593       await putPage(pageKey(file, page, lang, 'text'), entry);
594       return entry;
595     }
596   }
597   // OCR starts before the render: when it cannot start, no page is rendered for nothing.
598   await ocr.start();
599   const canvas = await pdf.renderPage(page, OCR_WIDTH);
600   const tokens = await ocr.recognize(canvas, page, pageTimeLimit(canvas.width * canvas.height));
601   const entry: PageEntry = { tokens, width: canvas.width, height: canvas.height, source: 'ocr' };
602   await putPage(pageKey(file, page, lang, 'ocr'), entry);
603   return entry;
604 }
605 
606 /** The OCR of the reading that runs now. */
607 let readingOcr: OcrJob<HTMLCanvasElement> | undefined;
608 
609 /** True while readBook reads the pages. */
610 let readingRuns = false;
611 
612 /**
613  * Reads every page of the book: the current page first, then the pages
614  * after it, then the pages before it. The cues are aligned again as pages
615  * finish, so the follow starts before the whole book is read.
616  *
617  * A page that cannot be read does not stop the other pages. At the end the
618  * strip tells how many pages could not be read, with Retry. `retry` reads
619  * only the pages that are not read.
620  */
621 async function readBook(retry = false): Promise<void> {
622   const { pdf, file } = state;
623   if (!pdf || !file) return;
624   const seq = ++state.readSeq;
625   readingRuns = true;
626   // The OCR of the reading before stops at once. It does not finish its
627   // page: two quick changes of a setting ran two OCR jobs at the same time.
628   readingOcr?.stop();
629   if (!retry) {
630     state.pages = [];
631     state.tokens = [];
632     // The spans index the old tokens, so they go too.
633     state.spans = [];
634     state.cueParts = [];
635     state.markedCue = -1;
636   }
637   // A SubRead job that waits shows the reading again, for example after Retry.
638   startMakeWhenRead();
639   const total = pdf.numPages;
640   const pending = new Set(
641     Array.from({ length: total }, (_, i) => i).filter((i) => !state.pages[i]),
642   );
643   const counts = { text: 0, ocr: 0 };
644   for (const entry of state.pages) if (entry) counts[entry.source]++;
645   const read = (): number => counts.text + counts.ocr;
646   let failed = 0;
647   let startFailed = false;
648   const lang = ui.lang.value;
649   const force = state.forceOcr;
650   /** The page that is read now. */
651   let page = -1;
652   const onOcrProgress = (p: number): void => {
653     if (seq === state.readSeq) {
654       setReading(readingText(read(), total, `page ${page + 1}: OCR ${Math.round(p * 100)}%`));
655     }
656   };
657   // The OCR worker, and tesseract.js itself, load on the first page that
658   // needs them. After a page that fails or takes too long, the job stops
659   // the worker, and the next page starts a new one. A start that takes too
660   // long gives each page the start error, and the strip shows Retry.
661   const ocr = createOcrJob<HTMLCanvasElement>((signal) =>
662     import('./ocr').then(
663       ({ createOcr }) => createOcr(lang, onOcrProgress, signal),
664       (err: unknown) => {
665         throw new OcrStartError(err);
666       },
667     ),
668   );
669   readingOcr = ocr;
670   try {
671     while (pending.size > 0) {
672       page = nextPage(pending, state.currentPage);
673       pending.delete(page);
674       setReading(readingText(read(), total, `page ${page + 1}`));
675       let entry: PageEntry;
676       try {
677         entry = await readPage(pdf, file, page, lang, force, ocr);
678       } catch (err) {
679         if (seq !== state.readSeq) return; // Another book or another setting took over.
680         failed++;
681         // Each page that needs OCR gives the same start error. Tell it once.
682         if (err instanceof OcrStartError) {
683           if (!startFailed) console.warn(err);
684           startFailed = true;
685         } else {
686           console.warn(`Page ${page + 1} could not be read.`, err);
687         }
688         continue;
689       }
690       if (seq !== state.readSeq) return; // Another book or another setting took over.
691       counts[entry.source]++;
692       state.pages[page] = entry;
693       // Put the tokens of the page at their place in page order. The spans
694       // keep their tokens until the next alignment: the mark and the Anki
695       // card use them.
696       const at = state.pages.slice(0, page).reduce((n, p) => n + (p?.tokens.length ?? 0), 0);
697       state.tokens.splice(at, 0, ...entry.tokens);
698       state.spans = shiftSpans(state.spans, at, entry.tokens.length);
699       const how = entry.source === 'text' ? 'text layer' : 'OCR';
700       setReading(readingText(read(), total, `page ${page + 1}: ${how}`));
701       if (page === state.currentPage) view.mark();
702       scheduleAlign();
703       startMakeWhenRead();
704     }
705     const matched = align();
706     say(
707       pagesReadText(counts) +
708         (matched === null ? '' : ` ${matchedText(matched, state.cues.length, failed === 0)}`),
709     );
710   } catch (err) {
711     if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`);
712   } finally {
713     ocr.stop();
714     if (seq === state.readSeq) {
715       readingRuns = false;
716       setReading(readingEndMessage(failed, startFailed));
717       // A SubRead job that waits now waits for pages that could not be read.
718       startMakeWhenRead();
719     }
720   }
721 }
722 
723 /**
724  * Shows the reading part of the strip: the database notice, else the
725  * reading of the pages, with the note while Force OCR is on.
726  */
727 function showReadingPart(): void {
728   if (state.dbSlow) {
729     showReading(UPDATING_CACHE_TEXT);
730     return;
731   }
732   const m = typeof state.reading === 'string' ? { text: state.reading } : state.reading;
733   showReading(withForceOcr(m, state.forceOcr && state.book !== null));
734 }
735 
736 function setReading(m: Message | string | null): void {
737   state.reading = m;
738   showReadingPart();
739 }
740 
741 /** True when every page of the book is read. */
742 function allPagesRead(): boolean {
743   const pdf = state.pdf;
744   return !!pdf && state.pages.filter((p) => p).length === pdf.numPages;
745 }
746 
747 // The pages of other books cost hours of OCR, so each clear asks first.
748 ui.clearBook.addEventListener('click', async () => {
749   const { book, file } = state;
750   if (book === null || !file || !confirm(clearBookQuestion(file.name))) return;
751   await clearPages(book);
752   say(`The saved pages of ${file.name} are removed.`);
753 });
754 
755 ui.clearAll.addEventListener('click', async () => {
756   if (!confirm(CLEAR_ALL_QUESTION)) return;
757   await clearPages();
758   say('The saved pages of all books are removed.');
759 });
760 
761 // --- Subtitles ---
762 
763 /**
764  * Loads subtitles from a file, from SubRead or from the meta data of the
765  * book.
766  *
767  * - `book` is the book that they belong to. Null: no book yet, and the
768  *   book that shows when no open loads gets them (claimSubtitles).
769  * - `from` names where they came from in the message.
770  * - `cues` are the cues of the text, when the caller parsed them already.
771  * - `save` keeps them in the meta data of `book`. The default is true:
772  *   each file and each SubRead result is kept.
773  */
774 function loadSubtitles(
775   subtitles: BookSubtitles,
776   opts: { book: string | null; from?: string; cues?: Cue[]; save?: boolean },
777 ): void {
778   const cues = opts.cues ?? parseSubtitles(subtitles.text);
779   state.cues = cues;
780   state.aligner = createAligner(cues.map((c) => c.text));
781   state.subtitles = subtitles;
782   state.subtitlesBook = opts.book;
783   state.subtitlesSeq++;
784   if (opts.save !== false && opts.book !== null) void putMeta(opts.book, { subtitles });
785   state.spans = [];
786   state.cueParts = [];
787   state.activeCue = -1;
788   state.markedCue = -1;
789   updateSyncPage();
790   renderCueList();
791   const matched = align();
792   say(
793     `${cues.length} cues loaded from ${opts.from ?? subtitles.name}.` +
794       (matched === null ? '' : ` ${matchedText(matched, cues.length, allPagesRead())}`),
795   );
796   showSrtRow();
797 }
798 
799 /**
800  * Gives subtitles of no book to the book that shows, and keeps them in its
801  * meta data. While an open loads that can replace that book, the subtitles
802  * wait (subtitlesOwner). Each end of an open, and the meta data of the new
803  * book, call this again.
804  */
805 function claimSubtitles(): void {
806   const book = subtitlesOwner(opens, state.book);
807   if (!state.subtitles || state.subtitlesBook !== null || book === null) return;
808   state.subtitlesBook = book;
809   void putMeta(book, { subtitles: state.subtitles });
810 }
811 
812 /** Removes the subtitles: the cues, the cue list, the mark, the .srt row. */
813 function clearSubtitles(): void {
814   state.cues = [];
815   state.aligner = null;
816   state.subtitles = null;
817   state.subtitlesBook = null;
818   state.spans = [];
819   state.cueParts = [];
820   state.activeCue = -1;
821   state.markedCue = -1;
822   updateSyncPage();
823   renderCueList();
824   showSrtRow();
825   view.mark();
826   // The Follow button goes: with no cues there is no page to follow.
827   runFollow('realign');
828 }
829 
830 // A browser download of an .srt often has the type application/octet-stream,
831 // and a type filter makes such a file grey in the Android picker. So the
832 // field takes each file, and the parser decides.
833 ui.subFile.addEventListener('change', async () => {
834   const file = ui.subFile.files?.[0];
835   // The same file can be picked again.
836   ui.subFile.value = '';
837   if (!file) return;
838   drawer.closeOnPhone();
839   if (file.size === 0) {
840     say(EMPTY_FILE_TEXT);
841     return;
842   }
843   let text: string;
844   try {
845     text = decodeSubtitles(await file.arrayBuffer());
846   } catch (err) {
847     say(`Cannot read ${file.name}: ${String(err)}`);
848     return;
849   }
850   // A file with no cues, for example a PDF, keeps the cues that are loaded.
851   const cues = parseSubtitles(text);
852   if (cues.length === 0) {
853     say(noCuesText(file.name));
854     return;
855   }
856   // During the open of another book, the file is for the book that shows next.
857   const book = subtitlesOwner(opens, state.book);
858   loadSubtitles({ name: file.name, text, source: 'file' }, { book, cues });
859 });
860 
861 /**
862  * The .srt row shows for subtitles that SubRead made: a download on the
863  * web, the share sheet on Android. A file that the user loaded is on the
864  * device already.
865  */
866 function showSrtRow(): void {
867   const subtitles = state.subtitles;
868   ui.srtRow.hidden = subtitles?.source !== 'subread';
869   if (!subtitles || ui.srtRow.hidden) return;
870   const { name, text } = subtitles;
871   ui.srtDownload.hidden = isAndroid;
872   ui.srtShare.hidden = !isAndroid;
873   if (!isAndroid) {
874     URL.revokeObjectURL(ui.srtDownload.href);
875     ui.srtDownload.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
876     ui.srtDownload.download = name;
877   }
878   ui.srtShare.onclick = () => {
879     SubRead.shareText({ name, text }).catch(sayError);
880   };
881 }
882 
883 // SubRead makes the subtitles from the audio and the text of the book. It
884 // starts when all pages are read: with part of the text, the subtitles
885 // cover only part of the book.
886 ui.makeSubs.addEventListener('click', async () => {
887   const book = state.book;
888   if (book === null) return;
889   let audio: { uri: string; name: string };
890   try {
891     audio = await SubRead.pickAudio();
892   } catch {
893     return;
894   }
895   if (state.book !== book) return;
896   state.makeWait = { audio, book };
897   startMakeWhenRead();
898 });
899 
900 /**
901  * Starts the SubRead job that waits, when all pages of its book are read.
902  * Else tells how far the reading is, or after the reading, how many pages
903  * could not be read, with Retry (waitMessage). readBook calls this after
904  * each page and at its end, so a Retry that reads those pages starts the job.
905  */
906 function startMakeWhenRead(): void {
907   const wait = state.makeWait;
908   const pdf = state.pdf;
909   if (!wait || !pdf || wait.book !== state.book) return;
910   if (!allPagesRead()) {
911     const read = state.pages.filter((p) => p).length;
912     fill(ui.makeStatus, waitMessage(read, pdf.numPages, readingRuns));
913     return;
914   }
915   state.makeWait = null;
916   void makeSubtitles(wait.audio, wait.book);
917 }
918 
919 /** Asks SubRead for the subtitles of the book `book`, and loads them after the checks. */
920 async function makeSubtitles(audio: { uri: string; name: string }, book: string): Promise<void> {
921   const name = resultName(book);
922   state.making = book;
923   ui.makeSubs.disabled = true;
924   ui.makeStatus.textContent = `SubRead makes the subtitles for ${audio.name}...`;
925   let result: SubtitlesResult;
926   try {
927     result = await SubRead.makeSubtitles({
928       audio: audio.uri,
929       bookText: bookText(state.tokens),
930       language: ui.subLang.value,
931       resultName: name,
932     });
933   } catch (err) {
934     result = { error: String(err) };
935   } finally {
936     state.making = null;
937     ui.makeSubs.disabled = state.book === null;
938   }
939   if (result.error === 'not_installed') {
940     fill(ui.makeStatus, SUBREAD_NOT_INSTALLED);
941     return;
942   }
943   // SubRead 0.10 and later also write the .srt into the result file. Read
944   // it now, so that the file goes, and use it when the answer has no .srt.
945   const copy = await SubRead.pendingSubtitles({ resultName: name }).catch(() => ({}) as PendingSrt);
946   const srt = result.srt ?? copy.srt;
947   if (srt === undefined) {
948     ui.makeStatus.textContent = subreadErrorText(result.error ?? 'no file');
949     return;
950   }
951   // Another book opened during the job: the result waits for its book,
952   // with what SubRead told about it, for the checks of the offer.
953   if (state.book !== book) {
954     const { cues, matchRate, language } = result;
955     keepResult(book, { srt, cues, matchRate, language });
956     return;
957   }
958   ui.makeStatus.textContent = subreadResultText(result);
959   offerSubreadSubtitles(srt, result);
960 }
961 
962 /**
963  * Keeps a SubRead result for the book `book`, which is not open, in its
964  * meta data. It is not saved as the subtitles of the book: applyMeta
965  * offers it when the book opens, with the confirm and the checks.
966  */
967 function keepResult(book: string, kept: KeptResult): void {
968   void putMeta(book, { subread: kept });
969   ui.makeStatus.textContent = keptResultText(bookName(book));
970 }
971 
972 type PendingSrt = { srt?: string };
973 
974 /** Subtitles that SubRead made for the book `book`. */
975 function subreadSubtitles(book: string, srt: string): BookSubtitles {
976   return { name: srtName(bookName(book)), text: srt, source: 'subread' };
977 }
978 
979 /**
980  * Loads subtitles that SubRead made for the open book. The user confirms
981  * first when they replace a file that the user loaded, or when the
982  * language or the match rate of SubRead look wrong (subreadConcerns).
983  */
984 function offerSubreadSubtitles(
985   srt: string,
986   info: { language?: string | null; matchRate?: number },
987 ): boolean {
988   const book = state.book;
989   if (book === null) return false;
990   const concerns = subreadConcerns({
991     requested: ui.subLang.value,
992     ocrLang: ui.lang.value,
993     language: info.language,
994     matchRate: info.matchRate,
995     loaded: state.subtitles,
996   });
997   if (concerns.length > 0 && !confirm(subreadQuestion(concerns))) {
998     say('The subtitles of SubRead are not loaded.');
999     return false;
1000   }
1001   loadSubtitles(subreadSubtitles(book, srt), { book, from: 'SubRead' });
1002   return true;
1003 }
1004 
1005 /**
1006  * Loads the .srt that SubRead wrote into the result file of the open book.
1007  * SubRead 0.10 and later write it also when Android stopped the reader
1008  * during the job; then no answer comes. The reader checks when a book
1009  * opens and when the app is active again.
1010  */
1011 async function checkPendingSubtitles(): Promise<void> {
1012   const book = state.book;
1013   // During a job the answer of makeSubtitles brings the .srt.
1014   if (!isAndroid || book === null || state.making !== null) return;
1015   let pending: PendingSrt;
1016   try {
1017     pending = await SubRead.pendingSubtitles({ resultName: resultName(book) });
1018   } catch {
1019     return;
1020   }
1021   const srt = pending.srt;
1022   if (srt === undefined) return;
1023   // The call removed the file, so the .srt waits in the meta data of its book.
1024   if (state.book !== book) {
1025     keepResult(book, { srt });
1026     return;
1027   }
1028   if (offerSubreadSubtitles(srt, {})) {
1029     ui.makeStatus.textContent = 'SubRead finished while the reader was closed.';
1030   }
1031 }
1032 
1033 // --- Alignment ---
1034 
1035 function scheduleAlign(): void {
1036   if (state.alignTimer) clearTimeout(state.alignTimer);
1037   state.alignTimer = setTimeout(align, ALIGN_DEBOUNCE);
1038 }
1039 
1040 /**
1041  * Align the cues to the pages read so far. Gives the count of matched
1042  * cues, or null when there is nothing to align.
1043  */
1044 function align(): number | null {
1045   if (state.alignTimer) clearTimeout(state.alignTimer);
1046   state.alignTimer = null;
1047   if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return null;
1048   state.spans = state.aligner.spans(state.pages.map((p) => p?.tokens));
1049   state.cueParts = cueParts(state.tokens, state.spans);
1050   renderCueList();
1051   view.mark();
1052   // The cue of now can have a new page. A new alignment is no seek, so a
1053   // page that the user turned to stays.
1054   runFollow('realign');
1055   return state.cueParts.filter((parts) => parts.length > 0).length;
1056 }
1057 
1058 // --- Cue list ---
1059 
1060 function renderCueList(): void {
1061   ui.cues.replaceChildren(
1062     ...state.cues.map((cue, i) => {
1063       const li = document.createElement('li');
1064       li.textContent = cue.text;
1065       li.title = `${cue.start.toFixed(2)} s`;
1066       const span = state.spans[i];
1067       if (span && !span.matched) li.classList.add('unmatched');
1068       if (i === state.activeCue) li.classList.add('active');
1069       li.addEventListener('click', () => {
1070         drawer.closeOnPhone();
1071         seek(cue.start * 1000);
1072       });
1073       return li;
1074     }),
1075   );
1076   scrollCueList(state.activeCue);
1077 }
1078 
1079 function markCueInList(i: number): void {
1080   ui.cues.querySelector('.active')?.classList.remove('active');
1081   ui.cues.children[i]?.classList.add('active');
1082   scrollCueList(i);
1083 }
1084 
1085 /**
1086  * Scrolls the cue list so that cue `i` shows, while the list shows. Only
1087  * the list scrolls: scrollIntoView also scrolled the menu, and the
1088  * settings went out of view.
1089  */
1090 function scrollCueList(i: number): void {
1091   const item = ui.cues.children[i];
1092   if (!(item instanceof HTMLElement) || !drawer.shows()) return;
1093   const list = ui.cues;
1094   const mark = { top: item.offsetTop, bottom: item.offsetTop + item.offsetHeight };
1095   const shown = { top: list.scrollTop, height: list.clientHeight };
1096   const top = scrollTarget(mark, shown, list.scrollHeight - list.clientHeight);
1097   if (top !== null) list.scrollTop = top;
1098 }
1099 
1100 // --- The follow ---
1101 
1102 function showPlayerStatus(s: ClockState): void {
1103   ui.play.disabled = playerCommandsOff(s.error, isAndroid);
1104   ui.play.classList.toggle('playing', s.playing);
1105   const action = s.playing ? 'Pause' : 'Play';
1106   if (ui.play.title !== action) {
1107     ui.play.title = action;
1108     ui.play.setAttribute('aria-label', action);
1109   }
1110   // Before a PDF is open, the start card tells what to do first.
1111   showPlayer(state.pdf ? playerMessage(s, isAndroid, overlayInstalled()) : null);
1112   updateSyncPage();
1113   updateAwake();
1114   // The checklist shows the notification access of the last player state.
1115   if (s.error !== state.suiteError) renderSuite();
1116 }
1117 
1118 /** True when SubRead Overlay, the release or the debug build, is on the device. */
1119 function overlayInstalled(): boolean {
1120   return state.suite !== null && (state.suite.overlay || state.suite.overlayDebug);
1121 }
1122 
1123 /**
1124  * "Move the audio to this page" needs cues and a player with a position.
1125  * A player with a problem has no position that counts.
1126  */
1127 function updateSyncPage(): void {
1128   const s = state.clock;
1129   ui.syncPage.disabled = state.cues.length === 0 || !s || s.positionMs === null || s.error !== null;
1130 }
1131 
1132 /**
1133  * The follow for the last state of the clock: mark the cue of now, and
1134  * turn to the page of the audio unless the follow is held. In the silence
1135  * between two cues, the cue before stays the cue of now. In a cue on two
1136  * pages, the page follows the time.
1137  */
1138 function runFollow(event: FollowEvent): void {
1139   const s = state.clock;
1140   const t = s?.positionMs == null ? null : s.positionMs / 1000;
1141   const cue = t === null ? -1 : lastCueAt(state.cues, t);
1142   if (cue !== state.activeCue) {
1143     markCueInList(cue);
1144     state.activeCue = cue;
1145   }
1146   const mode = followMode();
1147   const page =
1148     t === null || cue < 0 ? null : audioPage(state.cueParts, cue, cueProgress(state.cues[cue]!, t));
1149   const out = follow({
1150     mode,
1151     currentPage: state.currentPage,
1152     cue,
1153     matched: (state.cueParts[cue]?.length ?? 0) > 0,
1154     page,
1155     held: state.held,
1156     event,
1157   });
1158   state.held = out.held;
1159   // The Follow button shows only when it has a page to go to.
1160   const paused = out.held && mode !== 'off' && page !== null;
1161   ui.followNow.hidden = !paused;
1162   ui.followNote.hidden = !paused;
1163   const marked = out.highlightCue ?? -1;
1164   const markMoved = marked !== state.markedCue;
1165   state.markedCue = marked;
1166   if (out.turnToPage !== null) void showPage(out.turnToPage);
1167   else if (markMoved) view.mark();
1168 }
1169 
1170 /** One state of the clock. A jump of the audio is a seek. */
1171 function applyClock(s: ClockState): void {
1172   state.clock = s;
1173   showPlayerStatus(s);
1174   runFollow(s.seeked ? 'seek' : 'tick');
1175 }
1176 
1177 /** A page turn by the user. It holds the follow, until the audio reaches the page. */
1178 function turnByUser(index: number): void {
1179   state.held = true;
1180   void showPage(index);
1181   runFollow('tick');
1182 }
1183 
1184 /** Moves the audio from this app. The follow is not held after it. */
1185 function seek(ms: number): void {
1186   clock.seek(ms).then(() => {
1187     state.held = false;
1188     say(`Audio moved to ${clockText(ms)}.`);
1189   }, sayError);
1190 }
1191 
1192 const clock: ClockSource = isAndroid ? new OverlayClock(SubRead) : new LocalAudioClock(ui.audio);
1193 clock.start(applyClock);
1194 
1195 // --- The screen and the app state ---
1196 
1197 /** Keeps the screen on during read-along (keepScreenOn). Calls the plugin only on a change. */
1198 function updateAwake(): void {
1199   const on = keepScreenOn({
1200     active: state.active,
1201     book: state.pdf !== null,
1202     playing: state.clock?.playing === true,
1203     mode: followMode(),
1204   });
1205   if (on === state.awake) return;
1206   state.awake = on;
1207   SubRead.keepAwake({ on }).catch(() => undefined);
1208 }
1209 
1210 /**
1211  * The app goes to the background or comes back. In the background the
1212  * clock stops, so no poll of the overlay runs, and the screen may turn
1213  * off. Back in front, the clock reads the player at once.
1214  */
1215 function setActive(active: boolean): void {
1216   if (active === state.active) return;
1217   state.active = active;
1218   if (active) {
1219     clock.start(applyClock);
1220     if (isAndroid) {
1221       void checkPendingSubtitles();
1222       void refreshSuite();
1223     }
1224   } else {
1225     clock.stop();
1226     // Android can stop the reader in the background.
1227     flushPageSave();
1228   }
1229   updateAwake();
1230 }
1231 
1232 void App.addListener('appStateChange', ({ isActive }) => setActive(isActive));
1233 
1234 // Back never finishes the activity: that destroyed the WebView and the
1235 // reader lost the book. On the web the browser handles Back.
1236 if (isAndroid) {
1237   void App.addListener('backButton', () => {
1238     switch (backStep({ jump: nav.jumpOpen(), drawer: drawer.covers() })) {
1239       case 'close-jump':
1240         nav.closeJump();
1241         break;
1242       case 'close-drawer':
1243         drawer.set(false);
1244         break;
1245       case 'minimize':
1246         void App.minimizeApp();
1247         break;
1248     }
1249   });
1250 }
1251 
1252 /** Asks which apps of the SubRead suite are installed, and shows the checklist. */
1253 async function refreshSuite(): Promise<void> {
1254   try {
1255     state.suite = await SubRead.suite();
1256   } catch {
1257     return;
1258   }
1259   renderSuite();
1260   if (state.clock) showPlayerStatus(state.clock);
1261 }
1262 
1263 /**
1264  * Shows the checklist of the SubRead suite on the start card and in the
1265  * menu (Android only). The notification access of SubRead Overlay comes
1266  * from the last player state.
1267  */
1268 function renderSuite(): void {
1269   const apps = state.suite;
1270   if (!isAndroid || !apps) return;
1271   state.suiteError = state.clock?.error;
1272   const items = suiteChecklist(apps, state.suiteError);
1273   const list = (): HTMLLIElement[] =>
1274     items.map((item) => {
1275       const li = document.createElement('li');
1276       li.className = item.ok ? 'ok' : 'missing';
1277       fill(li, item);
1278       return li;
1279     });
1280   ui.suiteList.replaceChildren(...list());
1281   const title = document.createElement('h3');
1282   title.textContent = 'SubRead suite';
1283   const card = document.createElement('ul');
1284   card.className = 'suite';
1285   card.append(...list());
1286   ui.suiteChecklist.replaceChildren(title, card);
1287   ui.suite.hidden = false;
1288 }
1289 
1290 ui.play.addEventListener('click', () => {
1291   const action = state.clock?.playing ? clock.pause() : clock.play();
1292   action.catch(sayError);
1293 });
1294 
1295 ui.followNow.addEventListener('click', () => runFollow('follow'));
1296 
1297 // The audio goes to where the text of the page starts, also inside a cue
1298 // that starts on the page before.
1299 ui.syncPage.addEventListener('click', () => {
1300   const t = pageStartTime(state.cueParts, state.cues, state.currentPage);
1301   if (t === null) {
1302     say('No cue of the subtitles is on this page.');
1303     return;
1304   }
1305   seek(t * 1000);
1306 });
1307 
1308 ui.audioFile.addEventListener('change', () => {
1309   const file = ui.audioFile.files?.[0];
1310   if (!file) return;
1311   ui.audio.src = URL.createObjectURL(file);
1312 });
1313 
1314 // --- Page view ---
1315 
1316 /** The mark of page `index`: one box per text line of the marked cue. */
1317 function marks(index: number): Marks | null {
1318   const entry = state.pages[index];
1319   const span = state.spans[state.markedCue];
1320   if (!entry || !span) return null;
1321   return {
1322     width: entry.width,
1323     height: entry.height,
1324     boxes: (pad) => lineBoxes(state.tokens, span, index).map((box) => padBox(box, pad)),
1325   };
1326 }
1327 
1328 /**
1329  * Shows page `index`. `save` is true for a page turn by the user or by the
1330  * follow: the page goes to the meta data of the book soon, and the meta
1331  * data that comes later does not turn the page back (lateMeta). The page
1332  * of the open and the saved page need no save.
1333  */
1334 async function showPage(index: number, save = true): Promise<void> {
1335   const pdf = state.pdf;
1336   if (!pdf || index < 0 || index >= pdf.numPages) return;
1337   state.currentPage = index;
1338   nav.update();
1339   if (save) {
1340     state.changed.page = true;
1341     savePageSoon();
1342   }
1343   try {
1344     await view.show(pdf, index);
1345   } catch (err) {
1346     // A render of a book that closed meanwhile fails. That is no error.
1347     if (state.pdf === pdf) sayError(err);
1348   }
1349 }
1350 
1351 // --- Tap on a word ---
1352 
1353 /**
1354  * The token under a point of the screen, in client pixels: an index of
1355  * state.tokens, or -1 when no token is near. Null when the point is not on
1356  * the page, or the page is not read yet: then the strip tells how many
1357  * pages are read.
1358  */
1359 function tokenAtPoint(clientX: number, clientY: number): number | null {
1360   const canvas = ui.page.querySelector('canvas');
1361   const pdf = state.pdf;
1362   if (!canvas || !pdf || ui.page.hidden) return null;
1363   const rect = canvas.getBoundingClientRect();
1364   const inside =
1365     clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
1366   if (!inside) return null;
1367   const size = state.pages[state.currentPage];
1368   if (!size) {
1369     say(notReadText(state.pages.filter((p) => p).length, pdf.numPages));
1370     return null;
1371   }
1372   const x = ((clientX - rect.left) / rect.width) * size.width;
1373   const y = ((clientY - rect.top) / rect.height) * size.height;
1374   const zoom = window.visualViewport?.scale ?? 1;
1375   return tokenAt(state.tokens, state.currentPage, x, y, tapTolerance(size.width, rect.width, zoom));
1376 }
1377 
1378 /** Tap on the page: send the text under the tap to the dictionary. */
1379 ui.page.addEventListener('click', (e) => {
1380   const t = tokenAtPoint(e.clientX, e.clientY);
1381   if (t === null || t < 0) return;
1382   const text = lookupText(state.tokens, t);
1383   // A tap on punctuation only gives no text.
1384   if (text === '') return;
1385   void (isAndroid ? lookUp(text) : copy(text));
1386 });
1387 
1388 /**
1389  * Long press on the page: a card in SubRead Anki for the word and its
1390  * sentence (ankiCard). Gives true when the press was on a word, so that
1391  * the click after it does no lookup.
1392  */
1393 function longPress(clientX: number, clientY: number): boolean {
1394   const index = tokenAtPoint(clientX, clientY);
1395   const file = state.file;
1396   if (index === null || index < 0 || !file) return false;
1397   if (!isAndroid) {
1398     say(ANKI_WEB_TEXT);
1399     return true;
1400   }
1401   const card = ankiCard({
1402     tokens: state.tokens,
1403     index,
1404     spans: state.spans,
1405     markedCue: state.markedCue,
1406     cues: state.cues,
1407     source: cardSource(file.name, state.tokens[index]!.page),
1408   });
1409   if (card) void addCard(card);
1410   return true;
1411 }
1412 
1413 /** Sends the card to SubRead Anki. "Pause on lookup" pauses the player while the card shows. */
1414 async function addCard(card: AnkiCard): Promise<void> {
1415   await withPause(async () => {
1416     try {
1417       const m = ankiText(await SubRead.ankiAdd(card));
1418       if (m) say(m);
1419     } catch (err) {
1420       say(`Anki card failed: ${String(err)}`);
1421     }
1422     return true;
1423   });
1424 }
1425 
1426 /** The web has no dictionary app: the text goes to the clipboard. */
1427 async function copy(text: string): Promise<void> {
1428   try {
1429     await navigator.clipboard.writeText(text);
1430     say(`Copied: "${text}"`);
1431   } catch {
1432     say(`Copy failed: "${text}"`);
1433   }
1434 }
1435 
1436 /** Sends the text to the dictionary app, with "Pause on lookup". */
1437 async function lookUp(text: string): Promise<void> {
1438   say(`Lookup: "${text}"`);
1439   await withPause(async () => {
1440     // Play again when the dictionary closed, or when it did not open.
1441     try {
1442       return (await SubRead.lookup({ text })).closed;
1443     } catch (err) {
1444       say(`Lookup failed: ${String(err)}`);
1445       return true;
1446     }
1447   });
1448 }
1449 
1450 /**
1451  * Runs `open`, an app over the reader: the dictionary or SubRead Anki.
1452  * With "Pause on lookup", the player pauses first, and plays again when
1453  * `open` gives true: the app closed. When the pause fails, `open` still
1454  * runs.
1455  */
1456 async function withPause(open: () => Promise<boolean>): Promise<void> {
1457   const pause = ui.pauseLookup.checked && state.clock?.playing === true && !state.lookupPaused;
1458   let paused = false;
1459   if (pause) {
1460     state.lookupPaused = true;
1461     try {
1462       await clock.pause();
1463       paused = true;
1464     } catch (err) {
1465       sayError(err);
1466     }
1467   }
1468   const resume = await open();
1469   try {
1470     if (paused && resume) await clock.play();
1471   } catch (err) {
1472     sayError(err);
1473   } finally {
1474     if (pause) state.lookupPaused = false;
1475   }
1476 }
1477 
1478 // --- Dictionaries (Android) ---
1479 
1480 async function loadDictionaries(): Promise<void> {
1481   const { apps, chosen } = await SubRead.dictionaries();
1482   for (const app of apps) {
1483     const option = document.createElement('option');
1484     option.value = app.component;
1485     option.textContent = app.label;
1486     ui.dict.append(option);
1487   }
1488   ui.dict.value = apps.some((a) => a.component === chosen) ? chosen : '';
1489 }
1490 
1491 ui.dict.addEventListener('change', () => {
1492   SubRead.setDictionary({ component: ui.dict.value }).catch(sayError);
1493 });
1494 
1495 // --- The buttons in the strip ---
1496 
1497 onAction((id) => {
1498   switch (id) {
1499     case 'retry-reading':
1500       void readBook(true);
1501       break;
1502     case 'open-overlay':
1503       SubRead.openOverlay().catch(sayError);
1504       break;
1505   }
1506 });
1507 
1508 // --- Start ---
1509 
1510 loadSettings();
1511 // Force OCR and the pages of one book need an open book.
1512 ui.forceOcr.disabled = true;
1513 ui.clearBook.disabled = true;
1514 ui.make.hidden = !isAndroid;
1515 ui.audioRow.hidden = isAndroid;
1516 // The web copies a lookup at once: there is no dictionary app to choose or to wait for.
1517 ui.dictRow.hidden = !isAndroid;
1518 ui.pauseRow.hidden = !isAndroid;
1519 if (isAndroid) {
1520   void loadDictionaries().catch(sayError);
1521   void refreshSuite();
1522 }
1523 watchDb();
1524 void restoreLastBook();