From bfb85c2f68e45fbc3e3d825d1bb7fe8e7f77167d Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 14 Jun 2026 15:46:18 +0800 Subject: [PATCH] feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235) Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tts): emit canonical tts-position event from TTSController (#3235) Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers (paragraph mode, RSVP — later slices) drop out-of-order positions. Additive; existing events untouched. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235) RSVPController.syncToCfi + setExternallyDriven: containment match (fixes mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank, no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer suspension while externally driven. ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper returning -1 on no match (never first()). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235) useTTSControl republishes the controller's canonical tts-position (tagged with bookKey) via a dedicated listener — NOT inside the suppression-gated highlight handlers, so page-follow suppression can't silently desync the modes. Adds tts-playback-state (playing/paused/stopped) so RSVP can track playback without the hook-local isPlaying. Verified by extending the real-foliate-view browser harness. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): paragraph mode follows TTS playback (#3235) When paragraph mode + TTS are both active, the focused paragraph follows the spoken position (sentence granularity, all engines). Section-generation contract (stash cross-section position, apply after the iterator re-inits); sync-focus path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next playing. Start-alignment + visible indicator deferred to later slices. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(rsvp): speed reader follows TTS playback (#3235) Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge (sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice rate, hold at +60 words cap, snap to first word on each new sentence mark). RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a rsvp-manual-nav signal; re-engage on next playing. Cross-section positions re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235) Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and never engages. Both modes expose ttsSyncStatus (idle/following/syncing/ decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl now forwardRef-exposes the status via an imperative handle. Cross-bookKey events ignored (regression-tested). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): 'following audio' indicator for TTS sync (#3235) 5-state pill (following/syncing/decoupled, idle+unsupported render null) shown top-center in the paragraph overlay and as a status row in the RSVP overlay. Decoupled state is the tap-to-resume control; first decouple fires a one-time toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch targets, safe-area top inset. RSVP 'plain' variant matches its themed surface; non-Edge shows '· estimated'. New i18n keys need extraction before merge. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235) Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/ stops read-along from inside the full-screen overlay, start-aligned to the current word (range validated against the live doc). While TTS-driven, the WPM control shows a locked 'Audio pace' affordance that opens a compact rate picker; rate changes go through a new tts-set-rate bus event reusing the existing throttled setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) * test(reader): e2e paragraph mode follows TTS across a section boundary (#3235) Real browser e2e: with paragraph mode active, the focused paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5 boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap). Asserts on the owning section of the current range. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) * i18n(reader): translate TTS-sync strings across 33 locales (#3235) Following audio / · estimated / Resume audio / Stopped following audio / Play audio / Pause audio / Audio pace / Speed follows audio. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(reader): resolve TTS CFI anchors across iframe realms (#3235) RSVP and paragraph follow silently failed to track the spoken word: the CFI anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book iframe's realm, so 'anchor instanceof Range' (top realm) was always false (cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null -> syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before = syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at ~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is single-realm so the old code passed there but died in the app). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235) Two read-along refinements (verified live via CDP with Edge TTS): 1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit BOTH sentence marks and word boundaries; RSVP was routing sentence -> the estimator (self-paces ~190xrate, up to +60 words ahead) while word positions snapped it back. Now once a word position is seen, sentence positions are ignored and any running estimator is stopped, so words alone drive RSVP. 2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full stop releases it. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235) - Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio pace' lock (layout shift). New 'paused' sync status keeps the indicator row and WPM lock present while TTS is engaged-but-paused; only a full stop clears them. Verified live via CDP: pause keeps the layout, no shift. - Dict management is reachable from RSVP: the settings dialog is z-50, far below the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it. handleManageDictionary now exits RSVP first (position saved/resumable) so management shows over the reader. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(rsvp): show dict management over RSVP instead of exiting it (#3235) Per feedback: opening dictionary management from the RSVP lookup popup no longer closes the speed reader. The settings dialog is raised above the full-screen RSVP overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's capture-phase keyboard handler bails while the settings dialog is open so its inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP: management opens over RSVP, RSVP stays active behind it, Escape returns to it. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(dict): only apply drag-handle margin compensation when the handle shows (#3235) The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle, but that handle is sm:hidden (shown only below sm). On sm+ the handle is display:none, so -mt-4 pulled the header up into the top edge (broken layout when the lookup renders as a sheet on a short/wide window). Mirror the handle's breakpoint: -mt-4 sm:mt-0. Verified live via CDP. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): in-mode TTS audio toggle for paragraph mode (#3235) Paragraph mode already follows TTS, but there was no way to start read-along from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it starts TTS start-aligned to the focused paragraph (range validated live, + section index) and stops it. Track session-active vs playing so a pause keeps the indicator ('paused' status) instead of collapsing to idle. Pure buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping the icon starts audio from the focused paragraph and the focus follows speech. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(reader): highlight current TTS word/sentence in paragraph mode (#3235) Paragraph mode follows TTS by advancing the focused paragraph, but the spoken word wasn't highlighted within it like normal mode. The overlay renders a CLONE of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline boundaries natively, leaves the fade-in animation untouched). - TTSController already tags tts-position with kind word|sentence. The hook decides granularity: word boundaries (Edge) drive a per-word highlight; once seen, the coarse sentence event is skipped so the whole sentence doesn't flicker over the current word. Engines without word boundaries (WebSpeech/Native) fall back to the sentence highlight. - Offsets are computed relative to the paragraph start (so they map 1:1 onto the clone's text) and tagged with the paragraph index so a stale highlight never paints the wrong paragraph. Cleared on stop / section change / disabled. - The ::highlight() style mirrors the user's ttsHighlightOptions color+style. Pure helpers (offset math, word/sentence decision, css builder) unit-tested. Verified live via CDP: word highlight tracks Edge word-by-word and follows across paragraph boundaries (news -> ... -> ladies), matching the TTS color. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...26-06-14-tts-sync-paragraph-rsvp-design.md | 367 +++++++++++++ .../public/locales/ar/translation.json | 10 +- .../public/locales/bn/translation.json | 10 +- .../public/locales/bo/translation.json | 10 +- .../public/locales/de/translation.json | 10 +- .../public/locales/el/translation.json | 10 +- .../public/locales/es/translation.json | 10 +- .../public/locales/fa/translation.json | 10 +- .../public/locales/fr/translation.json | 10 +- .../public/locales/he/translation.json | 10 +- .../public/locales/hi/translation.json | 10 +- .../public/locales/hu/translation.json | 10 +- .../public/locales/id/translation.json | 10 +- .../public/locales/it/translation.json | 10 +- .../public/locales/ja/translation.json | 10 +- .../public/locales/ko/translation.json | 10 +- .../public/locales/ms/translation.json | 10 +- .../public/locales/nl/translation.json | 10 +- .../public/locales/pl/translation.json | 10 +- .../public/locales/pt-BR/translation.json | 10 +- .../public/locales/pt/translation.json | 10 +- .../public/locales/ro/translation.json | 10 +- .../public/locales/ru/translation.json | 10 +- .../public/locales/si/translation.json | 10 +- .../public/locales/sl/translation.json | 10 +- .../public/locales/sv/translation.json | 10 +- .../public/locales/ta/translation.json | 10 +- .../public/locales/th/translation.json | 10 +- .../public/locales/tr/translation.json | 10 +- .../public/locales/uk/translation.json | 10 +- .../public/locales/uz/translation.json | 10 +- .../public/locales/vi/translation.json | 10 +- .../public/locales/zh-CN/translation.json | 10 +- .../public/locales/zh-TW/translation.json | 10 +- .../tts/TTSFollowIndicator.test.tsx | 66 +++ .../paragraph-tts-highlight.test.ts | 126 +++++ .../paragraph-tts-speak-detail.test.ts | 62 +++ .../components/rsvp-section-advance.test.tsx | 5 + .../components/rsvp-tts-speak-detail.test.ts | 73 +++ .../components/rsvp-tts-sync-decision.test.ts | 170 ++++++ .../components/rsvp-tts-sync.test.tsx | 393 +++++++++++++ .../src/__tests__/paragraph-mode.test.tsx | 515 ++++++++++++++++++ .../services/rsvp-controller.test.ts | 397 ++++++++++++++ .../tts-auto-advance.browser.test.tsx | 345 +++++++++++- .../__tests__/services/tts-controller.test.ts | 88 +++ .../src/__tests__/utils/paragraph.test.ts | 82 ++- .../src/__tests__/utils/range.test.ts | 39 ++ .../components/annotator/DictionarySheet.tsx | 5 +- .../components/paragraph/ParagraphBar.tsx | 38 ++ .../components/paragraph/ParagraphControl.tsx | 31 +- .../components/paragraph/ParagraphOverlay.tsx | 99 ++++ .../components/paragraph/paragraphTts.ts | 112 ++++ .../reader/components/rsvp/RSVPControl.tsx | 480 +++++++++++++++- .../reader/components/rsvp/RSVPOverlay.tsx | 236 ++++++-- .../src/app/reader/components/rsvp/rsvpTts.ts | 45 ++ .../components/tts/TTSFollowIndicator.tsx | 117 ++++ .../src/app/reader/hooks/useParagraphMode.ts | 355 +++++++++++- .../src/app/reader/hooks/useTTSControl.ts | 48 ++ .../components/settings/SettingsDialog.tsx | 6 +- .../src/services/rsvp/RSVPController.ts | 287 +++++++++- .../src/services/tts/TTSController.ts | 22 + apps/readest-app/src/utils/paragraph.ts | 76 +++ apps/readest-app/src/utils/range.ts | 15 + 63 files changed, 4941 insertions(+), 89 deletions(-) create mode 100644 apps/readest-app/docs/superpowers/specs/2026-06-14-tts-sync-paragraph-rsvp-design.md create mode 100644 apps/readest-app/src/__tests__/app/reader/components/tts/TTSFollowIndicator.test.tsx create mode 100644 apps/readest-app/src/__tests__/components/paragraph-tts-highlight.test.ts create mode 100644 apps/readest-app/src/__tests__/components/paragraph-tts-speak-detail.test.ts create mode 100644 apps/readest-app/src/__tests__/components/rsvp-tts-speak-detail.test.ts create mode 100644 apps/readest-app/src/__tests__/components/rsvp-tts-sync-decision.test.ts create mode 100644 apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx create mode 100644 apps/readest-app/src/__tests__/utils/range.test.ts create mode 100644 apps/readest-app/src/app/reader/components/paragraph/paragraphTts.ts create mode 100644 apps/readest-app/src/app/reader/components/rsvp/rsvpTts.ts create mode 100644 apps/readest-app/src/app/reader/components/tts/TTSFollowIndicator.tsx create mode 100644 apps/readest-app/src/utils/range.ts diff --git a/apps/readest-app/docs/superpowers/specs/2026-06-14-tts-sync-paragraph-rsvp-design.md b/apps/readest-app/docs/superpowers/specs/2026-06-14-tts-sync-paragraph-rsvp-design.md new file mode 100644 index 00000000..42b85e31 --- /dev/null +++ b/apps/readest-app/docs/superpowers/specs/2026-06-14-tts-sync-paragraph-rsvp-design.md @@ -0,0 +1,367 @@ + +# Sync paragraph mode & speed reader with TTS (#3235) + +## Problem + +Readest has three independent reading aids that don't talk to each other: + +- **TTS** (read-aloud) — now emits per-**word** boundaries on Edge voices and per-**sentence** + marks on every engine, each tagged with a CFI. +- **Paragraph mode** — a focus overlay that shows one block at a time. +- **Speed reader (RSVP)** — flashes one word at a time, paced by its own WPM timer. + +Issue #3235 asks that paragraph mode and the speed reader **sync with TTS** so you can +listen and follow along in either view. + +## Locked decisions + +1. **TTS is the clock.** The two visual modes become *viewers* of the spoken position; + they follow TTS, not the reverse. +2. **Both modes, one PR.** They share the same CFI-driven "follow TTS" plumbing. +3. **RSVP on non-Edge voices → sentence-paced fallback.** Word boundaries are Edge-only; + on sentence-only engines RSVP jumps to the spoken sentence's first word and self-paces + through its words, correcting at each sentence mark. +4. **Automatic coupling, but visible and escapable.** Whenever TTS and a mode are both + active they sync by default — no separate "sync mode" setting. A visible "following + audio" indicator shows when sync is active, and a manual scroll / paragraph-nav / RSVP + skip **decouples** until the user re-engages. (Refined per CEO review — see audit trail.) +5. **RSVP gets a minimal TTS play/pause icon** in its existing control row (RSVP is a + full-screen overlay that otherwise hides the TTS transport). This surfaces the existing + TTS transport; it is not a new sync toggle. +6. **RSVP speed control is disabled while TTS-driven**, not repurposed to TTS rate. + `handleSetRate` does a throttled (3000ms) stop→setRate→start cycle, so wiring a rapid + +/- stepper to it would stutter/gap the audio. While externally driven, pace comes from + the TTS voice (Edge word boundaries, or the non-Edge estimator); the WPM control is + greyed out. (Refined per CEO review — see audit trail.) + +## Architecture + +### Glue: CFI + +Every TTS position is already reported as a CFI, and both modes already map a CFI back to +their own index: + +- `ParagraphIterator.findByRangeAsync(range)` → paragraph index (`src/utils/paragraph.ts`) +- `RSVPController.findWordIndexByCfi(cfi)` → word index (`src/services/rsvp/RSVPController.ts`) + +So syncing is mostly: deliver the TTS CFI to each mode and call its existing seek. + +### A. The bridge — `tts-position` broadcast + +**The `TTSController` emits the canonical event; the hook forwards.** (Corrected per Eng +review — both voices flagged hook-owned broadcast as critical.) The controller already owns +the source of truth (`#ttsSectionIndex`, the active word/sentence range, `getCurrentHighlightCfi`, +`reapplyCurrentHighlight`). It emits `tts-position` from the same code paths that already fire +`tts-highlight-word` / `tts-highlight-mark` (`dispatchSpeakWord` / `dispatchSpeakMark`): + +```ts +// on the controller (one source of truth): +{ cfi, kind: 'word' | 'sentence', sectionIndex, sequence } // sequence = monotonic counter +``` + +`useTTSControl` adds `bookKey` and forwards it onto the shared `eventDispatcher` from a +**dedicated listener** (NOT inside `handleHighlightWord/Mark`, which early-return on +`followingTTSLocationRef` and active text selection — bolting the broadcast there would +silently desync the modes exactly when the reader suppresses page-follow). Forward gated only +by lifecycle, not page-follow state. + +```ts +eventDispatcher.dispatch('tts-position', { bookKey, cfi, kind, sectionIndex, sequence }); +``` + +**Latest-only / ordering.** `eventDispatcher.dispatch` awaits listeners serially and callers +fire-and-forget, so a slow word-42 map can land after word-43. Consumers keep +`lastSequenceSeen` and **ignore any event with a smaller `sequence`**; keep the hot path +synchronous (cursor lookup, no `await`/rAF inside the listener). Paragraph work coalesces to +sentence granularity. + +**Lifecycle.** `ttsEnabled` is in `readerStore` (per `bookKey`) — modes read it. But +`isPlaying` is **hook-local** to `useTTSControl`, so add a `tts-playback-state` signal +(`{ bookKey, state: 'playing'|'paused'|'stopped' }`) the modes subscribe to for the +`following` ↔ paused transitions; do not assume RSVP can read `isPlaying`. + +**Rejected alternatives:** putting the controller in a store/context (it is recreated each +TTS session → constant re-subscribe churn, and leaks the instance widely); polling +`getCurrentHighlightCfi()` (still needs controller access, plus a poll loop). + +### B. Paragraph follows TTS — all engines + +Sentence granularity is enough to choose a paragraph, so this works on every TTS engine. + +In `useParagraphMode`, subscribe to `tts-position` (filtered by `bookKey`): + +1. Resolve `cfi` → `Range` (`view.resolveCFI`). +2. If the CFI's section ≠ the iterator's section, the view has already been navigated by + TTS; let the existing relocate handler re-init the iterator, then map on the next event. +3. `iterator.findByRangeAsync(range)` → `index`. If `index` ≠ current focus, + `goToParagraph(index)` + `focusCurrentParagraph()`. +4. Guard with the existing `isFocusingRef` so the programmatic scroll doesn't re-enter. + +**Start alignment.** When TTS is started while paragraph mode is active, dispatch +`tts-speak` with the focused paragraph's `range` and section `index` (the `tts-speak` +detail is `{ bookKey, range, index }`) so audio begins at the focused block (not the page +top). The focus flow already holds `currentRange`. + +**Manual nav while synced.** When the user taps next/prev paragraph and TTS is playing, +**decouple** (stop following audio; the "following audio" indicator hides). TTS keeps +playing; the user is now browsing independently. Re-engaging the indicator re-syncs to the +current TTS position. (Decision 4 — decouple-on-manual-interaction, not reverse-driving.) + +### C. Speed reader follows TTS + +Add an **external-drive mode** to `RSVPController`: when engaged it suspends the internal +WPM `scheduleNextWord` timer and shows whatever word the driver points at. + +New surface on `RSVPController`: + +- `syncToCfi(cfi: string): void` — `findWordIndexByCfi` → `seekToIndex` (no timer arm). +- `setExternallyDriven(on: boolean): void` — suspend/resume the internal timer and flip a + flag the overlay reads to adjust controls. + +Wiring in `RSVPControl` (subscribes to `tts-position`, filtered by `bookKey`): + +- **Edge (`kind: 'word'`):** `controller.syncToCfi(cfi)` — exact word-for-word read-along. +- **Non-Edge (`kind: 'sentence'`):** jump to the sentence's first word + (`findWordIndexByCfi`), then advance its words on a local timer at an **estimated WPM + derived from the TTS voice rate** (`ESTIMATED_TTS_WPM ≈ 190 × viewSettings.ttsRate`, a + named tunable). The next sentence event snaps/corrects drift. + +**Start alignment.** Starting TTS while RSVP is open dispatches `tts-speak` with the +current RSVP word's `range` and section `index`. + +**Section transitions.** When TTS drives the view into the next section, RSVP re-extracts +words for the new section (reuse `loadNextPageContent`) on the relocate, then resumes +mapping CFIs. (Today RSVP-initiated section change goes through `rsvp-request-next-page`; +the TTS-driven path is the inverse — react to the view relocating.) + +**Pace control while synced.** With the WPM timer suspended, the RSVP speed control is +**disabled / greyed out** while TTS-driven (decision 6) — do NOT repurpose it to TTS rate +(`handleSetRate`'s throttled stop→start would stutter the audio). Pace comes from the TTS +voice. To change speed while synced, the user adjusts the TTS rate from the TTS panel. + +**Decouple on manual interaction.** A manual RSVP skip/seek (or paragraph next/prev) drops +the externally-driven state and returns the mode to its own controls; the "following audio" +indicator hides. Re-engaging (e.g. tapping the RSVP TTS icon, or the indicator) re-syncs to +the current TTS position. + +### D. RSVP TTS trigger (decision 5) + +Add one TTS play/pause icon to the RSVP overlay's control row. It dispatches the existing +`tts-speak` / `tts-stop` events (with the current RSVP word's range for start). State +(playing/idle) reflects `ttsEnabled`/`isPlaying`. Paragraph mode needs no new control — +its footer/TTS transport remains reachable. + +## UI states & placement (from Design review — auto-adopted) + +Both design voices flagged that the plan named widgets without designing them. Resolved +decisions below; build the indicator as one reusable component. + +### Following-audio indicator — three states, not two + +| State | When | Rendering | +|-------|------|-----------| +| `following` | TTS playing + mode following | Filled pill, voice/equalizer glyph + "Following audio". RSVP on non-Edge appends "· estimated" (sets expectation that pacing is approximate). | +| `decoupled` | TTS still playing, user took manual control | Ghost pill, sync glyph + "Resume audio" — **the pill IS the re-engage button**. Do NOT hide it (hiding reads as "sync broke"). | +| `syncing` | section re-init / CFI not yet mapped | `loading-dots` sub-state (reuse ParagraphBar's existing loading affordance). | +| `not-synced` | TTS off | Unmounted. | +| `unsupported` | fixed-layout if sync is gated off | No pill; one-line "Sync unavailable for this book" hint (don't fail silently). | + +Component rules: `eink-bordered`, full-opacity text + glyph (never color-alone — RSVP is a +self-themed surface so global `[data-eink]` rules don't auto-apply; hand-apply, precedent = +RSVP "Look up" pill), logical start/end props (RTL), `touch-target`/`min-h-11` on mobile, +safe-area top inset via the overlay's `gridInsets`. + +**Placement.** Paragraph mode → persistent top-center chip on `ParagraphOverlay` (NOT in the +auto-hiding `ParagraphBar`, which vanishes after 2s); relate it visually to the existing +"Back to TTS Location" affordance. RSVP → a slim status row **below the header, above the +context panel** — NOT inside the crowded transport row. Also resolve the paragraph-mode +bottom-bar stack: suppress `TTSBar` while paragraph mode is active (its transport duplicates +what the mode now follows). + +### RSVP TTS toggle (decision 5) + +Voice/headphones glyph (reuse `TTSIcon` / the TTS panel's voice metaphor) — **never a second +play triangle** (RSVP already has a 56px center play for word-flashing). `aria-label` +"Play audio" / "Pause audio" (distinct from RSVP's "Play"/"Pause"). Place at the control +cluster's trailing edge near the settings gear, divider-separated. Match RSVP's local idiom +(`gray-500/xx` ghost), not a daisyui `btn` (RSVP paints its own theme surface). + +### Disabled WPM while synced (decision 6) + the rate escape hatch + +Don't just dim it. Replace its content with a voice/lock glyph + "Audio pace"; use +`aria-disabled` (not a dead `disabled`); tooltip "Speed follows audio". Because the +full-screen RSVP overlay hides the TTS rate panel, **tapping it opens a compact TTS rate +picker** (reuse the existing rate options) so rate stays changeable without leaving RSVP — +a one-shot set, which is throttle-safe (unlike a rapid stepper). E-ink: signal disabled with +lock glyph + border, not opacity. + +### Decouple UX (decision 4) + +First decouple shows a one-time transient toast ("Stopped following audio — tap to resume"). +Enumerate the gesture → decouple matrix so it's not invented ad-hoc: + +| Gesture | Decouple? | +|---------|-----------| +| Paragraph next/prev/scroll/swipe | Yes | +| Paragraph neutral-zone tap (reveal controls) | No | +| RSVP skip / seek / word-step / progress-drag / chapter-jump / context-word-seek | Yes | +| RSVP center-tap (maps to TTS play/pause when synced) | No | +| RSVP speed-swipe | N/A (speed disabled while synced) | + +Re-engage: tap the chip (or the RSVP TTS icon) → re-sync to the current TTS position. + +Done-condition addition: toggle Settings → Misc → Eink and verify the indicator, RSVP TTS +icon, and disabled-WPM rendering. + +## Data flow (Edge word-level, RSVP open) + +``` +audio.currentTime ─▶ EdgeTTSClient RAF ─▶ TTSController.dispatchSpeakWord(i) + └▶ 'tts-highlight-word' {cfi} (on controller) + └▶ useTTSControl.handleHighlightWord + └▶ eventDispatcher.dispatch('tts-position', {bookKey, cfi, kind:'word'}) + ├▶ useParagraphMode → findByRangeAsync → goToParagraph (if changed) + └▶ RSVPControl → controller.syncToCfi(cfi) → seekToIndex +``` + +Non-Edge swaps the word RAF for sentence marks (`tts-position {kind:'sentence'}`); RSVP +self-paces between marks. + +## Eng architecture corrections (from Eng review — auto-adopted) + +Both eng voices converged 8/8. Adopted: + +1. **Mapping must not be O(N)-from-0 per word.** `RSVPController.findWordIndexByCfi` and + `ParagraphIterator.findByRangeAsync` currently rescan from index 0 each call. At Edge + word rates on a 45k-word chapter this janks (and fixed-layout runs per-word `getCFI`, + catastrophic). Fix: the new public mapper keeps a **monotonic cursor** (start scan at the + last synced index, forward), with a **binary search** over the document-ordered ranges for + seek/decouple/resync. Assert in tests that **no per-word `getCFI` runs**. +2. **Map by containment/overlap, not "first word whose start ≥ target."** The existing + mapper returns the first word starting at-or-after the target, which **skips to the next + word** when TTS lands mid-token. Choose the word whose range *contains/intersects* the + target; only fall back to nearest-following when there's no overlap. (This is what the + "tokenisation mismatch" risk actually needs — the old mapper did not solve it.) +3. **`syncToCfi` no-ops on no-match.** `findWordIndexByCfi` returns `-1` and + `findByRangeAsync` falls back to `first()` — both silently jump to word/paragraph 0. The + sync path must distinguish "no match" (do nothing, stay put) from a real index 0. +4. **Section-generation contract** (the highest-risk bug). When a `tts-position`'s + `sectionIndex` ≠ the mode's current section: (a) do NOT map; (b) enter the `syncing` + indicator state; (c) invalidate the mode's section state and let its own relocate-driven + re-init run (`initIterator` / `loadNextPageContent`); (d) apply only the latest queued CFI + once its section is ready and its `sequence` is still current. Paragraph's TTS-driven focus + must use a **sync-focus path that does NOT arm `isFocusingRef`** — otherwise the 200ms + `isFocusingRef` window eats the TTS relocate, the iterator never re-inits, and it focuses + paragraph 0 of the wrong section. +5. **Gate sync to reflowable for v1.** Fixed-layout (`bookData.isFixedLayout`) → render the + `unsupported` indicator state; don't run the per-word `getCFI` slow path. +6. **Non-Edge estimator guardrails** (D3 kept, so make it degrade well): seed `~190 × ttsRate` + but **hold at the sentence's last word** until the next mark (never advance past the + current sentence's word range), clamp per-word duration to a floor/ceiling, cap snap + distance, and if a correction exceeds a threshold jump once rather than animate a long + catch-up. Compute the sentence's end word index (next mark, or `getSpokenSentence().text` + length) so "hold" has a bound. +7. **`bookKey` scoping:** filter on the full `bookKey` prop (TTS `bookKey` carries a session + suffix that `RSVPController.bookId` strips — comparing `bookId` would misroute in split + view). Validate a start-alignment range's `ownerDocument` matches the live content before + dispatching `tts-speak`. + +## Edge cases & risks + +- **Tokenisation mismatch.** Edge word boundaries and RSVP's segmenter (Jieba / Intl) split + differently. We map by **CFI/range containment**, not by index equality, so counts need + not match; a TTS word that lands mid-RSVP-word resolves to that RSVP word. +- **Section boundary races.** TTS navigates the view; both modes re-init per section. Order + matters — map only after the iterator/extraction targets the CFI's section. +- **User text selection / page-follow.** Existing TTS page-follow logic must keep working; + the broadcast is additive and must not change current `tts-highlight-*` behavior. +- **Fixed-layout books.** RSVP's CFI path has a fixed-layout branch; verify mapping there or + gate sync to reflowable. +- **Multiple views (split/parallel).** All events are `bookKey`-scoped; every subscriber + filters on it. +- **No regressions when unsynced.** With TTS off, both modes behave exactly as today + (timer-driven RSVP, manual paragraph nav). + +## Testing (test-first) + +Per `.claude/rules/test-first.md`, write failing tests first. Full matrix in the test-plan +artifact: `~/.gstack/projects/readest/tts-sync-test-plan-20260614.md`. Highlights: + +- **Pure mappers (unit):** containment-based CFI→index for paragraph and RSVP (incl. mid-token + / CJK / hyphen / rewritten-SSML); monotonic-cursor + binary-search correctness; **no-match + returns -1** (not 0). Perf assertion: **no per-word `getCFI`** on Edge word events. +- **Controller emission (unit):** `TTSController` emits `tts-position` with correct + `kind`/`sectionIndex`/monotonic `sequence` from `dispatchSpeakWord`/`dispatchSpeakMark`. + Regression guard: "a handler stopped broadcasting" fails the test. +- **Stale-sequence suppression (unit):** out-of-order events ignored via `lastSequenceSeen`. +- **Non-Edge estimator (unit):** clamp + hold-at-sentence-end + cap-snap; outrun / early-end / + slow+fast voice cases. +- **Decouple matrix (unit/integration):** the gesture→decouple table; re-engage re-syncs. +- **Section transition (browser-e2e, PRIMARY case):** TTS drives the view across a chapter + boundary with paragraph + RSVP both active; assert correct re-init ordering (no wrong-section + paragraph-0), `isFocusingRef` not eaten. +- **Multi-view `bookKey` isolation; fixed-layout `unsupported`** (integration). + +**Prerequisite (flagged by Eng review; resolved at gate D6):** the real-`` TTS +browser-e2e harness the e2e cases need (`src/__tests__/services/tts-auto-advance.browser.test.tsx` ++ memory note `tts-browser-e2e-harness.md`) **is not on `dev`/`main`** — it lives on the +`test/tts-auto-advance-e2e` branch. **Decision: branch #3235 off `test/tts-auto-advance-e2e`** +so the harness is present; rebase if that branch changes before it lands. + +Done-conditions: `pnpm test`, `pnpm lint`. No `src-tauri/` or koplugin changes expected. + +## Out of scope (v1) + +- Word boundaries for Web Speech / Native engines. +- Reverse driving (RSVP WPM or paragraph nav continuously steering TTS) beyond the + user-initiated re-seek. +- Persisting a "synced" preference (coupling is automatic and ephemeral). +- New i18n strings beyond the RSVP audio icon's label/tooltip + the "following audio" indicator. + + +## Decision Audit Trail + +CEO dual-voice review (Codex + independent Claude subagent) converged 6/6 on strategic +concerns. Premise gate surfaced four User Challenges; user resolved them as below. + +| # | Phase | Decision | Class | Principle | Rationale | Rejected alt | +|---|-------|----------|-------|-----------|-----------|--------------| +| D1 | Intake | Review the #3235 spec doc, base origin/main, skip gstack housekeeping | Mechanical | P3 pragmatic | Only plan that exists; branch in flux; keep focus | branch-setup-first, gstack-upgrade-first | +| D2 | CEO | Keep both modes in **one PR** | User Challenge | user context | Both models recommended split; user kept one PR (shared plumbing, owns roadmap) | split paragraph-first | +| D3 | CEO | Keep the **non-Edge sentence-paced estimator** | User Challenge | user context | Both models called it gold-plating on `190×rate`; user kept it (wants word-motion on all voices) | snap-to-sentence-hold; Edge-only | +| D4 | CEO | Adopt indicator + decouple; **disable** RSVP speed while TTS-driven | User Challenge | P5 explicit | Both models flagged silent coupling surprise + speed→rate 3s-throttle stutter | keep silent coupling + speed→rate | +| A1 | CEO | Run Codex + Claude dual voices | Mechanical | P6 action | Always run both when available | — | +| A2 | CEO | Skip DX phase (Phase 3.5) | Mechanical | — | End-user reading feature, not developer-facing; no DX scope | run DX | +| A3 | Design | Adopt 3-state indicator + placement + voice-glyph + WPM rate-picker + decouple matrix + e-ink rules | Auto (P5/P1) | explicit/complete | Both design voices converged 8/8; additive specs, not reversals; folded into "UI states & placement" | leave UI to implementer | +| A4 | Eng | Controller-owned `tts-position` + sequence + latest-only + `tts-playback-state` | Auto (P5) | explicit | Both eng voices CRIT/HIGH; hook handlers early-return on suppression → silent desync | hook-owned dispatch | +| A5 | Eng | Monotonic-cursor + binary-search mapping; no per-word `getCFI`; containment match; -1 no-op | Auto (P1/P5) | complete | Both flagged O(N)/word jank + mid-token skip | keep first-≥ scan | +| A6 | Eng | Section-generation contract; sync-focus path that doesn't arm `isFocusingRef` | Auto (P5) | explicit | Highest shipped-bug risk (wrong-section paragraph 0) | "re-init on next event" hand-wave | +| A7 | Eng | Gate sync to reflowable v1 (fixed-layout → `unsupported`); estimator clamp/hold/snap-cap | Auto (P3/P1) | pragmatic | Per-word getCFI on fixed-layout catastrophic; estimator drift | full fixed-layout support now | +| A8 | Eng | Write test-plan artifact; flag e2e harness prerequisite (on `test/tts-auto-advance-e2e`, not dev) | Mechanical | complete | Cited harness absent on current checkout | assume harness exists | +| D5 | Gate | **Approve plan as-is** | User | — | All findings addressed or consciously deferred; plan implementation-ready | revise / interrogate | +| D6 | Gate | **Branch #3235 off `test/tts-auto-advance-e2e`** | User | — | e2e harness (section-boundary case) lives there, not on dev/main | land-to-main-first; defer-e2e | +| D7 | Gate | **Gate sync to reflowable for v1** (fixed-layout → unsupported) | User (confirms A7) | P3/P1 | per-word `getCFI` on fixed-layout janks | support fixed-layout now | + +## GSTACK REVIEW REPORT + +**Status: APPROVED** (gate D5). /autoplan pipeline: CEO → Design → Eng (DX skipped — no +developer-facing scope). Dual voices (Codex `codex-cli 0.134.0` + independent Claude +subagent) each phase. Consensus: CEO 6/6, Design 8/8, Eng 8/8. 3 User Challenges (you kept +one-PR + estimator, adopted coupling refinements); 8 auto-decisions; 3 gate decisions. +Artifacts: this spec (hardened), test plan `~/.gstack/projects/readest/tts-sync-test-plan-20260614.md`, +restore point `~/.gstack/projects/readest/autoplan-restore-tts-sync-20260614-024952.md`. +Next: branch #3235 off `test/tts-auto-advance-e2e`, then writing-plans → implement (test-first). + +### CEO findings carried forward (not blocking, for Eng/Design phases) +- **Estimator fidelity (D3 kept):** Eng must pin `ESTIMATED_TTS_WPM` behavior so non-Edge + RSVP degrades gracefully (race-then-snap is the risk). Consider clamping per-word + duration and capping snap distance. +- **Position-broadcast ownership (6-month regret):** prefer the controller emitting the + canonical `tts-position` with the hook forwarding, over hand-mirroring in React handlers, + so a future engine can't silently desync. Add a regression test for "handler stopped + broadcasting." +- **Section-race timing:** new sync path stacks onto existing tuned timers (2000ms TTS + cross-section suppression, 200/100ms paragraph relocate guards, RSVP 150/200ms retries). + Make sync subscribe to the same relocate/highlight signals; write down the ordering + contract; test the section boundary as a primary case. + diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index d67f0597..e7f5daa4 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1854,5 +1854,13 @@ "All tools are in the toolbar.": "جميع الأدوات موجودة في شريط الأدوات.", "Add all": "إضافة الكل", "Clear all": "مسح الكل", - "No tools, drag one here": "لا توجد أدوات، اسحب واحدة إلى هنا" + "No tools, drag one here": "لا توجد أدوات، اسحب واحدة إلى هنا", + "Stopped following audio": "تم إيقاف متابعة الصوت", + "Audio pace": "إيقاع الصوت", + "Speed follows audio": "تتبع السرعة الصوت", + "Pause audio": "إيقاف الصوت مؤقتًا", + "Play audio": "تشغيل الصوت", + "Resume audio": "استئناف متابعة الصوت", + "Following audio": "يتابع الصوت", + " · estimated": " · تقديري" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index a3acbc34..5bd644ff 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "সব টুল টুলবারে রয়েছে।", "Add all": "সব যোগ করুন", "Clear all": "সব মুছুন", - "No tools, drag one here": "কোনো টুল নেই, এখানে একটি টেনে আনুন" + "No tools, drag one here": "কোনো টুল নেই, এখানে একটি টেনে আনুন", + "Stopped following audio": "অডিও অনুসরণ বন্ধ হয়েছে", + "Audio pace": "অডিওর গতি", + "Speed follows audio": "গতি অডিও অনুসরণ করে", + "Pause audio": "অডিও বিরতি দিন", + "Play audio": "অডিও চালান", + "Resume audio": "আবার অনুসরণ করুন", + "Following audio": "অডিও অনুসরণ করছে", + " · estimated": " · আনুমানিক" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 74e33225..7546a1d7 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "ལག་ཆ་ཡོངས་རྫོགས་ལག་ཆའི་ཕྲེང་ནང་ཡོད།", "Add all": "ཚང་མ་སྣོན་པ།", "Clear all": "ཚང་མ་གཙང་སེལ།", - "No tools, drag one here": "ལག་ཆ་མེད། འདིར་གཅིག་འདྲུད་རོགས།" + "No tools, drag one here": "ལག་ཆ་མེད། འདིར་གཅིག་འདྲུད་རོགས།", + "Stopped following audio": "སྒྲ་གདངས་རྗེས་འདེད་མཚམས་བཞག", + "Audio pace": "སྒྲའི་མགྱོགས་ཚད།", + "Speed follows audio": "མགྱོགས་ཚད་སྒྲ་གདངས་ལ་རྗེས་འདེད་བྱེད།", + "Pause audio": "སྒྲ་གདངས་འགོག་པ།", + "Play audio": "སྒྲ་གདངས་གཏོང་བ།", + "Resume audio": "རྗེས་འདེད་མུ་མཐུད།", + "Following audio": "སྒྲ་གདངས་རྗེས་འདེད་བྱེད་བཞིན་པ།", + " · estimated": " · ཚོད་དཔག" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 30c1b33f..d7ce2c30 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Alle Werkzeuge befinden sich in der Werkzeugleiste.", "Add all": "Alle hinzufügen", "Clear all": "Alle entfernen", - "No tools, drag one here": "Keine Werkzeuge, zieh eines hierher" + "No tools, drag one here": "Keine Werkzeuge, zieh eines hierher", + "Stopped following audio": "Audio wird nicht mehr verfolgt", + "Audio pace": "Audio-Tempo", + "Speed follows audio": "Tempo folgt dem Audio", + "Pause audio": "Audio pausieren", + "Play audio": "Audio abspielen", + "Resume audio": "Wieder folgen", + "Following audio": "Folgt dem Audio", + " · estimated": " · geschätzt" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index a031aa69..960dc455 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Όλα τα εργαλεία βρίσκονται στη γραμμή εργαλείων.", "Add all": "Προσθήκη όλων", "Clear all": "Εκκαθάριση όλων", - "No tools, drag one here": "Δεν υπάρχουν εργαλεία, σύρετε ένα εδώ" + "No tools, drag one here": "Δεν υπάρχουν εργαλεία, σύρετε ένα εδώ", + "Stopped following audio": "Διακόπηκε η παρακολούθηση ήχου", + "Audio pace": "Ρυθμός ήχου", + "Speed follows audio": "Η ταχύτητα ακολουθεί τον ήχο", + "Pause audio": "Παύση ήχου", + "Play audio": "Αναπαραγωγή ήχου", + "Resume audio": "Συνέχιση ήχου", + "Following audio": "Ακολουθεί τον ήχο", + " · estimated": " · κατ' εκτίμηση" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 7af11e25..ba95c784 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Todas las herramientas están en la barra de herramientas.", "Add all": "Añadir todo", "Clear all": "Borrar todo", - "No tools, drag one here": "No hay herramientas, arrastra una aquí" + "No tools, drag one here": "No hay herramientas, arrastra una aquí", + "Stopped following audio": "Se dejó de seguir el audio", + "Audio pace": "Ritmo del audio", + "Speed follows audio": "La velocidad sigue al audio", + "Pause audio": "Pausar audio", + "Play audio": "Reproducir audio", + "Resume audio": "Reanudar audio", + "Following audio": "Siguiendo el audio", + " · estimated": " · estimado" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index e3a760e8..7ce75d7a 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "همهٔ ابزارها در نوار ابزار هستند.", "Add all": "افزودن همه", "Clear all": "پاک کردن همه", - "No tools, drag one here": "هیچ ابزاری نیست، یکی را به اینجا بکشید" + "No tools, drag one here": "هیچ ابزاری نیست، یکی را به اینجا بکشید", + "Stopped following audio": "دنبال‌کردن صدا متوقف شد", + "Audio pace": "آهنگ صدا", + "Speed follows audio": "سرعت از صدا پیروی می‌کند", + "Pause audio": "مکث صدا", + "Play audio": "پخش صدا", + "Resume audio": "ادامهٔ دنبال‌کردن صدا", + "Following audio": "در حال دنبال‌کردن صدا", + " · estimated": " · تخمینی" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index fd668f81..893b5995 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Tous les outils sont dans la barre d’outils.", "Add all": "Tout ajouter", "Clear all": "Tout effacer", - "No tools, drag one here": "Aucun outil, glissez-en un ici" + "No tools, drag one here": "Aucun outil, glissez-en un ici", + "Stopped following audio": "Suivi de l'audio arrêté", + "Audio pace": "Rythme audio", + "Speed follows audio": "La vitesse suit l'audio", + "Pause audio": "Mettre l'audio en pause", + "Play audio": "Lire l'audio", + "Resume audio": "Reprendre l'audio", + "Following audio": "Suit l'audio", + " · estimated": " · estimé" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 411778be..c6da9232 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "כל הכלים נמצאים בסרגל הכלים.", "Add all": "הוסף הכול", "Clear all": "נקה הכול", - "No tools, drag one here": "אין כלים, גררו אחד לכאן" + "No tools, drag one here": "אין כלים, גררו אחד לכאן", + "Stopped following audio": "המעקב אחר השמע הופסק", + "Audio pace": "קצב השמע", + "Speed follows audio": "המהירות עוקבת אחר השמע", + "Pause audio": "השהיית שמע", + "Play audio": "השמעת שמע", + "Resume audio": "חידוש מעקב", + "Following audio": "עוקב אחר השמע", + " · estimated": " · משוער" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 8c9dedcd..046ac234 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "सभी टूल टूलबार में हैं।", "Add all": "सभी जोड़ें", "Clear all": "सभी साफ़ करें", - "No tools, drag one here": "कोई टूल नहीं, यहाँ एक खींचें" + "No tools, drag one here": "कोई टूल नहीं, यहाँ एक खींचें", + "Stopped following audio": "ऑडियो का अनुसरण रोक दिया", + "Audio pace": "ऑडियो गति", + "Speed follows audio": "गति ऑडियो का अनुसरण करती है", + "Pause audio": "ऑडियो रोकें", + "Play audio": "ऑडियो चलाएँ", + "Resume audio": "ऑडियो फिर से अनुसरण करें", + "Following audio": "ऑडियो का अनुसरण कर रहा है", + " · estimated": " · अनुमानित" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 1f239ac7..123dc211 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Minden eszköz az eszköztáron van.", "Add all": "Összes hozzáadása", "Clear all": "Összes törlése", - "No tools, drag one here": "Nincsenek eszközök, húzz ide egyet" + "No tools, drag one here": "Nincsenek eszközök, húzz ide egyet", + "Stopped following audio": "A hang követése leállt", + "Audio pace": "Hang tempója", + "Speed follows audio": "A sebesség a hangot követi", + "Pause audio": "Hang szüneteltetése", + "Play audio": "Hang lejátszása", + "Resume audio": "Követés folytatása", + "Following audio": "Hangot követi", + " · estimated": " · becsült" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 25674406..527c1413 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "Semua alat ada di bilah alat.", "Add all": "Tambah semua", "Clear all": "Hapus semua", - "No tools, drag one here": "Tidak ada alat, seret satu ke sini" + "No tools, drag one here": "Tidak ada alat, seret satu ke sini", + "Stopped following audio": "Berhenti mengikuti audio", + "Audio pace": "Tempo audio", + "Speed follows audio": "Kecepatan mengikuti audio", + "Pause audio": "Jeda audio", + "Play audio": "Putar audio", + "Resume audio": "Lanjutkan mengikuti", + "Following audio": "Mengikuti audio", + " · estimated": " · perkiraan" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index ca28ae57..eb0190d3 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Tutti gli strumenti sono nella barra degli strumenti.", "Add all": "Aggiungi tutto", "Clear all": "Cancella tutto", - "No tools, drag one here": "Nessuno strumento, trascinane uno qui" + "No tools, drag one here": "Nessuno strumento, trascinane uno qui", + "Stopped following audio": "Audio non più seguito", + "Audio pace": "Ritmo dell'audio", + "Speed follows audio": "La velocità segue l'audio", + "Pause audio": "Metti in pausa l'audio", + "Play audio": "Riproduci audio", + "Resume audio": "Riprendi audio", + "Following audio": "Segue l'audio", + " · estimated": " · stimato" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d3f39ed7..ee32cc47 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "すべてのツールがツールバーにあります。", "Add all": "すべて追加", "Clear all": "すべてクリア", - "No tools, drag one here": "ツールがありません、ここにドラッグしてください" + "No tools, drag one here": "ツールがありません、ここにドラッグしてください", + "Stopped following audio": "音声の追従を停止しました", + "Audio pace": "音声のペース", + "Speed follows audio": "速度は音声に従います", + "Pause audio": "音声を一時停止", + "Play audio": "音声を再生", + "Resume audio": "音声に再追従", + "Following audio": "音声に追従中", + " · estimated": " · 推定" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 9ea0e27a..f4d7c207 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "모든 도구가 도구 모음에 있습니다.", "Add all": "모두 추가", "Clear all": "모두 지우기", - "No tools, drag one here": "도구가 없습니다, 여기로 하나를 드래그하세요" + "No tools, drag one here": "도구가 없습니다, 여기로 하나를 드래그하세요", + "Stopped following audio": "오디오 따라가기를 중지함", + "Audio pace": "오디오 속도", + "Speed follows audio": "속도가 오디오를 따릅니다", + "Pause audio": "오디오 일시정지", + "Play audio": "오디오 재생", + "Resume audio": "오디오 다시 따라가기", + "Following audio": "오디오 따라가는 중", + " · estimated": " · 추정" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 57c580e0..60ff55e6 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "Semua alat berada dalam bar alat.", "Add all": "Tambah semua", "Clear all": "Kosongkan semua", - "No tools, drag one here": "Tiada alat, seret satu ke sini" + "No tools, drag one here": "Tiada alat, seret satu ke sini", + "Stopped following audio": "Berhenti mengikut audio", + "Audio pace": "Rentak audio", + "Speed follows audio": "Kelajuan mengikut audio", + "Pause audio": "Jeda audio", + "Play audio": "Main audio", + "Resume audio": "Sambung ikut audio", + "Following audio": "Mengikut audio", + " · estimated": " · anggaran" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index fc986ae2..b600de3e 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Alle gereedschappen staan op de werkbalk.", "Add all": "Alles toevoegen", "Clear all": "Alles wissen", - "No tools, drag one here": "Geen gereedschappen, sleep er een hierheen" + "No tools, drag one here": "Geen gereedschappen, sleep er een hierheen", + "Stopped following audio": "Audio niet meer gevolgd", + "Audio pace": "Audiotempo", + "Speed follows audio": "Snelheid volgt audio", + "Pause audio": "Audio pauzeren", + "Play audio": "Audio afspelen", + "Resume audio": "Audio hervatten", + "Following audio": "Volgt audio", + " · estimated": " · geschat" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 0db5aa94..9c19e236 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1788,5 +1788,13 @@ "All tools are in the toolbar.": "Wszystkie narzędzia są na pasku narzędzi.", "Add all": "Dodaj wszystkie", "Clear all": "Wyczyść wszystkie", - "No tools, drag one here": "Brak narzędzi, przeciągnij jedno tutaj" + "No tools, drag one here": "Brak narzędzi, przeciągnij jedno tutaj", + "Stopped following audio": "Zatrzymano podążanie za dźwiękiem", + "Audio pace": "Tempo dźwięku", + "Speed follows audio": "Prędkość podąża za dźwiękiem", + "Pause audio": "Wstrzymaj dźwięk", + "Play audio": "Odtwórz dźwięk", + "Resume audio": "Wznów podążanie", + "Following audio": "Podąża za dźwiękiem", + " · estimated": " · szacowane" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 386ab97d..3f5f73ca 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Todas as ferramentas estão na barra de ferramentas.", "Add all": "Adicionar tudo", "Clear all": "Limpar tudo", - "No tools, drag one here": "Sem ferramentas, arraste uma para cá" + "No tools, drag one here": "Sem ferramentas, arraste uma para cá", + "Stopped following audio": "Parou de seguir o áudio", + "Audio pace": "Ritmo do áudio", + "Speed follows audio": "A velocidade segue o áudio", + "Pause audio": "Pausar áudio", + "Play audio": "Reproduzir áudio", + "Resume audio": "Retomar áudio", + "Following audio": "Seguindo o áudio", + " · estimated": " · estimado" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index f7835982..b5bf7c34 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Todas as ferramentas estão na barra de ferramentas.", "Add all": "Adicionar tudo", "Clear all": "Limpar tudo", - "No tools, drag one here": "Sem ferramentas, arraste uma para aqui" + "No tools, drag one here": "Sem ferramentas, arraste uma para aqui", + "Stopped following audio": "Deixou de seguir o áudio", + "Audio pace": "Ritmo do áudio", + "Speed follows audio": "A velocidade segue o áudio", + "Pause audio": "Pausar áudio", + "Play audio": "Reproduzir áudio", + "Resume audio": "Retomar áudio", + "Following audio": "A seguir o áudio", + " · estimated": " · estimado" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 69da034a..4a0f02ab 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1755,5 +1755,13 @@ "All tools are in the toolbar.": "Toate instrumentele sunt în bara de instrumente.", "Add all": "Adaugă tot", "Clear all": "Șterge tot", - "No tools, drag one here": "Niciun instrument, trage unul aici" + "No tools, drag one here": "Niciun instrument, trage unul aici", + "Stopped following audio": "Urmărirea audio a fost oprită", + "Audio pace": "Ritm audio", + "Speed follows audio": "Viteza urmează audio", + "Pause audio": "Întrerupe audio", + "Play audio": "Redă audio", + "Resume audio": "Reia urmărirea", + "Following audio": "Urmărește audio", + " · estimated": " · estimat" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index a8096c14..01f0ccd8 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1788,5 +1788,13 @@ "All tools are in the toolbar.": "Все инструменты на панели инструментов.", "Add all": "Добавить все", "Clear all": "Очистить все", - "No tools, drag one here": "Нет инструментов, перетащите один сюда" + "No tools, drag one here": "Нет инструментов, перетащите один сюда", + "Stopped following audio": "Слежение за аудио остановлено", + "Audio pace": "Темп аудио", + "Speed follows audio": "Скорость следует за аудио", + "Pause audio": "Приостановить аудио", + "Play audio": "Воспроизвести аудио", + "Resume audio": "Возобновить слежение", + "Following audio": "Следует за аудио", + " · estimated": " · примерно" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index f53e3521..e803f51e 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "සියලුම මෙවලම් මෙවලම් තීරුවේ ඇත.", "Add all": "සියල්ල එක් කරන්න", "Clear all": "සියල්ල හිස් කරන්න", - "No tools, drag one here": "මෙවලම් නැත, මෙතැනට එකක් ඇද දමන්න" + "No tools, drag one here": "මෙවලම් නැත, මෙතැනට එකක් ඇද දමන්න", + "Stopped following audio": "ශ්‍රව්‍ය අනුගමනය නැවැත්වීය", + "Audio pace": "ශ්‍රව්‍ය වේගය", + "Speed follows audio": "වේගය ශ්‍රව්‍යය අනුගමනය කරයි", + "Pause audio": "ශ්‍රව්‍යය විරාම කරන්න", + "Play audio": "ශ්‍රව්‍යය වාදනය කරන්න", + "Resume audio": "නැවත අනුගමනය කරන්න", + "Following audio": "ශ්‍රව්‍යය අනුගමනය කරයි", + " · estimated": " · ඇස්තමේන්තුගත" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index f7326349..0cb173e9 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1788,5 +1788,13 @@ "All tools are in the toolbar.": "Vsa orodja so v orodni vrstici.", "Add all": "Dodaj vse", "Clear all": "Počisti vse", - "No tools, drag one here": "Ni orodij, povlecite eno sem" + "No tools, drag one here": "Ni orodij, povlecite eno sem", + "Stopped following audio": "Sledenje zvoku ustavljeno", + "Audio pace": "Tempo zvoka", + "Speed follows audio": "Hitrost sledi zvoku", + "Pause audio": "Začasno ustavi zvok", + "Play audio": "Predvajaj zvok", + "Resume audio": "Nadaljuj sledenje", + "Following audio": "Sledi zvoku", + " · estimated": " · ocenjeno" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 7411e45b..f4cb7801 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Alla verktyg finns i verktygsfältet.", "Add all": "Lägg till alla", "Clear all": "Rensa alla", - "No tools, drag one here": "Inga verktyg, dra ett hit" + "No tools, drag one here": "Inga verktyg, dra ett hit", + "Stopped following audio": "Slutade följa ljudet", + "Audio pace": "Ljudtempo", + "Speed follows audio": "Hastigheten följer ljudet", + "Pause audio": "Pausa ljud", + "Play audio": "Spela upp ljud", + "Resume audio": "Återuppta ljud", + "Following audio": "Följer ljudet", + " · estimated": " · uppskattat" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 231a5567..6af590ec 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "அனைத்து கருவிகளும் கருவிப்பட்டியில் உள்ளன.", "Add all": "அனைத்தையும் சேர்", "Clear all": "அனைத்தையும் அழி", - "No tools, drag one here": "கருவிகள் இல்லை, ஒன்றை இங்கே இழுக்கவும்" + "No tools, drag one here": "கருவிகள் இல்லை, ஒன்றை இங்கே இழுக்கவும்", + "Stopped following audio": "ஆடியோ பின்தொடர்தல் நிறுத்தப்பட்டது", + "Audio pace": "ஆடியோ வேகம்", + "Speed follows audio": "வேகம் ஆடியோவைப் பின்தொடர்கிறது", + "Pause audio": "ஆடியோவை இடைநிறுத்து", + "Play audio": "ஆடியோவை இயக்கு", + "Resume audio": "மீண்டும் பின்தொடர்", + "Following audio": "ஆடியோவைப் பின்தொடர்கிறது", + " · estimated": " · தோராயமாக" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index b0f743fe..88767aad 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "เครื่องมือทั้งหมดอยู่ในแถบเครื่องมือแล้ว", "Add all": "เพิ่มทั้งหมด", "Clear all": "ล้างทั้งหมด", - "No tools, drag one here": "ไม่มีเครื่องมือ ลากมาที่นี่สักรายการ" + "No tools, drag one here": "ไม่มีเครื่องมือ ลากมาที่นี่สักรายการ", + "Stopped following audio": "หยุดติดตามเสียงแล้ว", + "Audio pace": "จังหวะเสียง", + "Speed follows audio": "ความเร็วติดตามเสียง", + "Pause audio": "หยุดเสียงชั่วคราว", + "Play audio": "เล่นเสียง", + "Resume audio": "ติดตามเสียงต่อ", + "Following audio": "กำลังติดตามเสียง", + " · estimated": " · โดยประมาณ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index ca25e204..20800969 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Tüm araçlar araç çubuğunda.", "Add all": "Tümünü ekle", "Clear all": "Tümünü temizle", - "No tools, drag one here": "Araç yok, buraya bir tane sürükleyin" + "No tools, drag one here": "Araç yok, buraya bir tane sürükleyin", + "Stopped following audio": "Ses takibi durduruldu", + "Audio pace": "Ses temposu", + "Speed follows audio": "Hız sesi takip eder", + "Pause audio": "Sesi duraklat", + "Play audio": "Sesi oynat", + "Resume audio": "Sesi takibe devam et", + "Following audio": "Sesi takip ediyor", + " · estimated": " · tahmini" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index fe6c7d0b..3960a294 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1788,5 +1788,13 @@ "All tools are in the toolbar.": "Усі інструменти на панелі інструментів.", "Add all": "Додати все", "Clear all": "Очистити все", - "No tools, drag one here": "Немає інструментів, перетягніть один сюди" + "No tools, drag one here": "Немає інструментів, перетягніть один сюди", + "Stopped following audio": "Стеження за аудіо зупинено", + "Audio pace": "Темп аудіо", + "Speed follows audio": "Швидкість слідує за аудіо", + "Pause audio": "Призупинити аудіо", + "Play audio": "Відтворити аудіо", + "Resume audio": "Відновити стеження", + "Following audio": "Стежить за аудіо", + " · estimated": " · приблизно" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 8c495707..d7cdd7db 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1722,5 +1722,13 @@ "All tools are in the toolbar.": "Barcha asboblar asboblar panelida.", "Add all": "Hammasini qoʻshish", "Clear all": "Hammasini tozalash", - "No tools, drag one here": "Asboblar yoʻq, bittasini shu yerga torting" + "No tools, drag one here": "Asboblar yoʻq, bittasini shu yerga torting", + "Stopped following audio": "Audioga ergashish to'xtatildi", + "Audio pace": "Audio temp", + "Speed follows audio": "Tezlik audioga ergashadi", + "Pause audio": "Audioni pauza qilish", + "Play audio": "Audioni ijro etish", + "Resume audio": "Ergashishni davom ettirish", + "Following audio": "Audioga ergashmoqda", + " · estimated": " · taxminiy" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index f41e0b9e..de1fbdd6 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "Tất cả công cụ đều ở trên thanh công cụ.", "Add all": "Thêm tất cả", "Clear all": "Xóa tất cả", - "No tools, drag one here": "Không có công cụ, hãy kéo một công cụ vào đây" + "No tools, drag one here": "Không có công cụ, hãy kéo một công cụ vào đây", + "Stopped following audio": "Đã dừng theo âm thanh", + "Audio pace": "Nhịp âm thanh", + "Speed follows audio": "Tốc độ theo âm thanh", + "Pause audio": "Tạm dừng âm thanh", + "Play audio": "Phát âm thanh", + "Resume audio": "Tiếp tục theo âm thanh", + "Following audio": "Đang theo âm thanh", + " · estimated": " · ước tính" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index be004267..57a000a0 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "所有工具都在工具栏中。", "Add all": "全部添加", "Clear all": "全部清除", - "No tools, drag one here": "没有工具,拖一个到这里" + "No tools, drag one here": "没有工具,拖一个到这里", + "Stopped following audio": "已停止跟随音频", + "Audio pace": "音频节奏", + "Speed follows audio": "速度跟随音频", + "Pause audio": "暂停音频", + "Play audio": "播放音频", + "Resume audio": "恢复跟随", + "Following audio": "正在跟随音频", + " · estimated": " · 估算" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 364d7328..0bf5b25f 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1689,5 +1689,13 @@ "All tools are in the toolbar.": "所有工具都在工具列中。", "Add all": "全部新增", "Clear all": "全部清除", - "No tools, drag one here": "沒有工具,拖一個到這裡" + "No tools, drag one here": "沒有工具,拖一個到這裡", + "Stopped following audio": "已停止跟隨音訊", + "Audio pace": "音訊節奏", + "Speed follows audio": "速度跟隨音訊", + "Pause audio": "暫停音訊", + "Play audio": "播放音訊", + "Resume audio": "恢復跟隨", + "Following audio": "正在跟隨音訊", + " · estimated": " · 估算" } diff --git a/apps/readest-app/src/__tests__/app/reader/components/tts/TTSFollowIndicator.test.tsx b/apps/readest-app/src/__tests__/app/reader/components/tts/TTSFollowIndicator.test.tsx new file mode 100644 index 00000000..34dcbfb4 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/components/tts/TTSFollowIndicator.test.tsx @@ -0,0 +1,66 @@ +import { cleanup, render, fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import TTSFollowIndicator from '@/app/reader/components/tts/TTSFollowIndicator'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (key: string) => key, +})); + +describe('TTSFollowIndicator', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the following label', () => { + const { getByText, queryByRole } = render(); + expect(getByText('Following audio')).toBeTruthy(); + // following is a status, not an action — must not be a button. + expect(queryByRole('button')).toBeNull(); + }); + + it('appends the estimated suffix for RSVP non-Edge following', () => { + const { getByText } = render(); + expect(getByText('Following audio')).toBeTruthy(); + // getByText normalizes whitespace, so the leading separator space is trimmed. + expect(getByText(/estimated/)).toBeTruthy(); + }); + + it('does not append the estimated suffix when estimated is false', () => { + const { queryByText } = render(); + expect(queryByText(/estimated/)).toBeNull(); + }); + + it('renders a Resume audio button that calls onResume when decoupled', () => { + const onResume = vi.fn(); + const { getByRole, getByText } = render( + , + ); + expect(getByText('Resume audio')).toBeTruthy(); + const button = getByRole('button'); + fireEvent.click(button); + expect(onResume).toHaveBeenCalledTimes(1); + }); + + it('shows a loading affordance and the following look while syncing', () => { + const { container, getByText } = render(); + expect(getByText('Following audio')).toBeTruthy(); + expect(container.querySelector('.loading-dots')).toBeTruthy(); + }); + + it('renders nothing when idle', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when unsupported', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('always carries the eink-bordered class so it survives e-ink mode', () => { + const { container } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.className).toContain('eink-bordered'); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/paragraph-tts-highlight.test.ts b/apps/readest-app/src/__tests__/components/paragraph-tts-highlight.test.ts new file mode 100644 index 00000000..06876de2 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/paragraph-tts-highlight.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect } from 'vitest'; +import { + computeParagraphHighlightOffsets, + decideParagraphTtsHighlight, + buildTtsHighlightCssText, +} from '@/app/reader/components/paragraph/paragraphTts'; + +// Build a paragraph element and return both the element and its +// selectNodeContents range (what the iterator hands paragraph mode). +const makeParagraph = (html: string) => { + const p = document.createElement('p'); + p.innerHTML = html; + document.body.appendChild(p); + const range = document.createRange(); + range.selectNodeContents(p); + return { p, range }; +}; + +const rangeInNode = (node: Node, start: number, end: number) => { + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + return range; +}; + +describe('computeParagraphHighlightOffsets (#3235)', () => { + test('word in a single text node → character offsets relative to paragraph start', () => { + const { p, range } = makeParagraph('Hello world foo'); + const text = p.firstChild!; // "Hello world foo" + const target = rangeInNode(text, 6, 11); // "world" + + expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 6, end: 11 }); + }); + + test('word at the very start of the paragraph → start 0', () => { + const { p, range } = makeParagraph('Hello world'); + const target = rangeInNode(p.firstChild!, 0, 5); // "Hello" + + expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 0, end: 5 }); + }); + + test('word inside a nested inline element → offsets across node boundaries', () => { + // "Hello " (6) + "brave" (in ) + " world" + const { p, range } = makeParagraph('Hello brave world'); + const bold = p.querySelector('b')!; + const target = rangeInNode(bold.firstChild!, 0, 5); // "brave" + + expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 6, end: 11 }); + }); + + test('word after a nested inline element counts the inline text', () => { + const { p, range } = makeParagraph('Hello brave world'); + const tail = p.childNodes[2]!; // " world" text node + const target = rangeInNode(tail, 1, 6); // "world" + + expect(computeParagraphHighlightOffsets(range, target)).toEqual({ start: 12, end: 17 }); + }); + + test('target outside the paragraph → null', () => { + const { range } = makeParagraph('Hello world'); + const other = makeParagraph('Different paragraph'); + const target = rangeInNode(other.p.firstChild!, 0, 9); // "Different" + + expect(computeParagraphHighlightOffsets(range, target)).toBeNull(); + }); + + test('collapsed / empty target → null', () => { + const { p, range } = makeParagraph('Hello world'); + const target = rangeInNode(p.firstChild!, 3, 3); // empty + + expect(computeParagraphHighlightOffsets(range, target)).toBeNull(); + }); +}); + +describe('decideParagraphTtsHighlight (#3235)', () => { + test('word boundary event → highlight the word', () => { + expect(decideParagraphTtsHighlight({ kind: 'word', hasWordPositions: false })).toBe('word'); + expect(decideParagraphTtsHighlight({ kind: 'word', hasWordPositions: true })).toBe('word'); + }); + + test('sentence event with no word boundaries seen → highlight the sentence', () => { + expect(decideParagraphTtsHighlight({ kind: 'sentence', hasWordPositions: false })).toBe( + 'sentence', + ); + }); + + test('sentence event after words seen → skip (words drive the fine-grained highlight)', () => { + expect(decideParagraphTtsHighlight({ kind: 'sentence', hasWordPositions: true })).toBe('skip'); + }); + + test('unknown kind falls back to sentence granularity', () => { + expect(decideParagraphTtsHighlight({ hasWordPositions: false })).toBe('sentence'); + expect(decideParagraphTtsHighlight({ hasWordPositions: true })).toBe('skip'); + }); +}); + +describe('buildTtsHighlightCssText (#3235)', () => { + test('defaults to a translucent background using the default color', () => { + const css = buildTtsHighlightCssText(undefined); + expect(css).toContain('background-color'); + expect(css).toContain('#808080'); + }); + + test('highlight style uses a translucent background of the configured color', () => { + const css = buildTtsHighlightCssText({ style: 'highlight', color: '#ffcc00' }); + expect(css).toContain('color-mix'); + expect(css).toContain('#ffcc00'); + }); + + test('underline style uses text-decoration underline', () => { + const css = buildTtsHighlightCssText({ style: 'underline', color: '#3366ff' }); + expect(css).toContain('text-decoration'); + expect(css).toContain('underline'); + expect(css).toContain('#3366ff'); + }); + + test('squiggly style uses a wavy underline', () => { + expect(buildTtsHighlightCssText({ style: 'squiggly', color: '#ff0000' })).toContain('wavy'); + }); + + test('strikethrough style uses line-through', () => { + expect(buildTtsHighlightCssText({ style: 'strikethrough', color: '#ff0000' })).toContain( + 'line-through', + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/paragraph-tts-speak-detail.test.ts b/apps/readest-app/src/__tests__/components/paragraph-tts-speak-detail.test.ts new file mode 100644 index 00000000..2151496e --- /dev/null +++ b/apps/readest-app/src/__tests__/components/paragraph-tts-speak-detail.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from 'vitest'; +import { buildParagraphTtsSpeakDetail } from '@/app/reader/components/paragraph/paragraphTts'; + +const BOOK_KEY = 'hash123-session456'; + +// Build a real DOM range inside a given document so ownerDocument validation is +// exercised against an actual node (not a mock). +const makeRange = (doc: Document, text = 'paragraph'): Range => { + const p = doc.createElement('p'); + p.textContent = text; + doc.body.appendChild(p); + const range = doc.createRange(); + range.selectNodeContents(p.firstChild!); + return range; +}; + +describe('buildParagraphTtsSpeakDetail (#3235)', () => { + test('focused paragraph with live range → detail with range + index (start-alignment)', () => { + const range = makeRange(document); + + const detail = buildParagraphTtsSpeakDetail(range, 3, BOOK_KEY, document); + + expect(detail.bookKey).toBe(BOOK_KEY); + expect(detail.index).toBe(3); + expect(detail.range).toBe(range); + }); + + test('stale / cross-document range → detail keeps index but omits range', () => { + // Range built in a different document than the one paragraph mode renders. + const otherDoc = document.implementation.createHTMLDocument('other'); + const staleRange = makeRange(otherDoc); + + const detail = buildParagraphTtsSpeakDetail(staleRange, 3, BOOK_KEY, document); + + expect(detail.index).toBe(3); + expect(detail.range).toBeUndefined(); + }); + + test('no range → detail keeps index, no range', () => { + const detail = buildParagraphTtsSpeakDetail(null, 2, BOOK_KEY, document); + + expect(detail.index).toBe(2); + expect(detail.range).toBeUndefined(); + }); + + test('no range and no docIndex → bookKey only (TTS falls back to progress)', () => { + const detail = buildParagraphTtsSpeakDetail(null, undefined, BOOK_KEY, document); + + expect(detail.bookKey).toBe(BOOK_KEY); + expect(detail.index).toBeUndefined(); + expect(detail.range).toBeUndefined(); + }); + + test('range present but docIndex unknown → range only, no index', () => { + const range = makeRange(document); + + const detail = buildParagraphTtsSpeakDetail(range, undefined, BOOK_KEY, document); + + expect(detail.index).toBeUndefined(); + expect(detail.range).toBe(range); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx index fe4ed5cd..fb50b362 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-section-advance.test.tsx @@ -92,6 +92,11 @@ vi.mock('@/services/rsvp', () => ({ loadNextPageContent: vi.fn(() => { loadedSections.push(primaryIndex); }), + // Slice 5 (#3235) TTS-sync surface used by RSVPControl's subscription. + setExternallyDriven: vi.fn(), + stopEstimator: vi.fn(), + syncToCfi: vi.fn(() => false), + driveEstimatedFromCfi: vi.fn(() => false), getStoredPosition: vi.fn(() => null), get currentState() { return { active: true }; diff --git a/apps/readest-app/src/__tests__/components/rsvp-tts-speak-detail.test.ts b/apps/readest-app/src/__tests__/components/rsvp-tts-speak-detail.test.ts new file mode 100644 index 00000000..e371e3ea --- /dev/null +++ b/apps/readest-app/src/__tests__/components/rsvp-tts-speak-detail.test.ts @@ -0,0 +1,73 @@ +import { describe, test, expect } from 'vitest'; +import { buildRsvpTtsSpeakDetail } from '@/app/reader/components/rsvp/rsvpTts'; +import type { RsvpWord } from '@/services/rsvp'; + +const BOOK_KEY = 'hash123-session456'; + +// Build a real DOM range inside a given document so ownerDocument validation is +// exercised against an actual node (not a mock). +const makeRange = (doc: Document, text = 'word'): Range => { + const p = doc.createElement('p'); + p.textContent = text; + doc.body.appendChild(p); + const range = doc.createRange(); + range.selectNodeContents(p.firstChild!); + return range; +}; + +describe('buildRsvpTtsSpeakDetail (slice 7, #3235)', () => { + test('no current word → null', () => { + expect(buildRsvpTtsSpeakDetail(null, BOOK_KEY, document)).toBeNull(); + expect(buildRsvpTtsSpeakDetail(undefined, BOOK_KEY, document)).toBeNull(); + }); + + test('valid word with live range → detail with range + index (start-alignment)', () => { + const range = makeRange(document); + const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, range, docIndex: 3 }; + + const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document); + + expect(detail).not.toBeNull(); + expect(detail!.bookKey).toBe(BOOK_KEY); + expect(detail!.index).toBe(3); + expect(detail!.range).toBe(range); + }); + + test('stale / cross-document range → detail keeps index but omits range', () => { + // Range built in a different document than the one RSVP is rendering. + const otherDoc = document.implementation.createHTMLDocument('other'); + const staleRange = makeRange(otherDoc); + const word: RsvpWord = { + text: 'word', + orpIndex: 1, + pauseMultiplier: 1, + range: staleRange, + docIndex: 3, + }; + + const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document); + + expect(detail).not.toBeNull(); + expect(detail!.index).toBe(3); + expect(detail!.range).toBeUndefined(); + }); + + test('word without a range → detail keeps index, no range', () => { + const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, docIndex: 2 }; + + const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document); + + expect(detail!.index).toBe(2); + expect(detail!.range).toBeUndefined(); + }); + + test('word without docIndex → detail omits index (TTS falls back to progress)', () => { + const range = makeRange(document); + const word: RsvpWord = { text: 'word', orpIndex: 1, pauseMultiplier: 1, range }; + + const detail = buildRsvpTtsSpeakDetail(word, BOOK_KEY, document); + + expect(detail!.index).toBeUndefined(); + expect(detail!.range).toBe(range); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/rsvp-tts-sync-decision.test.ts b/apps/readest-app/src/__tests__/components/rsvp-tts-sync-decision.test.ts new file mode 100644 index 00000000..39b4b238 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/rsvp-tts-sync-decision.test.ts @@ -0,0 +1,170 @@ +import { describe, test, expect } from 'vitest'; +import { + decideRsvpTtsPosition, + type RsvpTtsSyncState, + type RsvpTtsPositionDetail, +} from '@/app/reader/components/rsvp/RSVPControl'; + +const BOOK_KEY = 'hash123-session456'; + +const freshState = (over: Partial = {}): RsvpTtsSyncState => ({ + following: true, + lastSequenceSeen: -Infinity, + currentSectionIndex: 2, + hasWordPositions: false, + ...over, +}); + +const detail = (over: Partial = {}): RsvpTtsPositionDetail => ({ + bookKey: BOOK_KEY, + cfi: 'epubcfi(/6/6!/4/2/1:0)', + kind: 'word', + sectionIndex: 2, + sequence: 1, + ...over, +}); + +describe('decideRsvpTtsPosition (slice 5, #3235)', () => { + test('word + following + same section → sync (Edge word-level), advances lastSequenceSeen', () => { + const result = decideRsvpTtsPosition( + freshState(), + detail({ kind: 'word', sequence: 5 }), + BOOK_KEY, + ); + expect(result.action).toBe('sync'); + expect(result.cfi).toBe('epubcfi(/6/6!/4/2/1:0)'); + expect(result.nextState.lastSequenceSeen).toBe(5); + }); + + test('word position marks the engine word-capable (hasWordPositions)', () => { + const result = decideRsvpTtsPosition( + freshState(), + detail({ kind: 'word', sequence: 1 }), + BOOK_KEY, + ); + expect(result.nextState.hasWordPositions).toBe(true); + }); + + test('sentence + following + same section → drive-estimator (non-Edge, no words seen)', () => { + const result = decideRsvpTtsPosition( + freshState(), + detail({ kind: 'sentence', sequence: 3 }), + BOOK_KEY, + ); + expect(result.action).toBe('drive-estimator'); + expect(result.cfi).toBe('epubcfi(/6/6!/4/2/1:0)'); + expect(result.nextState.lastSequenceSeen).toBe(3); + }); + + test('sentence is IGNORED once a word has been seen (Edge: estimator must not fight words)', () => { + // Word-boundary engines emit sentence marks too; driving the estimator on + // them makes RSVP outrun the audio and the words snap it back → flashing. + const result = decideRsvpTtsPosition( + freshState({ hasWordPositions: true }), + detail({ kind: 'sentence', sequence: 7 }), + BOOK_KEY, + ); + expect(result.action).toBe('ignore'); + expect(result.nextState.lastSequenceSeen).toBe(7); + }); + + test('drops events for a different bookKey (no state change)', () => { + const state = freshState({ lastSequenceSeen: 1 }); + const result = decideRsvpTtsPosition(state, detail({ bookKey: 'other-book' }), BOOK_KEY); + expect(result.action).toBe('ignore'); + expect(result.nextState).toBe(state); + }); + + test('drops stale / out-of-order sequence (<= lastSequenceSeen)', () => { + const state = freshState({ lastSequenceSeen: 10 }); + const result = decideRsvpTtsPosition(state, detail({ sequence: 10 }), BOOK_KEY); + expect(result.action).toBe('ignore'); + expect(result.nextState).toBe(state); + + const older = decideRsvpTtsPosition(state, detail({ sequence: 4 }), BOOK_KEY); + expect(older.action).toBe('ignore'); + }); + + test('does nothing while decoupled (following=false) and does NOT advance the sequence', () => { + const state = freshState({ following: false, lastSequenceSeen: 2 }); + const result = decideRsvpTtsPosition(state, detail({ sequence: 7 }), BOOK_KEY); + expect(result.action).toBe('ignore'); + // Sequence not bumped: when we re-engage we want the next live position. + expect(result.nextState.lastSequenceSeen).toBe(2); + }); + + test('different section → re-extract + stash latest (no mapping)', () => { + const result = decideRsvpTtsPosition( + freshState({ currentSectionIndex: 2 }), + detail({ sectionIndex: 5, sequence: 9, cfi: 'epubcfi(/6/12!/4/2/1:0)' }), + BOOK_KEY, + ); + expect(result.action).toBe('reextract'); + expect(result.nextState.lastSequenceSeen).toBe(9); + expect(result.nextState.pendingSync).toEqual({ + cfi: 'epubcfi(/6/12!/4/2/1:0)', + sequence: 9, + sectionIndex: 5, + }); + }); + + test('guards malformed details (missing cfi / sectionIndex) → ignore', () => { + const state = freshState(); + expect(decideRsvpTtsPosition(state, detail({ cfi: undefined }), BOOK_KEY).action).toBe( + 'ignore', + ); + expect(decideRsvpTtsPosition(state, detail({ sectionIndex: undefined }), BOOK_KEY).action).toBe( + 'ignore', + ); + }); + + // ─── Fixed-layout gate (decision D7, slice 8b, #3235) ────────────────── + describe('fixed-layout / unsupported gate (D7)', () => { + test('word + fixed-layout → never sync; ignore (no state change)', () => { + const state = freshState(); + const result = decideRsvpTtsPosition(state, detail({ kind: 'word', sequence: 5 }), BOOK_KEY, { + unsupported: true, + }); + expect(result.action).toBe('ignore'); + expect(result.cfi).toBeUndefined(); + // The gate is checked first; it must not advance the sequence. + expect(result.nextState).toBe(state); + }); + + test('sentence + fixed-layout → never drive-estimator; ignore', () => { + const state = freshState(); + const result = decideRsvpTtsPosition( + state, + detail({ kind: 'sentence', sequence: 3 }), + BOOK_KEY, + { unsupported: true }, + ); + expect(result.action).toBe('ignore'); + expect(result.nextState).toBe(state); + }); + + test('different section + fixed-layout → ignore (no reextract / no stash)', () => { + const state = freshState({ currentSectionIndex: 2 }); + const result = decideRsvpTtsPosition( + state, + detail({ sectionIndex: 5, sequence: 9 }), + BOOK_KEY, + { unsupported: true }, + ); + expect(result.action).toBe('ignore'); + expect(result.nextState).toBe(state); + expect(result.nextState.pendingSync).toBeUndefined(); + }); + + test('unsupported=false behaves exactly like the default (word → sync)', () => { + const result = decideRsvpTtsPosition( + freshState(), + detail({ kind: 'word', sequence: 5 }), + BOOK_KEY, + { unsupported: false }, + ); + expect(result.action).toBe('sync'); + expect(result.nextState.lastSequenceSeen).toBe(5); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx new file mode 100644 index 00000000..e8808444 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/rsvp-tts-sync.test.tsx @@ -0,0 +1,393 @@ +'use client'; + +import { createRef } from 'react'; +import { render, act, cleanup } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import RSVPControl, { type RSVPControlHandle } from '@/app/reader/components/rsvp/RSVPControl'; +import { eventDispatcher } from '@/utils/event'; + +// Mounts the real RSVPControl with a mocked RSVPController + stores and asserts +// that the slice-5 subscription routes tts-position / tts-playback-state to the +// controller correctly (engage, word-sync, decouple, re-engage). + +const BOOK_KEY = 'hash123-session456'; + +let primaryIndex = 2; +let isFixedLayout = false; +const controllerEventListeners = new Map(); +let controllerMock: ReturnType; + +function makeControllerMock() { + return { + seedPosition: vi.fn(), + setCurrentCfi: vi.fn(), + requestStart: vi.fn(() => { + const event = new CustomEvent('rsvp-start-choice', { + detail: { hasSavedPosition: false, hasSelection: false }, + }); + (controllerEventListeners.get('rsvp-start-choice') ?? []).forEach((h) => h(event)); + }), + startFromCurrentPosition: vi.fn(), + stop: vi.fn(), + loadNextPageContent: vi.fn(), + setExternallyDriven: vi.fn(), + stopEstimator: vi.fn(), + togglePlayPause: vi.fn(), + syncToCfi: vi.fn(() => true), + driveEstimatedFromCfi: vi.fn(() => true), + getStoredPosition: vi.fn(() => null), + get currentState() { + return { active: true }; + }, + addEventListener: vi.fn((type: string, listener: EventListener) => { + if (!controllerEventListeners.has(type)) controllerEventListeners.set(type, []); + controllerEventListeners.get(type)!.push(listener); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + const arr = controllerEventListeners.get(type) ?? []; + controllerEventListeners.set( + type, + arr.filter((l) => l !== listener), + ); + }), + }; +} + +vi.mock('@/app/reader/components/rsvp/RSVPOverlay', () => ({ + default: () => null, +})); +vi.mock('@/app/reader/components/rsvp/RSVPStartDialog', () => ({ default: () => null })); +vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s })); +vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig: {} }) })); + +vi.mock('@/store/readerStore', () => { + const state = { + getView: () => mockView, + getProgress: () => ({ location: 'epubcfi(/6/6!/4/1:0)', sectionHref: 'ch2.xhtml' }), + getViewSettings: () => ({ + ttsRate: 1.0, + defaultFont: 'serif', + serifFont: 'Georgia', + sansSerifFont: 'Arial', + monospaceFont: 'Menlo', + defaultCJKFont: 'Noto', + }), + }; + return { + useReaderStore: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), + }; +}); + +vi.mock('@/store/bookDataStore', () => { + const state = { + getBookData: () => ({ book: { format: 'EPUB' }, bookDoc: { toc: [] }, isFixedLayout }), + getConfig: () => null, + setConfig: vi.fn(), + saveConfig: vi.fn(), + }; + return { + useBookDataStore: (selector?: (s: typeof state) => R) => + selector ? selector(state) : state, + }; +}); + +vi.mock('@/store/settingsStore', () => ({ useSettingsStore: () => ({ settings: {} }) })); +vi.mock('@/store/themeStore', () => ({ + useThemeStore: () => ({ themeCode: { primary: '#000' } }), +})); + +vi.mock('@/services/rsvp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // eslint-disable-next-line prefer-arrow-callback + RSVPController: Object.assign( + vi.fn(function RSVPControllerMock() { + return controllerMock; + }), + { estimatedWpmFromRate: vi.fn(() => 190) }, + ), + buildRsvpExitConfigUpdate: vi.fn(() => ({})), + }; +}); + +const mockView = { + renderer: { + get primaryIndex() { + return primaryIndex; + }, + get atEnd() { + return false; + }, + goTo: vi.fn(), + getContents: vi.fn(() => []), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + book: { format: 'EPUB' }, + getCFI: vi.fn(() => null), + addAnnotation: vi.fn(), + resolveCFI: vi.fn(), +}; + +const wordPos = (over: Record = {}) => ({ + bookKey: BOOK_KEY, + cfi: 'epubcfi(/6/6!/4/2/1:0)', + kind: 'word', + sectionIndex: 2, + ...over, +}); + +async function startSession() { + await act(async () => { + eventDispatcher.dispatch('rsvp-start', { bookKey: BOOK_KEY }); + await new Promise((r) => setTimeout(r, 20)); + }); +} + +describe('RSVPControl — TTS sync wiring (slice 5, #3235)', () => { + beforeEach(() => { + primaryIndex = 2; + isFixedLayout = false; + controllerEventListeners.clear(); + controllerMock = makeControllerMock(); + }); + + afterEach(() => cleanup()); + + test('engages following on playing and syncs Edge word positions', async () => { + render( + , + ); + await startSession(); + + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + }); + expect(controllerMock.setExternallyDriven).toHaveBeenLastCalledWith(true); + + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 1 })); + }); + expect(controllerMock.syncToCfi).toHaveBeenCalledWith('epubcfi(/6/6!/4/2/1:0)'); + }); + + test('drops stale sequences and positions for other books', async () => { + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + }); + + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 5 })); + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 3 })); // stale + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 5, bookKey: 'other' })); + }); + // Only the first (seq 5) maps; the stale and other-book events are dropped. + expect(controllerMock.syncToCfi).toHaveBeenCalledTimes(1); + }); + + test('sentence positions drive the estimator', async () => { + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + await eventDispatcher.dispatch('tts-position', wordPos({ kind: 'sentence', sequence: 1 })); + }); + expect(controllerMock.driveEstimatedFromCfi).toHaveBeenCalledWith( + 'epubcfi(/6/6!/4/2/1:0)', + 190, + ); + }); + + test('paused stops the estimator + ignores positions but KEEPS RSVP suspended (#3235)', async () => { + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + }); + + controllerMock.setExternallyDriven.mockClear(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'paused' }); + }); + expect(controllerMock.stopEstimator).toHaveBeenCalled(); + // Pause must NOT restore the RSVP timer, or RSVP would run away on its own + // while audio is paused. Only a full stop releases it (asserted below). + expect(controllerMock.setExternallyDriven).not.toHaveBeenCalledWith(false); + + // While paused, positions are ignored (following dropped). + controllerMock.syncToCfi.mockClear(); + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 99 })); + }); + expect(controllerMock.syncToCfi).not.toHaveBeenCalled(); + + // A full STOP releases RSVP back to its own control. + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'stopped' }); + }); + expect(controllerMock.setExternallyDriven).toHaveBeenLastCalledWith(false); + }); + + test('a manual nav decouples; following resumes on the next playing', async () => { + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + }); + + // User skips: controller emits rsvp-manual-nav -> decouple. + await act(async () => { + (controllerEventListeners.get('rsvp-manual-nav') ?? []).forEach((h) => + h(new CustomEvent('rsvp-manual-nav')), + ); + }); + + controllerMock.syncToCfi.mockClear(); + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 50 })); + }); + expect(controllerMock.syncToCfi).not.toHaveBeenCalled(); // decoupled + + // Re-engage on next playing, then a fresh position syncs again. + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 60 })); + }); + expect(controllerMock.syncToCfi).toHaveBeenCalledWith('epubcfi(/6/6!/4/2/1:0)'); + }); + + test('cleans up subscriptions on unmount', async () => { + const { unmount } = render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: BOOK_KEY, state: 'playing' }); + }); + + act(() => unmount()); + + controllerMock.syncToCfi.mockClear(); + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 200 })); + }); + expect(controllerMock.syncToCfi).not.toHaveBeenCalled(); + }); + + // ─── Fixed-layout gate + ttsSyncStatus (slice 8b, #3235) ─────────────── + describe('fixed-layout gate (D7)', () => { + test('playing does NOT engage and positions do NOT drive the controller', async () => { + isFixedLayout = true; + render( + , + ); + await startSession(); + + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'playing', + }); + }); + // Never engage external driving for fixed-layout. + expect(controllerMock.setExternallyDriven).not.toHaveBeenCalledWith(true); + + await act(async () => { + await eventDispatcher.dispatch('tts-position', wordPos({ sequence: 1 })); + await eventDispatcher.dispatch('tts-position', wordPos({ kind: 'sentence', sequence: 2 })); + }); + expect(controllerMock.syncToCfi).not.toHaveBeenCalled(); + expect(controllerMock.driveEstimatedFromCfi).not.toHaveBeenCalled(); + }); + + test('exposes ttsSyncStatus="unsupported" via the imperative handle', async () => { + isFixedLayout = true; + const handle = createRef(); + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'playing', + }); + }); + expect(handle.current?.ttsSyncStatus).toBe('unsupported'); + }); + }); + + describe('ttsSyncStatus transitions (reflowable)', () => { + test('idle → following on playing → idle on stopped', async () => { + const handle = createRef(); + render( + , + ); + await startSession(); + // Not playing yet. + expect(handle.current?.ttsSyncStatus).toBe('idle'); + + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'playing', + }); + }); + expect(handle.current?.ttsSyncStatus).toBe('following'); + + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'stopped', + }); + }); + expect(handle.current?.ttsSyncStatus).toBe('idle'); + }); + + test('decoupled on a manual nav while playing', async () => { + const handle = createRef(); + render( + , + ); + await startSession(); + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: BOOK_KEY, + state: 'playing', + }); + }); + expect(handle.current?.ttsSyncStatus).toBe('following'); + + await act(async () => { + (controllerEventListeners.get('rsvp-manual-nav') ?? []).forEach((h) => + h(new CustomEvent('rsvp-manual-nav')), + ); + }); + expect(handle.current?.ttsSyncStatus).toBe('decoupled'); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx index e36699e8..66e83b78 100644 --- a/apps/readest-app/src/__tests__/paragraph-mode.test.tsx +++ b/apps/readest-app/src/__tests__/paragraph-mode.test.tsx @@ -23,6 +23,14 @@ const mockGetViewSettings = vi.fn(() => currentViewSettings); const mockSetViewSettings = vi.fn(); const mockGetProgress = vi.fn(() => null); +let mockIsFixedLayout = false; + +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ + getBookData: () => ({ isFixedLayout: mockIsFixedLayout }), + }), +})); + vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig: {}, appService: { hasSafeAreaInset: false } }), })); @@ -102,6 +110,7 @@ describe('paragraph mode', () => { beforeEach(() => { vi.clearAllMocks(); hookApi = null; + mockIsFixedLayout = false; currentViewSettings.writingMode = 'horizontal-tb'; currentViewSettings.vertical = false; currentViewSettings.rtl = false; @@ -373,3 +382,509 @@ describe('paragraph mode', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe('paragraph mode TTS sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + hookApi = null; + mockIsFixedLayout = false; + currentViewSettings.writingMode = 'horizontal-tb'; + currentViewSettings.vertical = false; + currentViewSettings.rtl = false; + }); + + afterEach(() => { + cleanup(); + }); + + // Multi-paragraph doc: ParagraphIterator turns each

into one block, so + // block N corresponds to the Nth

. + const createMultiParagraphDoc = () => + createDoc('

Block zero

Block one

Block two

'); + + // Mock view.resolveCFI so a given cfi resolves into the Nth

of the doc at + // `sectionIndex`. The hook anchors the current section's doc, so `anchor(doc)` + // returns a Range selecting the target paragraph's contents. + const stubResolveCFI = ( + view: FoliateView, + mapping: Record, + ) => { + (view.resolveCFI as ReturnType).mockImplementation((cfi: string) => { + const target = mapping[cfi]; + if (!target) return null; + return { + index: target.sectionIndex, + anchor: (doc: Document) => { + const paragraph = doc.querySelectorAll('p')[target.paragraphIndex]; + if (!paragraph) return null; + const range = doc.createRange(); + range.selectNodeContents(paragraph); + return range; + }, + }; + }); + }; + + const dispatchPlaying = async (bookKey: string) => { + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey, state: 'playing' }); + }); + }; + + const dispatchPosition = async (detail: { + bookKey: string; + cfi: string; + kind: 'word' | 'sentence'; + sectionIndex: number; + sequence: number; + }) => { + await act(async () => { + await eventDispatcher.dispatch('tts-position', detail); + }); + }; + + it('follows TTS to the spoken paragraph in the same section', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } }); + const viewRef = { current: view } as React.RefObject; + + render(); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero'); + }); + expect(hookApi?.paragraphState.currentIndex).toBe(0); + + await dispatchPlaying('book-1'); + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 1, + }); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block two'); + }); + + it('ignores tts-position events with a stale (<=) sequence', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + stubResolveCFI(view, { + 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 }, + 'cfi-block-1': { sectionIndex: 0, paragraphIndex: 1 }, + }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + await dispatchPlaying('book-1'); + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 5, + }); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + + // A later-arriving but older-sequence event must be dropped. + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-1', + kind: 'sentence', + sectionIndex: 0, + sequence: 3, + }); + + expect(hookApi?.paragraphState.currentIndex).toBe(2); + + // An equal sequence is also stale. + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-1', + kind: 'sentence', + sectionIndex: 0, + sequence: 5, + }); + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + + it('decouples on manual nav and re-engages on the next playing state', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + stubResolveCFI(view, { + 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 }, + 'cfi-block-0': { sectionIndex: 0, paragraphIndex: 0 }, + }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + await dispatchPlaying('book-1'); + + // Manual nav decouples: paragraph stops following TTS. + await act(async () => { + await hookApi?.goToNextParagraph(); + }); + expect(hookApi?.paragraphState.currentIndex).toBe(1); + + // While decoupled, tts-position is ignored (no focus change). + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 10, + }); + expect(hookApi?.paragraphState.currentIndex).toBe(1); + + // Re-engage via a fresh 'playing' state, then follow again. + await dispatchPlaying('book-1'); + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 11, + }); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + }); + + it('stashes a cross-section tts-position and applies it after the iterator re-inits', async () => { + const sectionZeroDoc = createDoc('

S0 first

S0 second

'); + const sectionOneDoc = createDoc('

S1 first

S1 second

S1 third

'); + const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0); + stubResolveCFI(view, { 'cfi-s1-block-2': { sectionIndex: 1, paragraphIndex: 2 } }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first'); + }); + + // Capture the relocate handler the hook registered with the renderer. + const relocateCall = (renderer.addEventListener as ReturnType).mock.calls.find( + ([eventName]) => eventName === 'relocate', + ); + const handleRelocate = relocateCall?.[1] as () => void; + expect(handleRelocate).toBeTypeOf('function'); + + await dispatchPlaying('book-1'); + + // A tts-position for section 1 while we are on section 0: must NOT map yet. + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-s1-block-2', + kind: 'sentence', + sectionIndex: 1, + sequence: 20, + }); + // Still on section 0, focus unchanged (no cross-section mapping). + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S0 first'); + + // Let the initial mount-focus isFocusingRef window (200ms) expire so the + // relocate below isn't eaten by an unrelated guard. + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // TTS drives the view into section 1; the existing relocate handler re-inits + // the iterator for the new section, after which the stashed CFI applies. + renderer.primaryIndex = 1; + await act(async () => { + handleRelocate(); + await new Promise((r) => setTimeout(r, 250)); + }); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 third'); + }); + + it('does not arm the isFocusingRef guard on a TTS-driven sync focus', async () => { + const sectionZeroDoc = createDoc('

