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();