S0 first

S0 second

S0 third

'); + const sectionOneDoc = createDoc('

S1 first

S1 second

'); + const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0); + stubResolveCFI(view, { + 'cfi-s0-block-2': { sectionIndex: 0, paragraphIndex: 2 }, + 'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 }, + }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + const relocateCall = (renderer.addEventListener as ReturnType).mock.calls.find( + ([eventName]) => eventName === 'relocate', + ); + const handleRelocate = relocateCall?.[1] as () => void; + + // Drain the initial mount-focus isFocusingRef window (200ms) so only the + // sync focus under test can possibly arm the guard. + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + await dispatchPlaying('book-1'); + + // A TTS-driven sync focus within the same section must NOT arm the focusing + // guard; otherwise the next relocate (a TTS-driven section change) would be + // swallowed and the iterator would never re-init for the new section. + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-s0-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 30, + }); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(2); + }); + // Let the sync focus fully settle (its scroll runs after a rAF) so that IF + // it (wrongly) armed isFocusingRef, the guard would be set and persist by + // the time the relocate below fires. + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // A cross-section tts-position stashes; the following relocate for section 1 + // must still re-init. If the sync focus above had armed isFocusingRef, this + // relocate would be eaten and the iterator would never re-init -> still + // section 0. + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-s1-block-1', + kind: 'sentence', + sectionIndex: 1, + sequence: 31, + }); + renderer.primaryIndex = 1; + await act(async () => { + handleRelocate(); + await new Promise((r) => setTimeout(r, 250)); + }); + + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('S1 second'); + }); + expect(hookApi?.paragraphState.currentIndex).toBe(1); + }); + + it('does not follow TTS and reports unsupported for a fixed-layout book', async () => { + mockIsFixedLayout = true; + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + expect(hookApi?.ttsSyncStatus).toBe('unsupported'); + + await dispatchPlaying('book-1'); + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 1, + }); + + // Fixed-layout never follows: focus stays on the first paragraph. + expect(hookApi?.paragraphState.currentIndex).toBe(0); + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero'); + expect(hookApi?.ttsSyncStatus).toBe('unsupported'); + }); + + it('derives ttsSyncStatus through the follow lifecycle (reflowable)', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc, createMultiParagraphDoc()], 0); + stubResolveCFI(view, { + 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 }, + 'cfi-s1-block-1': { sectionIndex: 1, paragraphIndex: 1 }, + }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + // Initial: idle (TTS not engaged). + expect(hookApi?.ttsSyncStatus).toBe('idle'); + + // Playing -> following. + await dispatchPlaying('book-1'); + await waitFor(() => { + expect(hookApi?.ttsSyncStatus).toBe('following'); + }); + + // Manual nav -> decoupled (TTS still playing). + await act(async () => { + await hookApi?.goToNextParagraph(); + }); + await waitFor(() => { + expect(hookApi?.ttsSyncStatus).toBe('decoupled'); + }); + + // Re-engage, then a cross-section position (before re-init) -> syncing. + await dispatchPlaying('book-1'); + await waitFor(() => { + expect(hookApi?.ttsSyncStatus).toBe('following'); + }); + await dispatchPosition({ + bookKey: 'book-1', + cfi: 'cfi-s1-block-1', + kind: 'sentence', + sectionIndex: 1, + sequence: 40, + }); + await waitFor(() => { + expect(hookApi?.ttsSyncStatus).toBe('syncing'); + }); + + // Stopped -> idle. + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { + bookKey: 'book-1', + state: 'stopped', + }); + }); + await waitFor(() => { + expect(hookApi?.ttsSyncStatus).toBe('idle'); + }); + }); + + it('toggleTtsAudio starts TTS aligned to the focused paragraph when idle', async () => { + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentRange?.toString()).toContain('Block zero'); + }); + expect(hookApi?.ttsActive).toBe(false); + + dispatchSpy.mockClear(); + act(() => { + hookApi?.toggleTtsAudio(); + }); + + const speakCall = dispatchSpy.mock.calls.find(([name]) => name === 'tts-speak'); + expect(speakCall).toBeDefined(); + const detail = speakCall![1] as { bookKey: string; index?: number; range?: Range }; + expect(detail.bookKey).toBe('book-1'); + // Start-aligned to the focused paragraph: section index + live range. + expect(detail.index).toBe(0); + expect(detail.range?.toString()).toContain('Block zero'); + }); + + it('toggleTtsAudio stops TTS when a session is active', async () => { + const dispatchSpy = vi.spyOn(eventDispatcher, 'dispatch'); + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + // A playing session makes the toggle a stop. + await dispatchPlaying('book-1'); + await waitFor(() => { + expect(hookApi?.ttsActive).toBe(true); + }); + + dispatchSpy.mockClear(); + act(() => { + hookApi?.toggleTtsAudio(); + }); + + expect(dispatchSpy).toHaveBeenCalledWith('tts-stop', { bookKey: 'book-1' }); + expect(dispatchSpy).not.toHaveBeenCalledWith('tts-speak', expect.anything()); + }); + + it('keeps ttsActive and reports paused on a TTS pause; clears on stop', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + + await dispatchPlaying('book-1'); + await waitFor(() => { + expect(hookApi?.ttsActive).toBe(true); + expect(hookApi?.ttsSyncStatus).toBe('following'); + }); + + // Pause keeps the session active and persists the indicator as 'paused'. + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'paused' }); + }); + await waitFor(() => { + expect(hookApi?.ttsActive).toBe(true); + expect(hookApi?.ttsSyncStatus).toBe('paused'); + }); + + // A full stop clears the session and returns to idle. + await act(async () => { + await eventDispatcher.dispatch('tts-playback-state', { bookKey: 'book-1', state: 'stopped' }); + }); + await waitFor(() => { + expect(hookApi?.ttsActive).toBe(false); + expect(hookApi?.ttsSyncStatus).toBe('idle'); + }); + }); + + it('ignores tts events for a different bookKey and keeps status unchanged', async () => { + const doc = createMultiParagraphDoc(); + const { view } = createMockView([doc], 0); + stubResolveCFI(view, { 'cfi-block-2': { sectionIndex: 0, paragraphIndex: 2 } }); + const viewRef = { current: view } as React.RefObject; + + render(); + await waitFor(() => { + expect(hookApi?.paragraphState.currentIndex).toBe(0); + }); + expect(hookApi?.ttsSyncStatus).toBe('idle'); + + // Playback + position for a DIFFERENT book: must be ignored. + await dispatchPlaying('other-book'); + await dispatchPosition({ + bookKey: 'other-book', + cfi: 'cfi-block-2', + kind: 'sentence', + sectionIndex: 0, + sequence: 1, + }); + + expect(hookApi?.paragraphState.currentIndex).toBe(0); + expect(hookApi?.ttsSyncStatus).toBe('idle'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts index 68e8d136..d3d299e3 100644 --- a/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/rsvp-controller.test.ts @@ -531,4 +531,401 @@ describe('RSVPController', () => { expect(controller.currentState.currentIndex).toBe(0); }); }); + + describe('syncToCfi / setExternallyDriven (slice 3a, #3235)', () => { + // These tests need REAL DOM ranges (not the stubbed createRange from + // makeDoc) so compareBoundaryPoints works for containment + binary search. + // Build a real jsdom document and a view whose resolveCFI maps a CFI to a + // collapsed range at an arbitrary character offset in the section text. + const TEXT = 'one two three four five six'; + // word offsets in TEXT: + // one [0,3) two [4,7) three [8,13) four [14,18) + // five [19,23) six [24,27) + + function makeSyncFixture() { + const doc = document.implementation.createHTMLDocument('sync'); + doc.body.innerHTML = `

${TEXT}

`; + const textNode = doc.body.querySelector('p')!.firstChild as Text; + + // resolveCFI returns an anchor function producing a collapsed range at the + // offset encoded in the test CFI: epubcfi(/6/2!/4/2/1:) + const resolveCFI = vi.fn().mockImplementation((cfi: string) => { + const m = cfi.match(/!\/4\/2\/1:(\d+)\)/); + if (!m) return null; + const offset = parseInt(m[1]!, 10); + return { + index: 0, + anchor: (d: Document) => { + const r = d.createRange(); + r.setStart(textNode, offset); + r.setEnd(textNode, offset); + return r; + }, + }; + }); + + const getCFI = vi.fn().mockReturnValue('epubcfi(/6/2!/4/2/1:0)'); + const view = { + renderer: { + primaryIndex: 0, + getContents: vi.fn().mockReturnValue([{ doc, index: 0 }]), + }, + book: { toc: [] }, + language: { isCJK: false }, + tts: null, + getCFI, + resolveCFI, + } as unknown as FoliateView; + + const controller = new RSVPController(view, 'sync-book-abc123'); + controller.start(); + return { controller, view, getCFI, resolveCFI }; + } + + // CFI for docIndex 0 is spine step /6/2 (index = (2-2)/2 = 0). + const cfiAt = (offset: number) => `epubcfi(/6/2!/4/2/1:${offset})`; + + test('containment: a CFI starting mid-token resolves to the CONTAINING word, not the next', () => { + const { controller } = makeSyncFixture(); + // offset 9 lands inside "three" [8,13) — at the 'h'. + const ok = controller.syncToCfi(cfiAt(9)); + expect(ok).toBe(true); + // Must be "three" (index 2), NOT the next word "four". + expect(controller.currentState.currentIndex).toBe(2); + expect(controller.currentState.words[2]!.text).toBe('three'); + }); + + test('monotonic forward: a sequence of forward CFIs maps to increasing indices', () => { + const { controller } = makeSyncFixture(); + // Start of each word. + expect(controller.syncToCfi(cfiAt(0))).toBe(true); // one + expect(controller.currentState.currentIndex).toBe(0); + expect(controller.syncToCfi(cfiAt(4))).toBe(true); // two + expect(controller.currentState.currentIndex).toBe(1); + expect(controller.syncToCfi(cfiAt(8))).toBe(true); // three + expect(controller.currentState.currentIndex).toBe(2); + expect(controller.syncToCfi(cfiAt(19))).toBe(true); // five + expect(controller.currentState.currentIndex).toBe(4); + expect(controller.syncToCfi(cfiAt(24))).toBe(true); // six + expect(controller.currentState.currentIndex).toBe(5); + }); + + test('backward seek: a CFI before the cursor lands on the correct earlier word', () => { + const { controller } = makeSyncFixture(); + // Advance the cursor forward first. + controller.syncToCfi(cfiAt(24)); // six -> cursor at 5 + expect(controller.currentState.currentIndex).toBe(5); + // Now seek backward (binary-search path); offset 5 is inside "two" [4,7). + const ok = controller.syncToCfi(cfiAt(5)); + expect(ok).toBe(true); + expect(controller.currentState.currentIndex).toBe(1); + expect(controller.currentState.words[1]!.text).toBe('two'); + }); + + test('gap fallback: a CFI in whitespace maps to the nearest following word', () => { + const { controller } = makeSyncFixture(); + // offset 3 is the space between "one" [0,3) and "two" [4,7). + const ok = controller.syncToCfi(cfiAt(3)); + expect(ok).toBe(true); + // No word contains offset 3 -> nearest following word is "two" (index 1). + expect(controller.currentState.currentIndex).toBe(1); + }); + + test('no-match: an unresolvable CFI returns false and leaves currentIndex unchanged (NOT 0)', () => { + const { controller } = makeSyncFixture(); + // Move off index 0 first so we can prove it does not clamp back to 0. + controller.syncToCfi(cfiAt(8)); // three -> index 2 + expect(controller.currentState.currentIndex).toBe(2); + + // A CFI that resolveCFI cannot resolve (no matching anchor pattern). + const ok = controller.syncToCfi('epubcfi(/6/2!/9/9/9:bogus)'); + expect(ok).toBe(false); + expect(controller.currentState.currentIndex).toBe(2); + }); + + test('no-match: an out-of-section CFI returns false and leaves currentIndex unchanged', () => { + const { controller } = makeSyncFixture(); + controller.syncToCfi(cfiAt(8)); // index 2 + // Spine step /6/8 => index 3, a different section than docIndex 0. + const ok = controller.syncToCfi('epubcfi(/6/8!/4/2/1:0)'); + expect(ok).toBe(false); + expect(controller.currentState.currentIndex).toBe(2); + }); + + test('perf guard: view.getCFI is NOT called during a syncToCfi fast-path call', () => { + const { controller, getCFI } = makeSyncFixture(); + getCFI.mockClear(); + const ok = controller.syncToCfi(cfiAt(9)); + expect(ok).toBe(true); + expect(getCFI).not.toHaveBeenCalled(); + }); + + test('setExternallyDriven(true) suspends auto-advance; (false) restores it', () => { + const { controller } = makeSyncFixture(); + // Suspend BEFORE the start countdown elapses so no word timer ever arms. + controller.setExternallyDriven(true); + // Even after plenty of time, the auto-advance timer must not fire. + vi.advanceTimersByTime(20000); + expect(controller.currentState.currentIndex).toBe(0); + + // Restoring should let auto-advance resume. + controller.setExternallyDriven(false); + vi.advanceTimersByTime(20000); + expect(controller.currentState.currentIndex).toBeGreaterThan(0); + }); + + test('syncToCfi displays the word without arming auto-advance (no scheduled next word)', () => { + const { controller } = makeSyncFixture(); + controller.setExternallyDriven(true); + + controller.syncToCfi(cfiAt(8)); // three -> index 2 + expect(controller.currentState.currentIndex).toBe(2); + // No timer was armed by syncToCfi: advancing time must not move the index. + vi.advanceTimersByTime(20000); + expect(controller.currentState.currentIndex).toBe(2); + }); + }); + + describe('estimator: driveEstimatedFromCfi (slice 5, #3235)', () => { + // Non-Edge TTS only emits sentence-level marks. The estimator jumps RSVP to + // the sentence's first word (syncToCfi) then SELF-PACES forward through the + // following words on a timer at an estimated rate, capped so it can't run + // away past the (unknown) sentence end. A new sentence drive re-syncs (snap). + // + // Build a long single-text-node section so there are plenty of words to + // advance through and so the cap is reachable. Word i starts at offset i*5 + // ("wNNN " is padded to a fixed width so offsets are predictable). + // Enough words that the cap (60) is reachable from both anchors used below + // (10 and 20) without clamping to the last word: 20 + 60 = 80 < 100. + const WORD_COUNT = 100; + const WORD_WIDTH = 4; // "w000".."w099" -> 4 chars each, space-separated -> stride 5 + + function makeEstimatorFixture() { + const text = Array.from( + { length: WORD_COUNT }, + (_, i) => `w${String(i).padStart(3, '0')}`, + ).join(' '); + const doc = document.implementation.createHTMLDocument('estimator'); + doc.body.innerHTML = `

${text}

`; + const textNode = doc.body.querySelector('p')!.firstChild as Text; + + const resolveCFI = vi.fn().mockImplementation((cfi: string) => { + const m = cfi.match(/!\/4\/2\/1:(\d+)\)/); + if (!m) return null; + const offset = parseInt(m[1]!, 10); + return { + index: 0, + anchor: (d: Document) => { + const r = d.createRange(); + r.setStart(textNode, offset); + r.setEnd(textNode, offset); + return r; + }, + }; + }); + + const view = { + renderer: { + primaryIndex: 0, + getContents: vi.fn().mockReturnValue([{ doc, index: 0 }]), + }, + book: { toc: [] }, + language: { isCJK: false }, + tts: null, + getCFI: vi.fn().mockReturnValue('epubcfi(/6/2!/4/2/1:0)'), + resolveCFI, + } as unknown as FoliateView; + + const controller = new RSVPController(view, 'estimator-book-abc123'); + controller.start(); + return { controller, view }; + } + + // Offset for the start of word index `i` (stride 5: WORD_WIDTH + 1 space). + const offsetOf = (i: number) => i * (WORD_WIDTH + 1); + const cfiAt = (offset: number) => `epubcfi(/6/2!/4/2/1:${offset})`; + const cfiOfWord = (i: number) => cfiAt(offsetOf(i)); + + test('jumps to the sentence first word, then self-advances at the estimated rate', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + + // ttsRate 1.0 -> 190 wpm -> ~316ms per word. + controller.driveEstimatedFromCfi(cfiOfWord(10), 190); + expect(controller.currentState.currentIndex).toBe(10); + + const perWordMs = 60000 / 190; + vi.advanceTimersByTime(perWordMs + 5); + expect(controller.currentState.currentIndex).toBe(11); + vi.advanceTimersByTime(perWordMs + 5); + expect(controller.currentState.currentIndex).toBe(12); + }); + + test('estimatedWpmFromRate clamps extreme tts rates to FLOOR/CEIL', () => { + // 190 * rate, clamped to [60, 600]. + expect(RSVPController.estimatedWpmFromRate(1)).toBe(190); + expect(RSVPController.estimatedWpmFromRate(0.1)).toBe(60); // 19 -> floor 60 + expect(RSVPController.estimatedWpmFromRate(10)).toBe(600); // 1900 -> ceil 600 + expect(RSVPController.estimatedWpmFromRate(2)).toBe(380); + }); + + test('drive seeds the rate via estimatedWpmFromRate so an extreme rate is clamped', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + + // Very slow rate -> clamp to FLOOR (60 wpm -> 1000ms/word). At 190's + // rate (316ms) it would have advanced; at 60 it must not yet. + const wpm = RSVPController.estimatedWpmFromRate(0.1); + controller.driveEstimatedFromCfi(cfiOfWord(5), wpm); + expect(controller.currentState.currentIndex).toBe(5); + + vi.advanceTimersByTime(60000 / 190 + 50); // enough for 190, not for 60 + expect(controller.currentState.currentIndex).toBe(5); + + vi.advanceTimersByTime(60000 / 60); // now enough for 60 wpm + expect(controller.currentState.currentIndex).toBe(6); + }); + + test('HOLDS at MAX_WORDS_AHEAD past the sentence first word (does not run away)', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + + const startIndex = 2; + controller.driveEstimatedFromCfi(cfiOfWord(startIndex), 600); // fast + // Run far longer than needed to advance past the cap. + vi.advanceTimersByTime(60_000); + + const cap = RSVPController.ESTIMATED_MAX_WORDS_AHEAD; + // Held at startIndex + cap, never beyond. + expect(controller.currentState.currentIndex).toBe(startIndex + cap); + + // Still held after even more time (timer stopped, not racing). + vi.advanceTimersByTime(60_000); + expect(controller.currentState.currentIndex).toBe(startIndex + cap); + }); + + test('a new sentence drive resets (snaps) to the new sentence first word', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + + controller.driveEstimatedFromCfi(cfiOfWord(10), 190); + const perWordMs = 60000 / 190; + vi.advanceTimersByTime(perWordMs * 3 + 15); // drift forward a few words + expect(controller.currentState.currentIndex).toBeGreaterThan(10); + + // Next sentence mark snaps to its first word, regardless of drift. + controller.driveEstimatedFromCfi(cfiOfWord(20), 190); + expect(controller.currentState.currentIndex).toBe(20); + + // And the cap is measured from the NEW anchor (20), not the old one. + vi.advanceTimersByTime(60_000); + expect(controller.currentState.currentIndex).toBe( + 20 + RSVPController.ESTIMATED_MAX_WORDS_AHEAD, + ); + }); + + test('estimator pacing does not co-run with normal auto-advance', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + controller.driveEstimatedFromCfi(cfiOfWord(0), 190); + + // Only the estimator timer should be advancing. Over one estimator tick + // exactly one word advances (not two from a co-running WPM timer). + const perWordMs = 60000 / 190; + vi.advanceTimersByTime(perWordMs + 5); + expect(controller.currentState.currentIndex).toBe(1); + }); + + test('stopping external drive cancels estimator pacing', () => { + const { controller } = makeEstimatorFixture(); + controller.setExternallyDriven(true); + controller.driveEstimatedFromCfi(cfiOfWord(10), 190); + + // Disengaging the external driver must clear the estimator timer so it + // cannot keep advancing on its own. + controller.stopEstimator(); + const before = controller.currentState.currentIndex; + vi.advanceTimersByTime(60_000); + expect(controller.currentState.currentIndex).toBe(before); + }); + }); + + describe('manual-nav decouple signal (slice 5, #3235)', () => { + // The TTS-sync wiring listens for 'rsvp-manual-nav' to drop following so a + // user jump isn't immediately overwritten by the next TTS position. + const navMethods: Array<[string, (c: RSVPController) => void]> = [ + ['skipForward', (c) => c.skipForward()], + ['skipBackward', (c) => c.skipBackward()], + ['nextWord', (c) => c.nextWord()], + ['prevWord', (c) => c.prevWord()], + ['seekToPosition', (c) => c.seekToPosition(50)], + ['seekToIndex', (c) => c.seekToIndex(1)], + ]; + + test.each(navMethods)('%s emits rsvp-manual-nav', (_name, run) => { + const view = createMockView(0, [makeDoc('one two three four')]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.start(); + + const onNav = vi.fn(); + controller.addEventListener('rsvp-manual-nav', onNav); + run(controller); + expect(onNav).toHaveBeenCalledTimes(1); + }); + + test('syncToCfi does NOT emit rsvp-manual-nav', () => { + const view = createMockView(0, [makeDoc('one two three')]); + const controller = new RSVPController(view, 'test-book-abc123'); + controller.start(); + + const onNav = vi.fn(); + controller.addEventListener('rsvp-manual-nav', onNav); + // resolveCFI on the stubbed view returns a real Range but syncToCfi will + // no-op on the stubbed createRange; either way it must not fire manual-nav. + controller.syncToCfi('epubcfi(/6/2!/4/2/1:0)'); + expect(onNav).not.toHaveBeenCalled(); + }); + }); + + // #3235 regression: the CFI anchor is a Range created in the book iframe's + // realm, so `anchor instanceof Range` (top realm) is always false. Before the + // fix resolveCfiToRange fell through to `null`, so syncToCfi never advanced the + // word (RSVP stayed frozen while TTS played). Confirmed live via CDP. + describe('resolveCfiToRange cross-realm anchor (#3235)', () => { + test('resolves an iframe-realm Range that is NOT instanceof the top Range', () => { + const doc = makeDoc('hello world'); + // Range-like anchor with Range methods but not an instance of this realm's + // Range constructor — exactly what view.resolveCFI(...).anchor(doc) returns + // from inside the book iframe. + const crossRealmRange = { + startContainer: doc.body, + startOffset: 0, + endContainer: doc.body, + endOffset: 0, + cloneRange() { + return this; + }, + toString() { + return 'hello'; + }, + }; + expect(crossRealmRange instanceof Range).toBe(false); + + const view = createMockView(0, [doc]); + (view.resolveCFI as ReturnType).mockReturnValue({ + index: 0, + anchor: () => crossRealmRange, + }); + const controller = new RSVPController(view, 'cross-realm-book'); + + const resolved = ( + controller as unknown as { + resolveCfiToRange: (cfi: string, spineIndex: number) => Range | null; + } + ).resolveCfiToRange('epubcfi(/6/2!/4/2/1:0)', 0); + + // Before the fix this was null (instanceof Range failed cross-realm). + expect(resolved).toBe(crossRealmRange); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx index 8dcf1a39..3cb99f85 100644 --- a/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx +++ b/apps/readest-app/src/__tests__/services/tts-auto-advance.browser.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { DocumentLoader } from '@/libs/document'; import type { BookDoc, TOCItem } from '@/libs/document'; @@ -15,6 +15,7 @@ import { useReaderStore } from '@/store/readerStore'; import { useSettingsStore } from '@/store/settingsStore'; import { eventDispatcher } from '@/utils/event'; import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; +import { useParagraphMode } from '@/app/reader/hooks/useParagraphMode'; // --------------------------------------------------------------------------- // Mock ONLY the speech client. Everything else is real: the foliate @@ -31,7 +32,12 @@ import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; // all come from the real document. // --------------------------------------------------------------------------- -const SPEAK_DELAY_MS = 25; +// Mutable so a single test can slow the synthetic walk down. The default 25ms +// keeps the existing tests fast; the paragraph-mode boundary test raises it so +// the per-section dwell exceeds the paragraph hook's relocate debounce + rAFs +// (the cross-section re-init needs the view to sit on the new section briefly). +// Always restored in that test's finally so other tests keep the fast default. +let speakDelayMs = 25; function makeMockTTSClient(name: string): TTSClient { return { @@ -46,7 +52,7 @@ function makeMockTTSClient(name: string): TTSClient { ): AsyncGenerator { // The preload path only warms a cache in real clients — emit nothing. if (preload) return; - await new Promise((r) => setTimeout(r, SPEAK_DELAY_MS)); + await new Promise((r) => setTimeout(r, speakDelayMs)); if (signal.aborted) return; yield { code: 'end' }; }, @@ -321,4 +327,337 @@ describe('TTS auto-advance across a chapter boundary (browser e2e)', () => { unmount(); }, 60000); + + // Slice 2: the hook republishes the controller's canonical 'tts-position' + // CustomEvent onto the app-wide eventDispatcher (tagged with bookKey) so + // paragraph mode + RSVP can follow TTS without touching the controller. It + // also emits 'tts-playback-state' transitions for consumers (like RSVP) that + // can't read the hook-local isPlaying flag. + it('republishes controller tts-position + tts-playback-state onto the global eventDispatcher', async () => { + const viewSettings: ViewSettings = { + ...getDefaultViewSettings({ + fs: {} as FileSystem, + isMobile: false, + isEink: false, + isAppDataSandbox: false, + }), + maxColumnCount: 1, + scrolled: false, + }; + + const { view } = createView(viewSettings); + await view.open(bookDoc); + view.renderer.setAttribute('max-column-count', '1'); + view.renderer.setAttribute('max-inline-size', '800px'); + view.renderer.setAttribute('max-block-size', '1000px'); + view.renderer.setAttribute('margin-top', '0px'); + view.renderer.setAttribute('margin-bottom', '0px'); + view.renderer.setAttribute('margin-left', '0px'); + view.renderer.setAttribute('margin-right', '0px'); + view.renderer.setAttribute('gap', '0%'); + await view.goToFraction(0); + + seedStores(view, viewSettings); + wireRelocate(view); + await view.renderer.goTo({ index: CH4_SECTION_INDEX }); + await sleep(50); + + interface BusPosition { + bookKey: string; + cfi: string; + kind: 'word' | 'sentence'; + sectionIndex: number; + sequence: number; + } + const positions: BusPosition[] = []; + const playbackStates: string[] = []; + const onPosition = (e: CustomEvent) => { + positions.push(e.detail as BusPosition); + }; + const onPlaybackState = (e: CustomEvent) => { + const detail = e.detail as { bookKey: string; state: string }; + if (detail.bookKey === BOOK_KEY) playbackStates.push(detail.state); + }; + eventDispatcher.on('tts-position', onPosition); + eventDispatcher.on('tts-playback-state', onPlaybackState); + + const { unmount } = renderHook(() => useTTSControl({ bookKey: BOOK_KEY })); + + try { + await act(async () => { + await eventDispatcher.dispatch('tts-speak', { + bookKey: BOOK_KEY, + index: CH4_SECTION_INDEX, + }); + }); + + // Let the controller walk a few sentences so several 'tts-position' + // events fire on the bus. + await act(async () => { + const deadline = Date.now() + 5000; + while (Date.now() < deadline && positions.length < 3) { + await sleep(50); + } + }); + + // 1. Playback started -> the bus saw a 'playing' state. + expect(playbackStates).toContain('playing'); + + // 2. The global bus received the controller's canonical positions, each + // tagged with the bookKey and carrying the controller detail. + expect(positions.length).toBeGreaterThan(0); + for (const pos of positions) { + expect(pos.bookKey).toBe(BOOK_KEY); + expect(pos.kind).toBe('sentence'); // mock client reports no word boundaries + expect(typeof pos.cfi).toBe('string'); + expect(pos.cfi.length).toBeGreaterThan(0); + expect(pos.sectionIndex).toBe(CH4_SECTION_INDEX); + expect(typeof pos.sequence).toBe('number'); + } + // Sequence is monotonic from the controller. + const sequences = positions.map((p) => p.sequence); + expect([...sequences].sort((a, b) => a - b)).toEqual(sequences); + + // 3. Stopping TTS emits a 'stopped' state on the bus. + await act(async () => { + await eventDispatcher.dispatch('tts-stop', { bookKey: BOOK_KEY }); + await sleep(50); + }); + expect(playbackStates[playbackStates.length - 1]).toBe('stopped'); + } finally { + eventDispatcher.off('tts-position', onPosition); + eventDispatcher.off('tts-playback-state', onPlaybackState); + unmount(); + } + }, 60000); + + // Slice 9 (the primary high-risk case): with PARAGRAPH MODE active, the + // focused paragraph must follow the spoken position AND correctly re-target + // after the section boundary. The trap this guards against: TTS sync arms + // isFocusingRef on a same-section focus, the next (section-change) relocate is + // eaten, the iterator never re-inits for Ch5, and focus stays stuck on + // paragraph 0 of the wrong (Ch4) document. Here EVERYTHING is real — the + // , the real useTTSControl walk, AND the real useParagraphMode + // iterator built on the live section docs. Only the speech client is mocked. + it('paragraph mode follows TTS across the Ch4→Ch5 boundary and re-targets the new section', async () => { + const viewSettings: ViewSettings = { + ...getDefaultViewSettings({ + fs: {} as FileSystem, + isMobile: false, + isEink: false, + isAppDataSandbox: false, + }), + maxColumnCount: 1, + scrolled: false, + // Paragraph mode enabled at mount: the hook auto-builds the iterator on + // the live section doc (first-mount effect) and registers its TTS-sync + // listeners. We avoid toggleParagraphMode() so the fire-and-forget + // saveViewSettings → saveConfig (appService is null in this harness) is + // never exercised — enabling via the seeded config is enough. + paragraphMode: { enabled: true }, + }; + + const { view } = createView(viewSettings); + await view.open(bookDoc); + view.renderer.setAttribute('max-column-count', '1'); + view.renderer.setAttribute('max-inline-size', '800px'); + view.renderer.setAttribute('max-block-size', '1000px'); + view.renderer.setAttribute('margin-top', '0px'); + view.renderer.setAttribute('margin-bottom', '0px'); + view.renderer.setAttribute('margin-left', '0px'); + view.renderer.setAttribute('margin-right', '0px'); + view.renderer.setAttribute('gap', '0%'); + await view.goToFraction(0); + + seedStores(view, viewSettings); + wireRelocate(view); + await goToLastPageOfCh4(view); + await sleep(50); // let the relocate → setProgress settle + + expect(view.renderer.primaryIndex).toBe(CH4_SECTION_INDEX); + + // Attribute a Range to its owning section by matching its document against + // the renderer's live section contents. This is how we tell whether a + // focused paragraph belongs to Ch4 (start side) or Ch5 (post-boundary). + const ownerSectionIndex = (range: Range): number | undefined => { + const owner = range?.startContainer?.ownerDocument; + return view.renderer.getContents().find((c) => c.doc === owner)?.index; + }; + + // Record every paragraph-focus the hook dispatches (by owning section) for + // the failure-diagnostic string; the load-bearing assertions read the + // iterator's current range directly (see currentSection below). + const focuses: (number | undefined)[] = []; + const onParagraphFocus = (e: CustomEvent) => { + const detail = e.detail as { bookKey: string; range: Range }; + if (detail.bookKey !== BOOK_KEY) return; + focuses.push(ownerSectionIndex(detail.range)); + }; + eventDispatcher.on('paragraph-focus', onParagraphFocus); + + // Record the section of every spoken position on the bus — proves TTS read + // out of Ch4 and into Ch5 (the audio actually crossed the boundary). + const posSections: number[] = []; + const onPos = (e: CustomEvent) => { + const d = e.detail as { bookKey: string; sectionIndex: number }; + if (d.bookKey === BOOK_KEY) posSections.push(d.sectionIndex); + }; + eventDispatcher.on('tts-position', onPos); + + // Mount BOTH hooks for the same book: the real TTS controller hook (drives + // the walk + publishes tts-position/tts-playback-state on the bus) and the + // real paragraph-mode hook (follows that bus). The viewRef points at the + // same live the store holds. + const viewRef = { current: view } as RefObject; + // Mount the two hooks in SEPARATE React roots (separate renderHook calls) + // so a store write from one doesn't synchronously re-enter the other's + // render commit — mirrors how the app mounts them in distinct components. + const tts = renderHook(() => useTTSControl({ bookKey: BOOK_KEY })); + const para = renderHook(() => useParagraphMode({ bookKey: BOOK_KEY, viewRef })); + const ttsApi = () => tts.result.current; + const paragraphApi = () => para.result.current; + const unmount = () => { + para.unmount(); + tts.unmount(); + }; + + let syncStatusEverFollowing = false; + + // Slow the synthetic walk so each section is spoken long enough for the + // paragraph hook's cross-section re-init (relocate debounce + rAFs) to land + // before TTS sprints onward. Restored in finally. + const prevSpeakDelay = speakDelayMs; + speakDelayMs = 800; + + // The robust, event-agnostic observable: the section the iterator's CURRENT + // range belongs to. A paragraph-focus CustomEvent only fires when focus + // actually MOVES, so the first paragraph of the new section (which TTS reads + // first) need not emit one — but the iterator's current range still belongs + // to that section. So we track currentRange's owning section directly. + const currentSection = (): number | undefined => { + const cr = paragraphApi().paragraphState.currentRange; + return cr ? ownerSectionIndex(cr) : undefined; + }; + // Distinct (section,index) pairs of the iterator's current paragraph over + // the whole walk — proves it tracked TTS rather than sitting still. + const currentTrail: string[] = []; + const recordCurrent = () => { + const sec = currentSection(); + const idx = paragraphApi().paragraphState.currentIndex; + const key = `${sec}:${idx}`; + if (currentTrail[currentTrail.length - 1] !== key) currentTrail.push(key); + }; + + try { + // Wait for paragraph mode to build its iterator on the live Ch4 doc (the + // first-mount effect runs after ~100ms, then focuses the first paragraph). + await act(async () => { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (paragraphApi().paragraphState.currentIndex >= 0 && currentSection() !== undefined) { + break; + } + await sleep(50); + } + }); + // Precondition: the iterator built on the live Ch4 doc and its current + // paragraph belongs to Ch4 (section 6) — the start side of the boundary. + expect(paragraphApi().paragraphState.totalParagraphs).toBeGreaterThan(0); + expect(currentSection()).toBe(CH4_SECTION_INDEX); + const ch4ParagraphTotal = paragraphApi().paragraphState.totalParagraphs; + recordCurrent(); + + // Drain the mount-focus isFocusingRef window (200ms) so the iterator's own + // initial focus can't swallow the upcoming TTS section-change relocate. + await act(async () => { + await sleep(300); + }); + + // Start TTS at the last paragraph of Ch4 (index pins the start section). + await act(async () => { + await eventDispatcher.dispatch('tts-speak', { + bookKey: BOOK_KEY, + index: CH4_SECTION_INDEX, + }); + }); + + // Drive the mock 'end' events; the controller walks to the end of Ch4 and + // auto-advances into Ch5, publishing tts-position the whole way. Paragraph + // mode follows. The mock client never stops yielding 'end', so the walk + // would run on forever — we STOP TTS the moment the iterator's current + // paragraph belongs to Ch5's document, then assert on captured evidence. + let ch4CurrentSeenDuringWalk = false; + await act(async () => { + const deadline = Date.now() + 40000; + while (Date.now() < deadline) { + if (paragraphApi().ttsSyncStatus === 'following') syncStatusEverFollowing = true; + recordCurrent(); + if (currentSection() === CH4_SECTION_INDEX) ch4CurrentSeenDuringWalk = true; + // Stop as soon as the iterator's current paragraph belongs to Ch5's + // document: this is the exact moment the re-target across the boundary + // succeeded. + if (currentSection() === CH5_SECTION_INDEX) break; + await sleep(50); + } + // Halt the (otherwise endless) walk so the view doesn't run past Ch5. + await eventDispatcher.dispatch('tts-stop', { bookKey: BOOK_KEY }); + }); + + // Let the final cross-section sync settle (re-init + applySyncCfi run + // across a couple of rAFs / event turns). + await act(async () => { + await sleep(300); + recordCurrent(); + }); + + const diag = + `trail=${currentTrail.join(' ')} | ` + + `posSecs(first/last)=${posSections[0]}/${posSections[posSections.length - 1]} | ` + + `nPos=${posSections.length} | focuses=${focuses.join(',')}`; + + // 1. TTS published spoken positions in BOTH sections — it genuinely read + // out of Ch4 and into Ch5 (the boundary was crossed by the audio). + expect(posSections, diag).toContain(CH4_SECTION_INDEX); + expect(posSections, diag).toContain(CH5_SECTION_INDEX); + + // 2. While in the first section, the iterator's current paragraph stayed in + // Ch4's document (it never wrongly jumped to a foreign section early). + expect(ch4CurrentSeenDuringWalk, diag).toBe(true); + + // 3. AFTER the boundary, the iterator re-inited for the NEW section: its + // current range belongs to Ch5's document, NOT the stale Ch4 one. This + // is the wrong-section-paragraph-0 / isFocusingRef trap. + const finalCurrentRange = paragraphApi().paragraphState.currentRange; + expect(finalCurrentRange, diag).toBeTruthy(); + expect(ownerSectionIndex(finalCurrentRange!), diag).toBe(CH5_SECTION_INDEX); + + // 4. The re-target is a genuine new-section iterator (Ch5's paragraph count + // differs from Ch4's), not the old Ch4 iterator mislabeled. + expect(paragraphApi().paragraphState.totalParagraphs, diag).not.toBe(ch4ParagraphTotal); + + // 5. The current-paragraph trail moved from a Ch4 entry to a Ch5 entry and, + // once in Ch5, never reverted to Ch4 (no bounce-back to the old doc). + const firstCh5TrailAt = currentTrail.findIndex((k) => k.startsWith(`${CH5_SECTION_INDEX}:`)); + expect(firstCh5TrailAt, diag).toBeGreaterThanOrEqual(0); + expect( + currentTrail.slice(0, firstCh5TrailAt).some((k) => k.startsWith(`${CH4_SECTION_INDEX}:`)), + diag, + ).toBe(true); + expect( + currentTrail.slice(firstCh5TrailAt).some((k) => k.startsWith(`${CH4_SECTION_INDEX}:`)), + diag, + ).toBe(false); + + // 6. ttsSyncStatus reported 'following' during the walk. + expect(syncStatusEverFollowing, diag).toBe(true); + + // Keep ttsApi referenced (it owns the live walk under test). + expect(ttsApi()).toBeTruthy(); + } finally { + speakDelayMs = prevSpeakDelay; + eventDispatcher.off('paragraph-focus', onParagraphFocus); + eventDispatcher.off('tts-position', onPos); + unmount(); + } + }, 60000); }); diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 54d896b8..e957e0ca 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -739,6 +739,94 @@ describe('TTSController', () => { }); }); + describe('tts-position event', () => { + const makeSentenceRange = () => { + document.body.innerHTML = '

Hello brave world

'; + const textNode = document.body.firstElementChild!.firstChild as Text; + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, textNode.length); + return range; + }; + + const armWithSentence = async (range: Range, markName = '0') => { + await controller.initViewTTS(0); + mockView.tts = { + setMark: vi.fn().mockReturnValue(range), + getLastRange: vi.fn().mockImplementation(() => range.cloneRange()), + } as unknown as FoliateView['tts']; + controller.dispatchSpeakMark({ + offset: 0, + name: markName, + text: 'Hello brave world', + language: 'en', + }); + }; + + test('dispatchSpeakMark emits tts-position with kind sentence, cfi, sectionIndex and sequence', async () => { + await controller.initViewTTS(0); + mockView.tts = { + setMark: vi.fn().mockReturnValue(new Range()), + getLastRange: vi.fn(), + } as unknown as FoliateView['tts']; + vi.mocked(mockView.getCFI).mockReturnValue('cfi-sentence'); + + const listener = vi.fn(); + controller.addEventListener('tts-position', listener); + + controller.dispatchSpeakMark({ offset: 0, name: '0', text: 'hello', language: 'en' }); + + expect(listener).toHaveBeenCalledTimes(1); + const ev = listener.mock.calls[0]![0] as CustomEvent; + expect(ev.detail.kind).toBe('sentence'); + expect(ev.detail.cfi).toBe('cfi-sentence'); + // initViewTTS(0) set the TTS section index to 0. + expect(ev.detail.sectionIndex).toBe(0); + expect(typeof ev.detail.sequence).toBe('number'); + }); + + test('dispatchSpeakWord emits tts-position with kind word', async () => { + await armWithSentence(makeSentenceRange()); + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + + const listener = vi.fn(); + controller.addEventListener('tts-position', listener); + vi.mocked(mockView.getCFI).mockReturnValue('cfi-word'); + controller.dispatchSpeakWord(1); + + expect(listener).toHaveBeenCalledTimes(1); + const ev = listener.mock.calls[0]![0] as CustomEvent; + expect(ev.detail.kind).toBe('word'); + expect(ev.detail.cfi).toBe('cfi-word'); + expect(ev.detail.sectionIndex).toBe(0); + expect(typeof ev.detail.sequence).toBe('number'); + }); + + test('sequence strictly increases across successive emits', async () => { + await armWithSentence(makeSentenceRange()); + vi.mocked(mockView.getCFI).mockReturnValue('cfi-x'); + + const sequences: number[] = []; + controller.addEventListener('tts-position', (e) => { + sequences.push((e as CustomEvent).detail.sequence); + }); + + // word emit + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + controller.dispatchSpeakWord(1); + // sentence emit + controller.dispatchSpeakMark({ offset: 0, name: '1', text: 'hello', language: 'en' }); + // another word emit + controller.prepareSpeakWords(['Hello', 'brave', 'world']); + controller.dispatchSpeakWord(2); + + expect(sequences.length).toBeGreaterThanOrEqual(3); + for (let i = 1; i < sequences.length; i++) { + expect(sequences[i]!).toBeGreaterThan(sequences[i - 1]!); + } + }); + }); + describe('getSpokenSentence', () => { test('returns the trimmed text and cfi of the current sentence', async () => { await controller.initViewTTS(0); diff --git a/apps/readest-app/src/__tests__/utils/paragraph.test.ts b/apps/readest-app/src/__tests__/utils/paragraph.test.ts index 55d74ded..8d86e929 100644 --- a/apps/readest-app/src/__tests__/utils/paragraph.test.ts +++ b/apps/readest-app/src/__tests__/utils/paragraph.test.ts @@ -10,8 +10,8 @@ describe('ParagraphIterator', () => {

First

 

-

\u200b

-

\u00a0

+

+

 


Second

`); @@ -44,3 +44,81 @@ describe('ParagraphIterator', () => { expect(iterator.next()?.toString()).toContain('Outro'); }); }); + +describe('ParagraphIterator.findIndexByRange', () => { + // Five compact, single-text-node blocks so indices map 1:1 to paragraphs. + const buildIterator = async () => { + const doc = createDoc( + `

Zero

One

Two

Three

Four

`, + ); + const iterator = await ParagraphIterator.createAsync(doc, 1000); + return { doc, iterator }; + }; + + // Builds a collapsed Range at `offset` inside the text node of paragraph `id`. + const pointInside = (doc: Document, id: string, offset = 0): Range => { + const el = doc.getElementById(id)!; + const textNode = el.firstChild!; + const range = doc.createRange(); + range.setStart(textNode, offset); + range.setEnd(textNode, offset); + return range; + }; + + it('returns the index of the block that contains the target start (containment)', async () => { + const { doc, iterator } = await buildIterator(); + // A point inside block 2's text must resolve to 2 (not 1 or 3). + const target = pointInside(doc, 'b2', 1); + expect(iterator.findIndexByRange(target)).toBe(2); + }); + + it('returns -1 (not 0) when the target is after the last block', async () => { + const { doc, iterator } = await buildIterator(); + // Append a node AFTER the iterator is built so it belongs to no block and + // sits in document order past the last block's end. + const trailing = doc.createElement('span'); + trailing.textContent = 'trailing'; + doc.body.appendChild(trailing); + const range = doc.createRange(); + range.setStart(trailing.firstChild!, 0); + range.collapse(true); + expect(iterator.findIndexByRange(range)).toBe(-1); + }); + + it('returns the nearest following block when the target falls in a gap', async () => { + const { doc, iterator } = await buildIterator(); + // Insert a node between block 2 and block 3 AFTER the iterator is built so + // it sits in a genuine gap that no block range covers. + const gap = doc.createElement('span'); + gap.textContent = 'gap'; + doc.body.insertBefore(gap, doc.getElementById('b3')); + const range = doc.createRange(); + range.setStart(gap.firstChild!, 0); + range.collapse(true); + // No block contains the gap point; the nearest following block is index 3. + expect(iterator.findIndexByRange(range)).toBe(3); + }); + + it('returns -1 when there are no blocks', async () => { + const doc = createDoc(`
`); + const iterator = await ParagraphIterator.createAsync(doc, 1000); + expect(iterator.length).toBe(0); + const range = doc.createRange(); + range.setStart(doc.body, 0); + range.collapse(true); + expect(iterator.findIndexByRange(range)).toBe(-1); + }); + + it('resolves correctly with a correct hint (fast path)', async () => { + const { doc, iterator } = await buildIterator(); + const target = pointInside(doc, 'b3', 0); + expect(iterator.findIndexByRange(target, 3)).toBe(3); + }); + + it('resolves correctly with a stale hint (binary-search fallback)', async () => { + const { doc, iterator } = await buildIterator(); + const target = pointInside(doc, 'b1', 0); + // Hint points far away (block 4); must still resolve to 1. + expect(iterator.findIndexByRange(target, 4)).toBe(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/range.test.ts b/apps/readest-app/src/__tests__/utils/range.test.ts new file mode 100644 index 00000000..139cf2fe --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/range.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { isRangeLike } from '@/utils/range'; + +describe('isRangeLike', () => { + it('accepts a real Range', () => { + expect(isRangeLike(document.createRange())).toBe(true); + }); + + it('accepts a cross-realm Range (range-like object that is NOT instanceof the top Range)', () => { + // Simulates an iframe-realm Range: has Range methods but is not an instance + // of this realm's Range constructor (the exact failure resolveCFI hits). + const crossRealmRange = { + startContainer: document.body, + startOffset: 0, + endContainer: document.body, + endOffset: 0, + cloneRange() { + return this; + }, + toString() { + return 'word'; + }, + }; + expect(crossRealmRange instanceof Range).toBe(false); + expect(isRangeLike(crossRealmRange)).toBe(true); + }); + + it('rejects a Node (no cloneRange)', () => { + expect(isRangeLike(document.body)).toBe(false); + expect(isRangeLike(document.createTextNode('x'))).toBe(false); + }); + + it('rejects null / undefined / primitives', () => { + expect(isRangeLike(null)).toBe(false); + expect(isRangeLike(undefined)).toBe(false); + expect(isRangeLike('range')).toBe(false); + expect(isRangeLike(42)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/DictionarySheet.tsx b/apps/readest-app/src/app/reader/components/annotator/DictionarySheet.tsx index 885d6a70..ba284f52 100644 --- a/apps/readest-app/src/app/reader/components/annotator/DictionarySheet.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/DictionarySheet.tsx @@ -25,7 +25,10 @@ const DictionarySheet: React.FC = ({ word, lang, onDismiss dismissible header={ void; onNext: () => void; onClose: () => void; + /** True when a TTS session is engaged (playing/paused) — drives the audio glyph. */ + ttsActive?: boolean; + /** Toggle read-along: start TTS from the focused paragraph, or stop it. */ + onToggleTtsAudio?: () => void; viewSettings?: ViewSettings; gridInsets: Insets; } @@ -51,6 +56,8 @@ const ParagraphBar: React.FC = ({ onPrev, onNext, onClose, + ttsActive = false, + onToggleTtsAudio, viewSettings, gridInsets, }) => { @@ -286,6 +293,37 @@ const ParagraphBar: React.FC = ({ + {onToggleTtsAudio && ( + <> +
+ + {/* Audio (TTS) toggle — starts read-along from the focused + paragraph, or stops it when engaged (#3235). Active state uses a + filled glyph + eink-bordered surface so it reads in e-ink + without relying on color. */} + + + )} + - {showWpmDropdown && ( +
+ {/* TTS "following audio" status row — slim, below the header and above the + context panel (never inside the transport row). Uses the 'plain' variant + to match the overlay's own theme-painted surface. idle/unsupported + collapse to nothing. */} + {(ttsSyncStatus === 'following' || + ttsSyncStatus === 'syncing' || + ttsSyncStatus === 'decoupled' || + ttsSyncStatus === 'paused') && ( +
+ +
+ )} + {/* Context panel (always visible, collapsible) */}
- + {/* Trailing cluster: audio (TTS) toggle + divider + settings gear. + The audio toggle starts TTS from the displayed word (or stops it + when engaged) — never a second play triangle (decision 5). Active + state uses a filled glyph + eink-bordered surface so it reads in + e-ink without relying on color. */} +
+ + +
{/* Settings row (collapsible) */} diff --git a/apps/readest-app/src/app/reader/components/rsvp/rsvpTts.ts b/apps/readest-app/src/app/reader/components/rsvp/rsvpTts.ts new file mode 100644 index 00000000..dc6bd93f --- /dev/null +++ b/apps/readest-app/src/app/reader/components/rsvp/rsvpTts.ts @@ -0,0 +1,45 @@ +import { RsvpWord } from '@/services/rsvp'; + +// Detail payload for the app-bus `tts-speak` event (see useTTSControl.handleTTSSpeak, +// which honors a passed `range` + `index`). RSVP starts audio from the displayed +// word so the listener and the flashing word stay aligned. +export interface RsvpTtsSpeakDetail { + bookKey: string; + // Section spine index of the displayed word — starts TTS in the right section. + index?: number; + // Live DOM range of the displayed word — starts TTS at the exact word. Omitted + // when the word has no range or the range is stale (its document no longer + // matches the current content), so TTS falls back to its own start position. + range?: Range; +} + +// Build the `tts-speak` detail for "start audio from the current RSVP word" +// (decision 5, #3235). Returns null when there is no current word to align to. +// +// Start-alignment rules: +// - index = the word's spine index (when known), so audio begins in the +// displayed section even if the range can't be used. +// - range is included ONLY when it is live: it exists and its ownerDocument is +// the document RSVP is currently rendering (`currentDoc`). A stale or +// cross-document range would resolve to the wrong place, so it is dropped and +// TTS falls back to its own start position. +export const buildRsvpTtsSpeakDetail = ( + currentWord: RsvpWord | null | undefined, + bookKey: string, + currentDoc: Document | null | undefined, +): RsvpTtsSpeakDetail | null => { + if (!currentWord) return null; + + const detail: RsvpTtsSpeakDetail = { bookKey }; + + if (typeof currentWord.docIndex === 'number') { + detail.index = currentWord.docIndex; + } + + const range = currentWord.range; + if (range && currentDoc && range.startContainer.ownerDocument === currentDoc) { + detail.range = range; + } + + return detail; +}; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSFollowIndicator.tsx b/apps/readest-app/src/app/reader/components/tts/TTSFollowIndicator.tsx new file mode 100644 index 00000000..0fe81148 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSFollowIndicator.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React from 'react'; +import clsx from 'clsx'; +import { IoVolumeHigh, IoSync } from 'react-icons/io5'; +import { useTranslation } from '@/hooks/useTranslation'; + +// The derived states both reader modes expose (slice 5, #3235). idle and +// unsupported render nothing — we never nag, especially not on fixed-layout. +// 'paused' keeps the pill visible while TTS is engaged but paused (no layout +// shift) — it renders like 'following'. +export type TtsSyncStatus = + | 'idle' + | 'following' + | 'syncing' + | 'decoupled' + | 'paused' + | 'unsupported'; + +interface TTSFollowIndicatorProps { + status: TtsSyncStatus; + /** + * RSVP non-Edge sentence-level following is paced by an estimator, not exact + * word marks, so the label is qualified with " · estimated". + */ + estimated?: boolean; + /** Re-engage following. Only wired through on the decoupled (action) state. */ + onResume?: () => void; + /** + * Surface idiom. 'base' (default) uses daisyui base tokens — for the paragraph + * overlay, where the global [data-eink] rules apply. 'plain' uses the neutral + * gray-500 overlay + currentColor text to match the RSVP overlay's own + * theme-painted surface (the same idiom as its chapter/WPM pills and "Look up" + * pill), where daisyui base tokens would clash with the book theme. + */ + variant?: 'base' | 'plain'; + className?: string; +} + +// Shared pill chassis. eink-bordered is applied unconditionally: in eink mode it +// swaps to bg-base-100 + a 1px base-content border, and the RSVP overlay paints +// its own theme surface where the global [data-eink] rules don't auto-apply +// (mirrors the RSVP "Look up" pill). Glyph + text together so the meaning never +// rests on color alone — required to read in e-ink monochrome. +const PILL_BASE = + 'eink-bordered inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium leading-none whitespace-nowrap'; + +// Per-variant fills. 'plain' inherits text color (currentColor) so it reads on +// any book theme the RSVP overlay paints. +const STATUS_FILL: Record, string> = { + base: 'bg-base-content/10 text-base-content', + plain: 'bg-gray-500/15', +}; +const ACTION_FILL: Record, string> = { + base: 'bg-base-200 text-base-content transition-colors hover:bg-base-300', + plain: 'bg-gray-500/20 transition-colors hover:bg-gray-500/30', +}; + +const TTSFollowIndicator: React.FC = ({ + status, + estimated = false, + onResume, + variant = 'base', + className, +}) => { + const _ = useTranslation(); + + // Never nag: idle has nothing to follow, unsupported (fixed-layout) can never + // engage. Both render null rather than a placeholder. + if (status === 'idle' || status === 'unsupported') return null; + + if (status === 'decoupled') { + // The only actionable state — the whole pill is the button. touch-target + // extends the hit area to 44px on mobile without growing the rendered pill. + return ( + + ); + } + + // following / syncing: a filled, non-interactive status pill. syncing adds the + // loading-dots affordance while a cross-section re-extract is in flight. + return ( +
+ {status === 'syncing' ? ( +
+ ); +}; + +export default TTSFollowIndicator; diff --git a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts index c12387db..d2cfea72 100644 --- a/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts +++ b/apps/readest-app/src/app/reader/hooks/useParagraphMode.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useReaderStore } from '@/store/readerStore'; +import { useBookDataStore } from '@/store/bookDataStore'; import { useEnv } from '@/context/EnvContext'; import { FoliateView } from '@/types/view'; import { eventDispatcher } from '@/utils/event'; @@ -7,12 +8,32 @@ import { saveViewSettings } from '@/helpers/settings'; import { ParagraphIterator } from '@/utils/paragraph'; import { getParagraphPresentation } from '@/utils/paragraphPresentation'; import { DEFAULT_PARAGRAPH_MODE_CONFIG } from '@/services/constants'; +import { isRangeLike } from '@/utils/range'; +import { + buildParagraphTtsSpeakDetail, + computeParagraphHighlightOffsets, + decideParagraphTtsHighlight, +} from '@/app/reader/components/paragraph/paragraphTts'; interface UseParagraphModeProps { bookKey: string; viewRef: React.RefObject; } +// Derived state for the TTS-sync indicator (later slice renders it). +// - 'unsupported': fixed-layout book; sync can never engage. +// - 'idle': TTS not playing / not engaged. +// - 'following': actively following the spoken position. +// - 'syncing': a cross-section position is pending a re-init. +// - 'decoupled': was following, user took manual control (TTS still playing). +export type TtsSyncStatus = + | 'unsupported' + | 'idle' + | 'following' + | 'syncing' + | 'decoupled' + | 'paused'; + export interface ParagraphState { isActive: boolean; isLoading: boolean; @@ -24,6 +45,11 @@ export interface ParagraphState { export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => { const { envConfig } = useEnv(); const { getViewSettings, setViewSettings, getProgress } = useReaderStore(); + const { getBookData } = useBookDataStore(); + + // Fixed-layout gate (D7): paragraph mode must never engage TTS sync for a + // fixed-layout book. Mirrors how other reader code reads the flag. + const isFixedLayout = getBookData(bookKey)?.isFixedLayout ?? false; const iteratorRef = useRef(null); const currentDocIndexRef = useRef(undefined); @@ -41,6 +67,29 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => paragraphCfi: string; docIndex: number; } | null>(null); + // TTS-sync (one-way follower): TTS is the clock, paragraph mode follows it. + const followingTtsRef = useRef(false); + const lastSequenceSeenRef = useRef(-Infinity); + const pendingSyncRef = useRef<{ + cfi: string; + sequence: number; + sectionIndex: number; + kind?: 'word' | 'sentence'; + } | null>(null); + // Whether the active TTS session has emitted word boundaries (Edge). Drives the + // word-vs-sentence highlight granularity; reset when a session fully stops. + const hasWordPositionsRef = useRef(false); + // Holds the latest applySyncCfi so initIterator can apply a pending cross- + // section sync after re-init without a circular useCallback dependency. + const applySyncCfiRef = useRef<((cfi: string, highlight: boolean) => void) | null>(null); + // Latest TTS playback-state for this book ('playing' vs not), used to derive + // the sync status alongside the follow/pending refs. + const ttsPlayingRef = useRef(false); + // A TTS session exists (playing OR paused) for this book. Distinct from + // ttsPlayingRef so a pause keeps the indicator + audio toggle "active" (the + // mode is still on); only a full stop clears it. Drives the bar's audio toggle. + const ttsActiveRef = useRef(false); + const refreshTtsSyncStatusRef = useRef<(() => void) | null>(null); bookKeyRef.current = bookKey; const [paragraphState, setParagraphState] = useState({ @@ -51,6 +100,33 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => currentRange: null, }); + const [ttsSyncStatus, setTtsSyncStatus] = useState( + isFixedLayout ? 'unsupported' : 'idle', + ); + + // Whether a TTS session is engaged (playing or paused) for this book. Drives + // the bar's audio toggle active/idle glyph (#3235). + const [ttsActive, setTtsActive] = useState(false); + + // Derive the indicator status from the current refs + the latest playback + // state and push it to React state so the indicator re-renders. Fixed-layout + // always wins. + const refreshTtsSyncStatus = useCallback(() => { + setTtsSyncStatus(() => { + if (isFixedLayout) return 'unsupported'; + // No session at all (never started / fully stopped): idle. + if (!ttsActiveRef.current) return 'idle'; + // Engaged but paused: keep the indicator visible (mode still on). + if (!ttsPlayingRef.current) return 'paused'; + if (!followingTtsRef.current) return 'decoupled'; + if (pendingSyncRef.current) return 'syncing'; + return 'following'; + }); + }, [isFixedLayout]); + // Lets initIterator refresh the status after clearing a pending sync without + // pulling refreshTtsSyncStatus into its (widely-depended-on) deps array. + refreshTtsSyncStatusRef.current = refreshTtsSyncStatus; + const paragraphConfig = getViewSettings(bookKey)?.paragraphMode ?? DEFAULT_PARAGRAPH_MODE_CONFIG; const getPrimaryContent = useCallback(() => { @@ -135,7 +211,8 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => const resolved = view.resolveCFI(progressLocation); if (!resolved || resolved.index !== docIndex) return null; const anchor = resolved.anchor(doc); - if (anchor instanceof Range) return anchor; + // Realm-safe: iframe-realm Range fails top-realm `instanceof Range`. + if (isRangeLike(anchor)) return anchor; if (anchor) { const range = doc.createRange(); range.selectNodeContents(anchor); @@ -155,7 +232,8 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => const resolved = view.resolveCFI(lastParagraph.paragraphCfi); if (!resolved || resolved.index !== docIndex) return null; const anchor = resolved.anchor(doc); - if (anchor instanceof Range) return anchor; + // Realm-safe: iframe-realm Range fails top-realm `instanceof Range`. + if (isRangeLike(anchor)) return anchor; if (anchor) { const range = doc.createRange(); range.selectNodeContents(anchor); @@ -182,6 +260,24 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => } updateStateFromIterator(false); + + // Apply a pending cross-section TTS sync once the iterator targets the + // CFI's section and that sync is still the latest sequence seen. + const pending = pendingSyncRef.current; + if ( + pending && + followingTtsRef.current && + pending.sectionIndex === currentDocIndexRef.current && + pending.sequence >= lastSequenceSeenRef.current + ) { + pendingSyncRef.current = null; + const action = decideParagraphTtsHighlight({ + kind: pending.kind, + hasWordPositions: hasWordPositionsRef.current, + }); + applySyncCfiRef.current?.(pending.cfi, action !== 'skip'); + refreshTtsSyncStatusRef.current?.(); + } return true; } finally { isProcessingRef.current = false; @@ -236,6 +332,113 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => }); }, [getViewSettings, viewRef]); + // Sync-focus path for TTS following. Moves focus to `index` and scrolls/emits + // exactly like focusCurrentParagraph but WITHOUT arming isFocusingRef. The + // 200ms isFocusingRef window makes the relocate handler skip re-init; if a + // TTS-driven focus armed it, the subsequent TTS section-change relocate would + // be eaten and the iterator would never re-init for the new section (it would + // then focus paragraph 0 of the wrong section). Re-entrancy from this + // programmatic scroll's own relocate is instead prevented by the + // lastSequenceSeen / section-match guards on the tts-position handler. + const focusParagraphForSync = useCallback( + async (index: number) => { + const view = viewRef.current; + const iterator = iteratorRef.current; + if (!view || !iterator) return; + + const range = iterator.goTo(index); + if (!range) return; + updateStateFromIterator(); + + await new Promise((r) => requestAnimationFrame(r)); + + const presentation = getParagraphPresentation( + range.startContainer.ownerDocument, + range, + getViewSettings(bookKeyRef.current), + ); + + const docIndex = currentDocIndexRef.current; + const renderer = view.renderer as FoliateView['renderer'] & { + goTo?: (target: { index: number; anchor: Range }) => Promise; + }; + if (docIndex !== undefined && renderer.goTo) { + renderer.goTo({ index: docIndex, anchor: range }); + } else { + view.renderer.scrollToAnchor?.(range); + } + + eventDispatcher.dispatch('paragraph-focus', { + bookKey: bookKeyRef.current, + range, + index: iterator.currentIndex, + total: iterator.length, + presentation, + }); + }, + [getViewSettings, viewRef, updateStateFromIterator], + ); + + // Resolve a TTS cfi to the matching paragraph index in the current section and + // sync-focus it. No-op when the cfi can't be resolved or maps nowhere (-1). + // When `highlight` is set, also dispatch the spoken word/sentence offsets so + // the overlay highlights the current text within the focused paragraph (#3235); + // this fires even when the paragraph doesn't change (word moving within it). + const applySyncCfi = useCallback( + (cfi: string, highlight: boolean) => { + const view = viewRef.current; + const iterator = iteratorRef.current; + const docIndex = currentDocIndexRef.current; + if (!view || !iterator || docIndex === undefined) return; + + let range: Range | null = null; + try { + const resolved = view.resolveCFI(cfi); + if (!resolved || resolved.index !== docIndex) return; + const content = getPrimaryContent(); + const doc = content?.doc; + if (!doc) return; + const anchor = resolved.anchor(doc); + // Realm-safe: iframe-realm Range fails top-realm `instanceof Range`. + if (isRangeLike(anchor)) { + range = anchor; + } else if (anchor) { + range = doc.createRange(); + range.selectNodeContents(anchor); + } + } catch { + return; + } + if (!range) return; + + const index = iterator.findIndexByRange(range, iterator.currentIndex); + if (index < 0) return; + + // Move focus only when the spoken position crossed into another paragraph; + // goTo() runs synchronously so iterator.current() is the target afterwards. + if (index !== iterator.currentIndex) { + focusParagraphForSync(index); + } + + if (highlight) { + const paragraphRange = iterator.current(); + const offsets = paragraphRange + ? computeParagraphHighlightOffsets(paragraphRange, range) + : null; + if (offsets) { + eventDispatcher.dispatch('paragraph-tts-highlight', { + bookKey: bookKeyRef.current, + index, + start: offsets.start, + end: offsets.end, + }); + } + } + }, + [viewRef, getPrimaryContent, focusParagraphForSync], + ); + applySyncCfiRef.current = applySyncCfi; + const waitForNewSection = useCallback( async (oldIndex: number | undefined, maxAttempts: number = 15): Promise => { const view = viewRef.current; @@ -262,6 +465,11 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => const view = viewRef.current; if (!iterator || !view) return false; + // Manual nav decouples from TTS following until the user re-engages. + followingTtsRef.current = false; + pendingSyncRef.current = null; + refreshTtsSyncStatus(); + const range = iterator.next(); if (range) { updateStateFromIterator(); @@ -302,13 +510,25 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => focusCurrentParagraph(); return false; } - }, [viewRef, updateStateFromIterator, focusCurrentParagraph, initIterator, waitForNewSection]); + }, [ + viewRef, + updateStateFromIterator, + focusCurrentParagraph, + initIterator, + waitForNewSection, + refreshTtsSyncStatus, + ]); const goToPrevParagraph = useCallback(async () => { const iterator = iteratorRef.current; const view = viewRef.current; if (!iterator || !view) return false; + // Manual nav decouples from TTS following until the user re-engages. + followingTtsRef.current = false; + pendingSyncRef.current = null; + refreshTtsSyncStatus(); + const range = iterator.prev(); if (range) { updateStateFromIterator(); @@ -349,7 +569,43 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => focusCurrentParagraph(); return false; } - }, [viewRef, updateStateFromIterator, focusCurrentParagraph, initIterator, waitForNewSection]); + }, [ + viewRef, + updateStateFromIterator, + focusCurrentParagraph, + initIterator, + waitForNewSection, + refreshTtsSyncStatus, + ]); + + // Re-engage TTS following after a manual nav decoupled it (indicator's + // "Resume audio" action). Sets following back on and refreshes the derived + // status; the next tts-position event re-syncs the focused paragraph. No-op + // on fixed-layout (sync is unsupported there). + const reengageTtsFollow = useCallback(() => { + if (isFixedLayout) return; + followingTtsRef.current = true; + refreshTtsSyncStatus(); + }, [isFixedLayout, refreshTtsSyncStatus]); + + // Audio (TTS) toggle from the paragraph bar (#3235). When a TTS session is + // engaged, stop it; otherwise start it from the FOCUSED paragraph with + // start-alignment — the paragraph's range (validated live) + its section index + // — so audio begins at the same paragraph that's highlighted. Mirrors RSVP's + // handleToggleTtsAudio. + const toggleTtsAudio = useCallback(() => { + if (ttsActiveRef.current) { + eventDispatcher.dispatch('tts-stop', { bookKey: bookKeyRef.current }); + return; + } + const range = iteratorRef.current?.current() ?? null; + const docIndex = currentDocIndexRef.current; + // The doc the focused paragraph lives in (current primary content), used to + // validate the range is live before passing it along. + const currentDoc = getPrimaryContent()?.doc; + const detail = buildParagraphTtsSpeakDetail(range, docIndex, bookKeyRef.current, currentDoc); + eventDispatcher.dispatch('tts-speak', detail); + }, [getPrimaryContent]); const goToParagraph = useCallback( (index: number) => { @@ -509,6 +765,93 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => }; }, [toggleParagraphMode, goToNextParagraph, goToPrevParagraph, paragraphConfig.enabled]); + // TTS sync (one-way follower): while paragraph mode is active, follow the + // spoken position. TTS is the clock; this never drives TTS back. Fixed-layout + // books never engage (D7); the indicator stays 'unsupported'. + useEffect(() => { + if (!paragraphConfig.enabled) return; + if (isFixedLayout) return; + + const handlePlaybackState = (event: CustomEvent) => { + const detail = event.detail as { bookKey?: string; state?: string } | undefined; + if (detail?.bookKey !== bookKeyRef.current) return; + const playing = detail.state === 'playing'; + ttsPlayingRef.current = playing; + // A session exists while playing OR paused; only a full stop clears it. + const active = detail.state === 'playing' || detail.state === 'paused'; + ttsActiveRef.current = active; + setTtsActive(active); + if (playing) { + // Fresh engage (re-)enables following. + followingTtsRef.current = true; + } + if (!active) { + // Full stop: forget word-boundary state and clear the word highlight so a + // later engine (which may lack word boundaries) starts clean. + hasWordPositionsRef.current = false; + eventDispatcher.dispatch('paragraph-tts-highlight', { + bookKey: bookKeyRef.current, + clear: true, + }); + } + refreshTtsSyncStatus(); + }; + + const handlePosition = (event: CustomEvent) => { + const detail = event.detail as + | { + bookKey?: string; + cfi?: string; + sectionIndex?: number; + sequence?: number; + kind?: 'word' | 'sentence'; + } + | undefined; + if (detail?.bookKey !== bookKeyRef.current) return; + if (!followingTtsRef.current) return; + if (typeof detail.cfi !== 'string' || typeof detail.sectionIndex !== 'number') return; + + // Drop out-of-order / stale events (dispatch awaits listeners serially and + // callers fire-and-forget, so a slow map can land after a newer one). + const sequence = detail.sequence ?? -Infinity; + if (sequence <= lastSequenceSeenRef.current) return; + lastSequenceSeenRef.current = sequence; + + // Word vs sentence highlight granularity (Edge emits both; words win once + // seen). Paragraph selection still runs for 'skip' so following keeps up. + const action = decideParagraphTtsHighlight({ + kind: detail.kind, + hasWordPositions: hasWordPositionsRef.current, + }); + if (detail.kind === 'word') hasWordPositionsRef.current = true; + + if (detail.sectionIndex === currentDocIndexRef.current) { + applySyncCfi(detail.cfi, action !== 'skip'); + return; + } + + // Different section: don't map. Stash the latest sync and invalidate the + // current section so the existing relocate handler re-inits the iterator + // for the new section; the pending sync is applied once re-init completes. + pendingSyncRef.current = { + cfi: detail.cfi, + sequence, + sectionIndex: detail.sectionIndex, + kind: detail.kind, + }; + iteratorRef.current = null; + refreshTtsSyncStatus(); + }; + + eventDispatcher.on('tts-playback-state', handlePlaybackState); + eventDispatcher.on('tts-position', handlePosition); + + return () => { + eventDispatcher.off('tts-playback-state', handlePlaybackState); + eventDispatcher.off('tts-position', handlePosition); + }; + }, [paragraphConfig.enabled, isFixedLayout, applySyncCfi, refreshTtsSyncStatus]); + useEffect(() => { return () => { if (focusResetTimerRef.current) { @@ -524,11 +867,15 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) => return { paragraphState, + ttsSyncStatus, + ttsActive, paragraphConfig, toggleParagraphMode, goToNextParagraph, goToPrevParagraph, goToParagraph, + toggleTtsAudio, + reengageTtsFollow, focusCurrentParagraph, initIterator, }; diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index 1c405ee6..971c4c82 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -66,6 +66,12 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp deinitMediaSession, } = useTTSMediaSession({ bookKey }); + // Broadcast playback transitions on the app-wide bus so consumers that + // can't read the hook-local isPlaying flag (RSVP, paragraph mode) can react. + const emitPlaybackState = (state: 'playing' | 'paused' | 'stopped') => { + eventDispatcher.dispatch('tts-playback-state', { bookKey, state }); + }; + const handleTTSForward = async (event: CustomEvent) => { const detail = event.detail as { bookKey: string; byMark?: boolean } | undefined; if (detail?.bookKey !== bookKey) return; @@ -92,6 +98,22 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence }); }; + // Set the TTS rate from the app bus. The RSVP overlay is full-screen, so its + // rate picker can't reach the TTS panel; it dispatches `tts-set-rate` and we + // reuse the same controller rate-change path the panel uses (handleSetRate, + // defined below — stop→setRate→start while playing, throttled). Also persists + // the value to viewSettings so it survives like a panel change. + const handleTTSSetRate = (event: CustomEvent) => { + const detail = event.detail as { bookKey: string; rate?: number } | undefined; + if (detail?.bookKey !== bookKey || typeof detail.rate !== 'number') return; + const viewSettings = getViewSettings(bookKey); + if (viewSettings) { + viewSettings.ttsRate = detail.rate; + setViewSettings(bookKey, viewSettings); + } + handleSetRate(detail.rate); + }; + const handleTTSTogglePlay = async (event: CustomEvent) => { const detail = event.detail as { bookKey: string } | undefined; if (detail?.bookKey !== bookKey) return; @@ -100,10 +122,12 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (ttsController.state === 'playing') { setIsPlaying(false); setIsPaused(true); + emitPlaybackState('paused'); await ttsController.pause(); } else { setIsPlaying(true); setIsPaused(false); + emitPlaybackState('playing'); if (ttsController.state === 'paused') { await ttsController.resume(); } else { @@ -118,6 +142,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.on('tts-forward', handleTTSForward); eventDispatcher.on('tts-backward', handleTTSBackward); eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay); + eventDispatcher.on('tts-set-rate', handleTTSSetRate); eventDispatcher.on('tts-highlight-sentence', handleTTSHighlightSentence); return () => { eventDispatcher.off('tts-speak', handleTTSSpeak); @@ -125,6 +150,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp eventDispatcher.off('tts-forward', handleTTSForward); eventDispatcher.off('tts-backward', handleTTSBackward); eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay); + eventDispatcher.off('tts-set-rate', handleTTSSetRate); eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence); if (ttsControllerRef.current) { ttsControllerRef.current.shutdown(); @@ -293,15 +319,31 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp } }; + // Republish the controller's canonical position signal onto the app-wide + // bus so paragraph mode + RSVP can follow TTS without touching the + // controller. This MUST be its own listener: handleHighlightMark / + // handleHighlightWord early-return on following-suppression and text + // selection, which would silently stop the modes from following. The + // forward fires on every controller 'tts-position', gated only by the + // listener's lifecycle (it exists only while the controller does). + const handlePosition = (e: Event) => { + eventDispatcher.dispatch('tts-position', { + bookKey, + ...(e as CustomEvent).detail, + }); + }; + ttsController.addEventListener('tts-need-auth', handleNeedAuth); ttsController.addEventListener('tts-speak-mark', handleSpeakMark); ttsController.addEventListener('tts-highlight-mark', handleHighlightMark); ttsController.addEventListener('tts-highlight-word', handleHighlightWord); + ttsController.addEventListener('tts-position', handlePosition); return () => { ttsController.removeEventListener('tts-need-auth', handleNeedAuth); ttsController.removeEventListener('tts-speak-mark', handleSpeakMark); ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark); ttsController.removeEventListener('tts-highlight-word', handleHighlightWord); + ttsController.removeEventListener('tts-position', handlePosition); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ttsController, bookKey]); @@ -473,6 +515,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp setTtsController(null); getView(bookKey)?.deselect(); setIsPlaying(false); + emitPlaybackState('stopped'); onRequestHidePanel?.(); setShowIndicator(false); setShowBackToCurrentTTSLocation(false); @@ -583,6 +626,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (ssml) { const lang = parseSSMLLang(ssml, primaryLang) || 'en'; setIsPlaying(true); + emitPlaybackState('playing'); setTtsLang(lang); ttsController.setLang(lang); @@ -619,10 +663,12 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (isPlaying) { setIsPlaying(false); setIsPaused(true); + emitPlaybackState('paused'); await ttsController.pause(); } else if (isPaused) { setIsPlaying(true); setIsPaused(false); + emitPlaybackState('playing'); // start for forward/backward/setvoice-paused // set rate don't pause the tts if (ttsController.state === 'paused') { @@ -661,8 +707,10 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (ttsController) { setIsPlaying(false); setIsPaused(true); + emitPlaybackState('paused'); await ttsController.pause(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Rate/voice/timeout/bar controls diff --git a/apps/readest-app/src/components/settings/SettingsDialog.tsx b/apps/readest-app/src/components/settings/SettingsDialog.tsx index 38337a1b..182d9202 100644 --- a/apps/readest-app/src/components/settings/SettingsDialog.tsx +++ b/apps/readest-app/src/components/settings/SettingsDialog.tsx @@ -347,7 +347,11 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => { | null = null; + #estimatorAnchorIndex = -1; + #estimatorWpm = ESTIMATED_TTS_WPM_BASE; + constructor(view: FoliateView, bookKey: string, primaryLanguage?: string) { super(); this.view = view; @@ -396,7 +427,9 @@ export class RSVPController extends EventTarget { return null; } const anchor = resolved.anchor(target.doc); - if (anchor instanceof Range) return anchor; + // Realm-safe: anchor is an iframe-realm Range, so `instanceof Range` (top + // realm) is always false. Duck-type instead (cross-realm instanceof). + if (isRangeLike(anchor)) return anchor; if (anchor && anchor instanceof target.doc.defaultView!.Node) { const range = target.doc.createRange(); range.selectNode(anchor as Node); @@ -449,6 +482,8 @@ export class RSVPController extends EventTarget { } const clampedStart = words.length > 0 ? Math.min(words.length - 1, Math.max(0, startIndex)) : 0; + // New word list => the sync cursor from any previous section is stale. + this.#lastSyncIndex = -1; this.state = { ...this.state, active: true, @@ -544,6 +579,8 @@ export class RSVPController extends EventTarget { this.clearTimer(); this.clearCountdown(); + this.stopEstimator(); + this.#lastSyncIndex = -1; this.state = { ...this.state, active: false, @@ -698,18 +735,27 @@ export class RSVPController extends EventTarget { this.emitStateChange(); } + // Slice 5 (#3235): a user-initiated jump (skip/seek/word-step) decouples RSVP + // from TTS following. Emitted so the TTS-sync wiring can set following=false; + // sync/estimator paths set currentIndex directly and never fire this. + private emitManualNav(): void { + this.dispatchEvent(new CustomEvent('rsvp-manual-nav')); + } + skipForward(count: number = 10): void { this.state.currentIndex = Math.min( this.state.words.length - 1, this.state.currentIndex + count, ); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } skipBackward(count: number = 10): void { this.state.currentIndex = Math.max(0, this.state.currentIndex - count); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } @@ -723,6 +769,7 @@ export class RSVPController extends EventTarget { this.state.currentIndex + 1, ); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } @@ -730,6 +777,7 @@ export class RSVPController extends EventTarget { if (this.state.playing) this.pause(); this.state.currentIndex = Math.max(0, this.state.currentIndex - 1); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } @@ -738,6 +786,7 @@ export class RSVPController extends EventTarget { const newIndex = Math.floor((percentage / 100) * this.state.words.length); this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, newIndex)); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } @@ -745,9 +794,237 @@ export class RSVPController extends EventTarget { if (this.state.words.length === 0) return; this.state.currentIndex = Math.max(0, Math.min(this.state.words.length - 1, index)); this.state.currentPartIndex = 0; + this.emitManualNav(); this.emitStateChange(); } + // Slice 5 (#3235): cap exposed for the non-Edge estimator's tests + callers. + static readonly ESTIMATED_MAX_WORDS_AHEAD = ESTIMATED_MAX_WORDS_AHEAD; + + // Slice 5 (#3235): estimated reading rate for a non-Edge TTS voice rate. + // clamp(BASE * ttsRate, FLOOR, CEIL) + static estimatedWpmFromRate(ttsRate: number): number { + const wpm = ESTIMATED_TTS_WPM_BASE * (ttsRate || 1); + return Math.max(ESTIMATED_TTS_WPM_FLOOR, Math.min(ESTIMATED_TTS_WPM_CEIL, wpm)); + } + + // Slice 3a (#3235): suspend/restore the auto-advance timer so an external + // driver (e.g. TTS) can own word advancement via syncToCfi without the + // controller's own setTimeout racing it. + setExternallyDriven(on: boolean): void { + if (this.#externallyDriven === on) return; + this.#externallyDriven = on; + if (on) { + // Stop any pending auto-advance immediately. + this.clearTimer(); + } else { + // Leaving external-drive mode: kill any estimator pacing first so it + // can't keep advancing, then resume normal auto-advance if playing. + this.stopEstimator(); + if (this.state.playing && this.state.active) { + this.scheduleNextWord(); + } + } + } + + // Slice 5 (#3235): drive RSVP from a non-Edge sentence mark. Jumps to the + // sentence's first word (syncToCfi — corrects any drift; that's the "snap"), + // then SELF-PACES forward through the following words on a timer at the given + // estimated wpm. Capped at ESTIMATED_MAX_WORDS_AHEAD past the anchor so a fast + // estimate can't outrun the audio past the (still-unknown) sentence end; at + // the cap it HOLDS until the next drive. No-op (and leaves the cursor put) if + // the cfi can't be resolved in this section. + driveEstimatedFromCfi(cfi: string, wpm: number): boolean { + // Stop any in-flight estimator pacing before re-anchoring (snap). + this.stopEstimator(); + if (!this.syncToCfi(cfi)) return false; + this.#estimatorAnchorIndex = this.state.currentIndex; + this.#estimatorWpm = Math.max(ESTIMATED_TTS_WPM_FLOOR, Math.min(ESTIMATED_TTS_WPM_CEIL, wpm)); + this.scheduleEstimatorAdvance(); + return true; + } + + // Slice 5 (#3235): cancel estimator pacing (e.g. disengage / stop / snap). + stopEstimator(): void { + if (this.#estimatorTimer) { + clearTimeout(this.#estimatorTimer); + this.#estimatorTimer = null; + } + this.#estimatorAnchorIndex = -1; + } + + private scheduleEstimatorAdvance(): void { + if (this.#estimatorTimer) { + clearTimeout(this.#estimatorTimer); + this.#estimatorTimer = null; + } + if (!this.#externallyDriven || this.#estimatorAnchorIndex < 0) return; + + // HOLD at the cap: never advance more than MAX_WORDS_AHEAD past the anchor. + const capIndex = Math.min( + this.state.words.length - 1, + this.#estimatorAnchorIndex + ESTIMATED_MAX_WORDS_AHEAD, + ); + if (this.state.currentIndex >= capIndex) return; + + const perWordMs = 60000 / this.#estimatorWpm; + this.#estimatorTimer = setTimeout(() => { + this.#estimatorTimer = null; + if (!this.#externallyDriven || this.#estimatorAnchorIndex < 0) return; + const next = Math.min(this.state.words.length - 1, this.state.currentIndex + 1); + if (next === this.state.currentIndex) return; + this.state.currentIndex = next; + this.state.currentPartIndex = 0; + this.#lastSyncIndex = next; + this.emitStateChange(); + this.scheduleEstimatorAdvance(); + }, perWordMs); + } + + // Slice 3a (#3235): drive the displayed word from an external CFI (e.g. the + // word/sentence TTS is currently speaking). Resolves the CFI to a DOM range + // via the same fast path findWordIndexByCfi uses (no per-word view.getCFI), + // maps it to a word by CONTAINMENT (fixing the mid-token skip), and displays + // that word WITHOUT arming the auto-advance timer. Returns false (leaving + // currentIndex untouched) when the CFI can't be resolved in this section. + syncToCfi(cfi: string): boolean { + const words = this.state.words; + if (words.length === 0) return false; + + const targetSpineIndex = this.getSpineIndex(cfi); + if (targetSpineIndex < 0) return false; + + const targetRange = this.resolveCfiToRange(cfi, targetSpineIndex); + if (!targetRange) return false; + + const index = this.findWordIndexContaining(words, targetRange, targetSpineIndex); + if (index < 0) return false; + + this.#lastSyncIndex = index; + this.state.currentIndex = index; + this.state.currentPartIndex = 0; + // Display the word but do NOT call scheduleNextWord(): the external driver + // controls advancement. This mirrors the seek display path (set index + + // emit) minus the timer. + this.emitStateChange(); + return true; + } + + // Map a resolved target range to a word by CONTAINMENT: the word whose range + // contains the target's start position. If the target falls in a gap + // (whitespace between words), fall back to the nearest FOLLOWING word. + // + // Uses a monotonic cursor (#lastSyncIndex): forward syncs scan from the last + // match (~O(1)); a target before the cursor binary-searches the + // document-ordered word ranges. + private findWordIndexContaining( + words: RsvpWord[], + targetRange: Range, + targetSpineIndex: number, + ): number { + const cursor = this.#lastSyncIndex; + + // Decide direction: if the target starts before the cursor word, binary + // search backward; otherwise linear-scan forward from the cursor. + let backward = false; + if (cursor >= 0 && cursor < words.length) { + const cursorRange = words[cursor]?.range; + if (cursorRange && words[cursor]?.docIndex === targetSpineIndex) { + try { + // target.start < cursor.start => cursor.start > target.start + backward = cursorRange.compareBoundaryPoints(Range.START_TO_START, targetRange) > 0; + } catch { + backward = false; + } + } + } + + if (backward) { + const found = this.binarySearchWord(words, targetRange, targetSpineIndex); + if (found >= 0) return found; + // Fall through to a forward scan from 0 if binary search couldn't decide. + } + + return this.linearScanWord( + words, + targetRange, + targetSpineIndex, + backward ? 0 : Math.max(0, cursor), + ); + } + + // Forward linear scan from `from`. Returns the containing word, else the + // nearest following word (gap fallback). + private linearScanWord( + words: RsvpWord[], + targetRange: Range, + targetSpineIndex: number, + from: number, + ): number { + let firstFollowing = -1; + for (let i = from; i < words.length; i++) { + const word = words[i]; + if (!word?.range || word.docIndex !== targetSpineIndex) continue; + const rel = this.compareWordToTarget(word.range, targetRange); + if (rel === 0) return i; // contains the target start + if (rel > 0 && firstFollowing < 0) firstFollowing = i; // first word after target + } + return firstFollowing; + } + + // Binary search over the document-ordered word ranges for the word containing + // the target start; if none contains it, return the nearest following word. + private binarySearchWord( + words: RsvpWord[], + targetRange: Range, + targetSpineIndex: number, + ): number { + let lo = 0; + let hi = words.length - 1; + let firstFollowing = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const word = words[mid]; + if (!word?.range || word.docIndex !== targetSpineIndex) { + // Ranges without a comparable range break ordering; fall back to a + // linear scan from the low bound. + return this.linearScanWord(words, targetRange, targetSpineIndex, lo); + } + const rel = this.compareWordToTarget(word.range, targetRange); + if (rel === 0) return mid; + if (rel < 0) { + // word is entirely before the target — search right. + lo = mid + 1; + } else { + // word starts after the target — candidate following word. + firstFollowing = mid; + hi = mid - 1; + } + } + return firstFollowing; + } + + // Classify a word range relative to a collapsed target position: + // -1 : word ends at-or-before the target start (word is before the target) + // 0 : word contains the target start (word.start <= target < word.end) + // +1 : word starts after the target start (word is after the target) + private compareWordToTarget(wordRange: Range, targetRange: Range): number { + try { + // DOM compareBoundaryPoints(how, source) semantics: + // START_TO_START -> this.start vs source.start + // START_TO_END -> this.end vs source.start + // (END_TO_START is this.start vs source.end — NOT what we want here.) + const startCmp = wordRange.compareBoundaryPoints(Range.START_TO_START, targetRange); + if (startCmp > 0) return 1; // word starts after the target start + // word.start <= target.start. Check word.end vs target.start. + const endCmp = wordRange.compareBoundaryPoints(Range.START_TO_END, targetRange); + if (endCmp > 0) return 0; // word.end > target.start => contains the target start + return -1; // word.end <= target.start => word entirely before the target + } catch { + return -1; + } + } + loadNextPageContent(retryCount = 0): void { this.clearTimer(); const words = this.extractWordsWithRanges(); @@ -761,6 +1038,8 @@ export class RSVPController extends EventTarget { } const wasPlaying = this.state.playing; + // New section => the sync cursor from the previous section is stale. + this.#lastSyncIndex = -1; this.state = { ...this.state, playing: false, @@ -804,6 +1083,8 @@ export class RSVPController extends EventTarget { } } + // Re-segmentation invalidates the prior word indices the cursor referenced. + this.#lastSyncIndex = -1; this.state = { ...this.state, words, @@ -825,6 +1106,10 @@ export class RSVPController extends EventTarget { private scheduleNextWord(): void { this.clearTimer(); + // When externally driven (e.g. by TTS), the driver advances words via + // syncToCfi; the auto-advance timer must never arm. + if (this.#externallyDriven) return; + if (!this.state.playing || !this.state.active) return; if (this.state.currentIndex >= this.state.words.length) { diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 917b46d1..df32ceb0 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -36,6 +36,10 @@ export class TTSController extends EventTarget { #ttsSectionIndex: number = -1; + // Monotonic counter for the canonical 'tts-position' event so downstream + // consumers (paragraph mode, RSVP) can drop out-of-order positions. + #positionSequence: number = 0; + // Word-level highlight state for the currently spoken chunk. Armed by a // successful dispatchSpeakMark, populated by prepareSpeakWords when a TTS // client has word-boundary metadata for the chunk. @@ -595,6 +599,22 @@ export class TTSController extends EventTarget { } } + // Canonical position signal emitted from the same paths as + // tts-highlight-mark / tts-highlight-word. The controller is the source of + // truth (it owns the section index and current word/sentence CFI). + #dispatchPosition(cfi: string, kind: 'word' | 'sentence') { + this.dispatchEvent( + new CustomEvent('tts-position', { + detail: { + cfi, + kind, + sectionIndex: this.#ttsSectionIndex, + sequence: ++this.#positionSequence, + }, + }), + ); + } + dispatchSpeakMark(mark?: TTSMark) { this.#resetSpeakWords(); this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } })); @@ -610,6 +630,7 @@ export class TTSController extends EventTarget { this.#speakWordsArmed = !!range; const cfi = this.view.getCFI(this.#ttsSectionIndex, range); this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: { cfi } })); + this.#dispatchPosition(cfi, 'sentence'); } catch { this.#suppressMarkHighlight = false; } @@ -697,6 +718,7 @@ export class TTSController extends EventTarget { const cfi = this.view.getCFI(this.#ttsSectionIndex, range); if (cfi) { this.dispatchEvent(new CustomEvent('tts-highlight-word', { detail: { cfi } })); + this.#dispatchPosition(cfi, 'word'); } } catch {} } diff --git a/apps/readest-app/src/utils/paragraph.ts b/apps/readest-app/src/utils/paragraph.ts index f6e619a0..64af6abb 100644 --- a/apps/readest-app/src/utils/paragraph.ts +++ b/apps/readest-app/src/utils/paragraph.ts @@ -212,6 +212,82 @@ export class ParagraphIterator { return this.first(); } + /** + * Synchronous, containment-based mapper for TTS-driven sync. + * + * Returns the index of the block whose range CONTAINS the target's start + * position. If the target start falls in a gap between blocks, returns the + * nearest FOLLOWING block's index. Returns -1 when the target is after the + * last block, unresolvable, or there are no blocks. + * + * Unlike `findByRangeAsync`, this NEVER falls back to `first()` (index 0) on + * a no-match — a no-match yields -1. `#blocks` are in document order, so this + * uses a binary search; when `hintIndex >= 0`, the hint and its immediate + * neighbours are checked first (forward word-by-word sync usually stays in the + * same or next block). + */ + findIndexByRange(targetRange: Range, hintIndex = -1): number { + const n = this.#blocks.length; + if (n === 0 || !targetRange) return -1; + + const point = { node: targetRange.startContainer, offset: targetRange.startOffset }; + + // Classify a block against the target's start point via Range.comparePoint: + // 1 the point is AFTER this block's end -> block is before the target + // 0 the point is WITHIN this block -> containment (a direct hit) + // -1 the point is BEFORE this block's start -> block follows the target + // Returns null on a comparison error so callers can skip the block. + const classify = (index: number): number | null => { + const block = this.#blocks[index]; + if (!block) return null; + try { + return block.comparePoint(point.node, point.offset); + } catch { + return null; + } + }; + + // Fast path: most word-by-word syncs stay in the hinted block or the next. + if (hintIndex >= 0 && hintIndex < n) { + for (const i of [hintIndex, hintIndex + 1, hintIndex - 1]) { + if (i < 0 || i >= n) continue; + if (classify(i) === 0) return i; + } + } + + // Binary search over document-ordered blocks for the first block that does + // NOT sit entirely before the target start (i.e. classify(mid) <= 0). That + // block is either the container (0) or the nearest following block (-1). + let lo = 0; + let hi = n - 1; + let candidate = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const cmp = classify(mid); + if (cmp === null) { + // Comparison failed for this block; fall back to a linear scan from lo. + for (let i = lo; i <= hi; i++) { + const c = classify(i); + if (c !== null && c <= 0) return i; + } + return -1; + } + if (cmp > 0) { + // Block ends before the target start — search the right half. + lo = mid + 1; + } else { + // Block contains (0) or follows (-1) the target start — remember it and + // search the left half for an earlier match. + candidate = mid; + hi = mid - 1; + } + } + + // `candidate` is the first block that contains or follows the target start; + // -1 means every block ended before it (target is after the last block). + return candidate; + } + async findByRangeAsync(targetRange: Range | null, batchSize = 50): Promise { if (!targetRange) return this.first(); diff --git a/apps/readest-app/src/utils/range.ts b/apps/readest-app/src/utils/range.ts new file mode 100644 index 00000000..756fc871 --- /dev/null +++ b/apps/readest-app/src/utils/range.ts @@ -0,0 +1,15 @@ +/** + * Realm-safe check for a DOM Range. + * + * A Range produced by `view.resolveCFI(cfi).anchor(doc)` is created inside the + * book iframe's realm. App-bundle code runs in the top realm, so + * `anchor instanceof Range` compares against the *top* window's `Range` + * constructor and is ALWAYS false for an iframe-realm Range (cross-realm + * instanceof). That silently turns CFI resolution into `null`. + * + * Duck-type on `cloneRange` instead — it is unique to `Range` (a `Node` does not + * have it), so this distinguishes a Range from a Node anchor without depending + * on which realm created it. See the iframe-cross-realm-instanceof learning. + */ +export const isRangeLike = (value: unknown): value is Range => + typeof value === 'object' && value !== null && typeof (value as Range).cloneRange === 'function';