feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)

Real <foliate-view> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-14 15:46:18 +08:00
committed by GitHub
parent cc618b8739
commit bfb85c2f68
63 changed files with 4941 additions and 89 deletions
@@ -0,0 +1,367 @@
<!-- /autoplan restore point: /Users/chrox/.gstack/projects/readest/autoplan-restore-tts-sync-20260614-024952.md -->
# 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-`<foliate-view>` 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.
<!-- AUTONOMOUS DECISION LOG (autoplan) -->
## 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.
@@ -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": " · تقديري"
}
@@ -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": " · আনুমানিক"
}
@@ -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": " · ཚོད་དཔག"
}
@@ -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"
}
@@ -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": " · κατ' εκτίμηση"
}
@@ -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"
}
@@ -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": " · تخمینی"
}
@@ -1755,5 +1755,13 @@
"All tools are in the toolbar.": "Tous les outils sont dans la barre doutils.",
"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é"
}
@@ -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": " · משוער"
}
@@ -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": " · अनुमानित"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": " · 推定"
}
@@ -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": " · 추정"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": " · примерно"
}
@@ -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": " · ඇස්තමේන්තුගත"
}
@@ -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"
}
@@ -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"
}
@@ -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": " · தோராயமாக"
}
@@ -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": " · โดยประมาณ"
}
@@ -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"
}
@@ -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": " · приблизно"
}
@@ -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"
}
@@ -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"
}
@@ -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": " · 估算"
}
@@ -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": " · 估算"
}
@@ -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(<TTSFollowIndicator status='following' />);
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(<TTSFollowIndicator status='following' estimated />);
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(<TTSFollowIndicator status='following' estimated={false} />);
expect(queryByText(/estimated/)).toBeNull();
});
it('renders a Resume audio button that calls onResume when decoupled', () => {
const onResume = vi.fn();
const { getByRole, getByText } = render(
<TTSFollowIndicator status='decoupled' onResume={onResume} />,
);
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(<TTSFollowIndicator status='syncing' />);
expect(getByText('Following audio')).toBeTruthy();
expect(container.querySelector('.loading-dots')).toBeTruthy();
});
it('renders nothing when idle', () => {
const { container } = render(<TTSFollowIndicator status='idle' />);
expect(container.firstChild).toBeNull();
});
it('renders nothing when unsupported', () => {
const { container } = render(<TTSFollowIndicator status='unsupported' />);
expect(container.firstChild).toBeNull();
});
it('always carries the eink-bordered class so it survives e-ink mode', () => {
const { container } = render(<TTSFollowIndicator status='following' />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('eink-bordered');
});
});
@@ -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 <b>) + " world"
const { p, range } = makeParagraph('Hello <b>brave</b> 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 <b>brave</b> 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',
);
});
});
@@ -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);
});
});
@@ -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 };
@@ -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);
});
});
@@ -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> = {}): RsvpTtsSyncState => ({
following: true,
lastSequenceSeen: -Infinity,
currentSectionIndex: 2,
hasWordPositions: false,
...over,
});
const detail = (over: Partial<RsvpTtsPositionDetail> = {}): 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);
});
});
});
@@ -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<string, EventListener[]>();
let controllerMock: ReturnType<typeof makeControllerMock>;
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: <R,>(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: <R,>(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<typeof import('@/services/rsvp')>();
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<string, unknown> = {}) => ({
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<void>((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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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(
<RSVPControl bookKey={BOOK_KEY} gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }} />,
);
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<RSVPControlHandle>();
render(
<RSVPControl
ref={handle}
bookKey={BOOK_KEY}
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
/>,
);
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<RSVPControlHandle>();
render(
<RSVPControl
ref={handle}
bookKey={BOOK_KEY}
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
/>,
);
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<RSVPControlHandle>();
render(
<RSVPControl
ref={handle}
bookKey={BOOK_KEY}
gridInsets={{ top: 0, bottom: 0, left: 0, right: 0 }}
/>,
);
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');
});
});
});
@@ -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 <p> into one block, so
// block N corresponds to the Nth <p>.
const createMultiParagraphDoc = () =>
createDoc('<p>Block zero</p><p>Block one</p><p>Block two</p>');
// Mock view.resolveCFI so a given cfi resolves into the Nth <p> 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<string, { sectionIndex: number; paragraphIndex: number }>,
) => {
(view.resolveCFI as ReturnType<typeof vi.fn>).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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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('<p>S0 first</p><p>S0 second</p>');
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p><p>S1 third</p>');
const { view, renderer } = createMockView([sectionZeroDoc, sectionOneDoc], 0);
stubResolveCFI(view, { 'cfi-s1-block-2': { sectionIndex: 1, paragraphIndex: 2 } });
const viewRef = { current: view } as React.RefObject<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<typeof vi.fn>).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('<p>S0 first</p><p>S0 second</p><p>S0 third</p>');
const sectionOneDoc = createDoc('<p>S1 first</p><p>S1 second</p>');
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
await waitFor(() => {
expect(hookApi?.paragraphState.currentIndex).toBe(0);
});
const relocateCall = (renderer.addEventListener as ReturnType<typeof vi.fn>).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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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<FoliateView | null>;
render(<HookHarness view={viewRef} />);
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');
});
});
@@ -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 = `<p>${TEXT}</p>`;
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:<offset>)
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 = `<p>${text}</p>`;
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<typeof vi.fn>).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);
});
});
});
@@ -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<TTSMessageEvent> {
// 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
// <foliate-view>, 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 <foliate-view> the store holds.
const viewRef = { current: view } as RefObject<FoliateView | null>;
// 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);
});
@@ -739,6 +739,94 @@ describe('TTSController', () => {
});
});
describe('tts-position event', () => {
const makeSentenceRange = () => {
document.body.innerHTML = '<p>Hello brave world</p>';
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);
@@ -10,8 +10,8 @@ describe('ParagraphIterator', () => {
<p>First</p>
<p> </p>
<p>&nbsp;</p>
<p>\u200b</p>
<p><span>\u00a0</span></p>
<p></p>
<p><span> </span></p>
<p><br /></p>
<p>Second</p>
`);
@@ -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(
`<p id="b0">Zero</p><p id="b1">One</p><p id="b2">Two</p><p id="b3">Three</p><p id="b4">Four</p>`,
);
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(`<div></div>`);
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);
});
});
@@ -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);
});
});
@@ -25,7 +25,10 @@ const DictionarySheet: React.FC<DictionarySheetProps> = ({ word, lang, onDismiss
dismissible
header={
<DictionaryResultsHeader
headerClassName='-mt-4'
// The -mt-4 compensates for Dialog's drag handle, which is `sm:hidden`
// (shown only below sm). Mirror that breakpoint so on sm+ (no handle)
// the header isn't pulled up into the top edge.
headerClassName='-mt-4 sm:mt-0'
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
@@ -9,6 +9,7 @@ import {
MdKeyboardArrowDown,
MdKeyboardArrowUp,
} from 'react-icons/md';
import { IoVolumeHigh, IoVolumeMediumOutline } from 'react-icons/io5';
import { ViewSettings } from '@/types/book';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
@@ -30,6 +31,10 @@ interface ParagraphBarProps {
onPrev: () => 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<ParagraphBarProps> = ({
onPrev,
onNext,
onClose,
ttsActive = false,
onToggleTtsAudio,
viewSettings,
gridInsets,
}) => {
@@ -286,6 +293,37 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
<NextIcon size={iconSize} />
</button>
{onToggleTtsAudio && (
<>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
{/* 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. */}
<button
onClick={onToggleTtsAudio}
disabled={isLoading}
className={clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out active:scale-90',
ttsActive
? 'text-primary eink-bordered not-eink:bg-base-200'
: 'not-eink:hover:bg-base-200',
isLoading && 'pointer-events-none opacity-50',
)}
title={ttsActive ? _('Pause audio') : _('Play audio')}
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh size={iconSize} aria-hidden='true' />
) : (
<IoVolumeMediumOutline size={iconSize} aria-hidden='true' />
)}
</button>
</>
)}
<button
onClick={onClose}
disabled={isLoading}
@@ -1,9 +1,11 @@
'use client';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { FoliateView } from '@/types/view';
import { Insets } from '@/types/misc';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { useParagraphMode } from '../../hooks/useParagraphMode';
import ParagraphBar from './ParagraphBar';
import ParagraphOverlay from './ParagraphOverlay';
@@ -17,17 +19,40 @@ interface ParagraphControlProps {
}
const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, gridInsets }) => {
const _ = useTranslation();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const {
paragraphState,
paragraphConfig,
ttsSyncStatus,
ttsActive,
toggleParagraphMode,
goToNextParagraph,
goToPrevParagraph,
toggleTtsAudio,
reengageTtsFollow,
} = useParagraphMode({ bookKey, viewRef });
// One-time-per-session decouple toast: the first time following drops while
// TTS still plays, tell the user once. Reset when following re-engages so a
// later decouple notifies again.
const decoupleToastShownRef = useRef(false);
useEffect(() => {
if (ttsSyncStatus === 'decoupled') {
if (!decoupleToastShownRef.current) {
decoupleToastShownRef.current = true;
eventDispatcher.dispatch('toast', {
message: _('Stopped following audio'),
type: 'info',
});
}
} else if (ttsSyncStatus === 'following') {
decoupleToastShownRef.current = false;
}
}, [ttsSyncStatus, _]);
if (!paragraphConfig?.enabled) {
return null;
}
@@ -39,6 +64,8 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
dimOpacity={DIM_OPACITY}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
ttsSyncStatus={ttsSyncStatus}
onResumeTtsFollow={reengageTtsFollow}
onClose={toggleParagraphMode}
/>
<ParagraphBar
@@ -49,6 +76,8 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
onPrev={goToPrevParagraph}
onNext={goToNextParagraph}
onClose={toggleParagraphMode}
ttsActive={ttsActive}
onToggleTtsAudio={toggleTtsAudio}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
/>
@@ -12,12 +12,23 @@ import {
getParagraphLayoutContext,
ParagraphPresentation,
} from '@/utils/paragraphPresentation';
import { getTextSubRange } from '@/services/tts/wordHighlight';
import TTSFollowIndicator, { TtsSyncStatus } from '../tts/TTSFollowIndicator';
import { buildTtsHighlightCssText } from './paragraphTts';
// CSS Custom Highlight registry name for the in-paragraph TTS word/sentence
// highlight (#3235). Unique per app so it never collides with other highlights.
const TTS_HIGHLIGHT_NAME = 'readest-tts-paragraph';
interface ParagraphOverlayProps {
bookKey: string;
dimOpacity: number;
viewSettings?: ViewSettings;
gridInsets?: Insets;
/** Derived TTS-sync status driving the "following audio" indicator (#3235). */
ttsSyncStatus?: TtsSyncStatus;
/** Re-engage following after a manual nav decoupled it (indicator action). */
onResumeTtsFollow?: () => void;
onClose?: () => void;
}
@@ -119,6 +130,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
dimOpacity,
viewSettings,
gridInsets = { top: 0, right: 0, bottom: 0, left: 0 },
ttsSyncStatus = 'idle',
onResumeTtsFollow,
onClose,
}) => {
const { appService } = useEnv();
@@ -127,6 +140,14 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const [isOverlayMounted, setIsOverlayMounted] = useState(false);
const [isChangingSection, setIsChangingSection] = useState(false);
const [sectionDirection, setSectionDirection] = useState<'next' | 'prev'>('next');
// Index of the currently focused paragraph, used to gate the TTS word/sentence
// highlight so a stale highlight never lands on the wrong paragraph (#3235).
const [focusIndex, setFocusIndex] = useState(-1);
const [ttsHighlight, setTtsHighlight] = useState<{
index: number;
start: number;
end: number;
} | null>(null);
const paragraphIdCounter = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -196,6 +217,12 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}) as React.CSSProperties,
[],
);
// `::highlight()` declaration matching the user's TTS highlight color/style so
// the in-paragraph word/sentence highlight looks like normal mode (#3235).
const ttsHighlightCss = useMemo(
() => buildTtsHighlightCssText(viewSettings?.ttsHighlightOptions),
[viewSettings?.ttsHighlightOptions],
);
const fallbackPresentation = useMemo(
(): ParagraphPresentation => ({
dir: layoutContext.rtl ? 'rtl' : 'ltr',
@@ -245,6 +272,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
setIsChangingSection(false);
setIsVisible(true);
setIsOverlayMounted(true);
setFocusIndex(typeof event.detail?.index === 'number' ? event.detail.index : -1);
addParagraph(range, presentation);
}
};
@@ -257,6 +285,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}
setIsOverlayMounted(false);
setIsChangingSection(false);
setTtsHighlight(null);
setTimeout(() => {
setIsVisible(false);
setParagraphs([]);
@@ -267,18 +296,36 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
if (event.detail?.bookKey !== bookKey) return;
setSectionDirection(event.detail?.direction || 'next');
setParagraphs([]);
setTtsHighlight(null);
setIsChangingSection(true);
};
// TTS word/sentence highlight within the focused paragraph (#3235). The hook
// sends character offsets relative to the paragraph start (+ its index, to
// guard against landing on the wrong paragraph) or a clear when TTS stops.
const handleTtsHighlight = (event: CustomEvent) => {
if (event.detail?.bookKey !== bookKey) return;
const detail = event.detail as
| { clear?: boolean; index?: number; start?: number; end?: number }
| undefined;
if (detail?.clear || typeof detail?.start !== 'number' || typeof detail?.end !== 'number') {
setTtsHighlight(null);
return;
}
setTtsHighlight({ index: detail.index ?? -1, start: detail.start, end: detail.end });
};
eventDispatcher.on('paragraph-focus', handleFocus);
eventDispatcher.on('paragraph-mode-disabled', handleDisabled);
eventDispatcher.on('paragraph-section-changing', handleSectionChanging);
eventDispatcher.on('paragraph-tts-highlight', handleTtsHighlight);
return () => {
if (sectionChangeTimeoutId) clearTimeout(sectionChangeTimeoutId);
eventDispatcher.off('paragraph-focus', handleFocus);
eventDispatcher.off('paragraph-mode-disabled', handleDisabled);
eventDispatcher.off('paragraph-section-changing', handleSectionChanging);
eventDispatcher.off('paragraph-tts-highlight', handleTtsHighlight);
};
}, [bookKey, addParagraph]);
@@ -334,6 +381,39 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
return () => window.removeEventListener('wheel', handleWheel, true);
}, [isVisible, bookKey]);
// Paint the current TTS word/sentence onto the cloned paragraph using the CSS
// Custom Highlight API (#3235). It highlights a Range without mutating the DOM
// and natively spans inline element boundaries (sentences), so the fade-in
// animation and the clone's markup stay untouched. Re-runs when the clone
// (paragraphs) or the offsets change; gated on the index so a highlight from a
// previous paragraph never paints the wrong text. No-op where unsupported.
useEffect(() => {
const registry = typeof CSS !== 'undefined' ? CSS.highlights : undefined;
if (!registry || typeof Highlight === 'undefined') return undefined;
const clear = () => {
registry.delete(TTS_HIGHLIGHT_NAME);
};
if (!ttsHighlight || ttsHighlight.index !== focusIndex) {
clear();
return clear;
}
const contentEl = contentRef.current?.querySelector('.paragraph-content');
if (!contentEl) {
clear();
return clear;
}
const base = document.createRange();
base.selectNodeContents(contentEl);
const range = getTextSubRange(base, ttsHighlight.start, ttsHighlight.end);
if (!range) {
clear();
return clear;
}
registry.set(TTS_HIGHLIGHT_NAME, new Highlight(range));
return clear;
}, [ttsHighlight, focusIndex, paragraphs]);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const touchStartY = e.touches[0]?.clientY ?? 0;
@@ -465,6 +545,21 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
onTouchStart={handleTouchStart}
onKeyDown={(e) => e.stopPropagation()}
>
{/* TTS "following audio" indicator, pinned top-center. Anchored below the
top safe-area inset the overlay already accounts for; idle/unsupported
render nothing so it stays out of the way when TTS isn't driving. */}
<div
className='pointer-events-none absolute inset-x-0 z-10 flex justify-center'
style={{
top: appService?.hasSafeAreaInset ? `calc(${gridInsets.top}px + 0.75rem)` : '0.75rem',
}}
>
{/* Only the indicator itself takes pointer events (its decoupled state is
a button); the wrapper stays transparent to backdrop/region taps. */}
<div className='pointer-events-auto'>
<TTSFollowIndicator status={ttsSyncStatus} onResume={onResumeTtsFollow} />
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={contentRef}
@@ -492,6 +587,10 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
.paragraph-content > :last-child {
margin-block-end: 0;
}
::highlight(${TTS_HIGHLIGHT_NAME}) {
${ttsHighlightCss}
}
`}</style>
{activeParagraph ? (
<div
@@ -0,0 +1,112 @@
import { TTSHighlightOptions } from '@/services/tts/types';
// Detail payload for the app-bus `tts-speak` event (see useTTSControl.handleTTSSpeak,
// which honors a passed `range` + `index`). Paragraph mode starts audio from the
// focused paragraph so the listener and the highlighted paragraph stay aligned.
// Mirrors rsvpTts.ts (decision 5, #3235).
export interface ParagraphTtsSpeakDetail {
bookKey: string;
// Section spine index of the focused paragraph — starts TTS in the right section.
index?: number;
// Live DOM range of the focused paragraph — starts TTS at the exact paragraph.
// Omitted when there is 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 focused paragraph"
// (#3235). Returns `{ bookKey }` only when there is nothing to align to.
//
// Start-alignment rules (mirror buildRsvpTtsSpeakDetail):
// - index = the paragraph's spine index (when known), so audio begins in the
// focused 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 paragraph mode 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 buildParagraphTtsSpeakDetail = (
range: Range | null | undefined,
docIndex: number | undefined,
bookKey: string,
currentDoc: Document | null | undefined,
): ParagraphTtsSpeakDetail => {
const detail: ParagraphTtsSpeakDetail = { bookKey };
if (typeof docIndex === 'number') {
detail.index = docIndex;
}
if (range && currentDoc && range.startContainer.ownerDocument === currentDoc) {
detail.range = range;
}
return detail;
};
// Character offsets of a spoken word/sentence range relative to the start of the
// focused paragraph's text (#3235). Paragraph mode renders a CLONE of the
// paragraph in the overlay, so the iframe's TTS highlight isn't visible there;
// the overlay re-creates it from these offsets (which map 1:1 onto the clone's
// text content because both the clone and these offsets start at the paragraph
// start). Returns null when the target isn't inside the paragraph or is empty.
export const computeParagraphHighlightOffsets = (
paragraphRange: Range,
targetRange: Range,
): { start: number; end: number } | null => {
try {
const doc = paragraphRange.startContainer.ownerDocument;
if (!doc) return null;
// The target must begin within the paragraph; isPointInRange returns false
// (no throw) for points before/after the range or in a different root.
if (!paragraphRange.isPointInRange(targetRange.startContainer, targetRange.startOffset)) {
return null;
}
const pre = doc.createRange();
pre.setStart(paragraphRange.startContainer, paragraphRange.startOffset);
pre.setEnd(targetRange.startContainer, targetRange.startOffset);
const start = pre.toString().length;
const length = targetRange.toString().length;
if (length <= 0) return null;
return { start, end: start + length };
} catch {
return null;
}
};
export type ParagraphTtsHighlightAction = 'word' | 'sentence' | 'skip';
// Decide what to highlight for a `tts-position` event (#3235). Edge TTS emits
// BOTH a per-sentence mark and per-word boundaries; once words have been seen we
// keep the fine-grained word highlight and skip the coarse sentence event so the
// whole sentence doesn't flicker over the current word. Engines without word
// boundaries (WebSpeech/Native) only emit sentence events → sentence highlight.
export const decideParagraphTtsHighlight = (input: {
kind?: 'word' | 'sentence';
hasWordPositions: boolean;
}): ParagraphTtsHighlightAction => {
if (input.kind === 'word') return 'word';
if (input.hasWordPositions) return 'skip';
return 'sentence';
};
// CSS declaration body for the overlay's `::highlight()` rule, derived from the
// user's TTS highlight options so the in-paragraph highlight matches normal mode
// (#3235). The CSS Custom Highlight pseudo only supports a handful of properties
// (background-color, text-decoration, color, …), so styles map onto those:
// - highlight/outline → a translucent background of the chosen color
// - underline/squiggly/strikethrough → a text-decoration in the chosen color
export const buildTtsHighlightCssText = (options?: TTSHighlightOptions): string => {
const color = options?.color || '#808080';
switch (options?.style) {
case 'underline':
return `text-decoration: underline; text-decoration-color: ${color}; text-decoration-thickness: 2px; text-underline-offset: 2px;`;
case 'squiggly':
return `text-decoration: underline wavy; text-decoration-color: ${color};`;
case 'strikethrough':
return `text-decoration: line-through; text-decoration-color: ${color};`;
case 'highlight':
case 'outline':
default:
return `background-color: color-mix(in srgb, ${color} 40%, transparent);`;
}
};
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
@@ -14,6 +14,7 @@ import {
buildRsvpExitConfigUpdate,
} from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { buildRsvpTtsSpeakDetail } from './rsvpTts';
import { getBaseFontFamily } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -29,6 +30,140 @@ interface RSVPControlProps {
gridInsets: Insets;
}
// Imperative handle so the later TTS-sync indicator slice (and tests) can read
// the derived sync status without RSVPControl having a sibling to surface it to.
export interface RSVPControlHandle {
ttsSyncStatus: TtsSyncStatus;
}
// ─── TTS-sync decision logic (slice 5, #3235) ──────────────────────────
// RSVP follows the spoken position while TTS plays (TTS is the clock). The
// pure decision below maps an incoming tts-position event to the action the
// component should take, so the stale-sequence / decouple / section-stash
// rules can be unit-tested without mounting the heavy component.
export interface RsvpTtsPositionDetail {
bookKey?: string;
cfi?: string;
kind?: 'word' | 'sentence';
sectionIndex?: number;
sequence?: number;
}
// Derived sync state surfaced to RSVPOverlay for the later indicator slice.
// idle — RSVP not following TTS (not playing / not engaged)
// following — engaged and mapping the spoken position
// syncing — a cross-section re-extract is pending
// decoupled — a manual RSVP nav dropped following while TTS still plays
// unsupported — fixed-layout book; TTS sync can never engage (D7)
export type TtsSyncStatus =
| 'idle'
| 'following'
| 'syncing'
| 'decoupled'
| 'paused'
| 'unsupported';
export interface RsvpTtsPendingSync {
cfi: string;
sequence: number;
sectionIndex: number;
}
export interface RsvpTtsSyncState {
// Whether RSVP is currently following TTS. A manual RSVP nav decouples
// (sets false); the next 'playing' re-engages.
following: boolean;
// Monotonic guard: drop tts-position with sequence <= this.
lastSequenceSeen: number;
// The section RSVP currently has extracted words for.
currentSectionIndex: number;
// Latest sync stashed during a pending section re-extract.
pendingSync?: RsvpTtsPendingSync;
// True once a word-level position has arrived — i.e. the engine emits word
// boundaries (Edge). Word-boundary engines ALSO emit sentence marks, so once
// this is set we must NOT drive the estimator on sentence positions (it
// outruns the audio and the word positions snap RSVP back → flashing). Reset
// on a full stop so a later voice switch (e.g. to a sentence-only engine)
// re-enables the estimator.
hasWordPositions: boolean;
}
export interface RsvpTtsPositionDecision {
// sync → Edge word-level: controller.syncToCfi(cfi)
// drive-estimator→ non-Edge sentence: controller.driveEstimatedFromCfi(cfi)
// reextract → different section: trigger re-extract, apply stash after
// ignore → drop (wrong book / stale seq / decoupled / malformed /
// fixed-layout where sync is unsupported, decision D7)
action: 'sync' | 'drive-estimator' | 'reextract' | 'ignore';
cfi?: string;
nextState: RsvpTtsSyncState;
}
export interface RsvpTtsDecisionOptions {
// Fixed-layout books can't host the synthetic word stream RSVP sync drives,
// so TTS sync is unsupported (decision D7). When set, every position is
// dropped without mapping or advancing state.
unsupported?: boolean;
}
export const decideRsvpTtsPosition = (
state: RsvpTtsSyncState,
detail: RsvpTtsPositionDetail,
bookKey: string,
options: RsvpTtsDecisionOptions = {},
): RsvpTtsPositionDecision => {
const ignore = (next: RsvpTtsSyncState = state): RsvpTtsPositionDecision => ({
action: 'ignore',
nextState: next,
});
// D7: never engage on fixed-layout. Checked first so state is left untouched.
if (options.unsupported) return ignore();
if (detail.bookKey !== bookKey) return ignore();
if (!state.following) return ignore();
if (typeof detail.cfi !== 'string' || typeof detail.sectionIndex !== 'number') return ignore();
const sequence = detail.sequence ?? -Infinity;
if (sequence <= state.lastSequenceSeen) return ignore();
// Different section: don't map. Stash the latest sync + bump the sequence,
// and let the caller trigger RSVP's re-extract for the new section; the stash
// is applied once re-extract completes and the sequence is still current.
if (detail.sectionIndex !== state.currentSectionIndex) {
return {
action: 'reextract',
nextState: {
...state,
lastSequenceSeen: sequence,
pendingSync: { cfi: detail.cfi, sequence, sectionIndex: detail.sectionIndex },
},
};
}
// Word-level position (Edge): the authoritative path. Mark the engine as
// word-capable so subsequent sentence marks don't also drive the estimator.
if (detail.kind === 'word') {
return {
action: 'sync',
cfi: detail.cfi,
nextState: { ...state, lastSequenceSeen: sequence, hasWordPositions: true },
};
}
// Sentence mark. Word-boundary engines emit these too — but once we've seen a
// word position, the words drive RSVP and the estimator must stay off (running
// it makes RSVP outrun the audio, then word positions snap it back → flashing).
if (state.hasWordPositions) {
return { action: 'ignore', nextState: { ...state, lastSequenceSeen: sequence } };
}
return {
action: 'drive-estimator',
cfi: detail.cfi,
nextState: { ...state, lastSequenceSeen: sequence },
};
};
// Helper to expand a range to include the full sentence
const expandRangeToSentence = (range: Range, doc: Document): Range => {
const sentenceRange = doc.createRange();
@@ -112,7 +247,10 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
return range;
};
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const RSVPControl = forwardRef<RSVPControlHandle, RSVPControlProps>(function RSVPControl(
{ bookKey, gridInsets },
ref,
) {
const _ = useTranslation();
const { envConfig } = useEnv();
const settings = useSettingsStore((s) => s.settings);
@@ -131,6 +269,20 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const [isActive, setIsActive] = useState(false);
const [showStartDialog, setShowStartDialog] = useState(false);
const [startChoice, setStartChoice] = useState<RsvpStartChoice | null>(null);
// Derived TTS-sync status for the overlay indicator (slice 8b, #3235).
const [ttsSyncStatus, setTtsSyncStatus] = useState<TtsSyncStatus>('idle');
// True when the last accepted position was sentence-level (non-Edge), so
// following is paced by the estimator — the indicator appends " · estimated".
const [ttsEstimated, setTtsEstimated] = useState(false);
// Whether TTS audio is currently engaged (playing or paused) for this book,
// tracked from the tts-playback-state bus. Drives the overlay's audio toggle
// active/idle icon (slice 7, #3235).
const [ttsActive, setTtsActive] = useState(false);
// TTS currently playing (vs paused). Drives the RSVP transport button's icon
// when read-along is engaged so play/pause maps to the audio (slice, #3235).
const [ttsPlaying, setTtsPlaying] = useState(false);
useImperativeHandle(ref, () => ({ ttsSyncStatus }), [ttsSyncStatus]);
const controllerRef = useRef<RSVPController | null>(null);
const tempHighlightRef = useRef<BookNote | null>(null);
// renderer.primaryIndex reverts after navigation (paginator #detectPrimaryView),
@@ -138,6 +290,29 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const rsvpSectionRef = useRef<number>(-1);
const rsvpChapterHrefRef = useRef<string | null>(null);
// TTS-sync follower state (slice 5, #3235). Mutated by the tts-position /
// tts-playback-state handlers and the manual-nav decouple listener.
const syncStateRef = useRef<RsvpTtsSyncState>({
following: false,
lastSequenceSeen: -Infinity,
currentSectionIndex: -1,
hasWordPositions: false,
});
// Lets the indicator's "Resume audio" action re-derive the status outside the
// sync effect that owns refreshSyncStatus (mirrors the paragraph hook).
const refreshSyncStatusRef = useRef<(() => void) | null>(null);
// Re-engage TTS following after a manual nav decoupled it (indicator action).
// Sets following back on and re-derives the status; the next tts-position
// event re-syncs. No-op when the controller is gone or sync isn't running.
const reengageTtsFollow = useCallback(() => {
const sync = syncStateRef.current;
if (sync.following) return;
sync.following = true;
controllerRef.current?.setExternallyDriven(true);
refreshSyncStatusRef.current?.();
}, []);
// Helper to remove any existing RSVP highlight
const removeRsvpHighlight = useCallback(() => {
const view = getView(bookKey);
@@ -192,6 +367,248 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
// Drop TTS following on a user-initiated manual RSVP nav (skip/seek/word-step;
// chapter-jump decouples in handleChapterSelect). Stays decoupled until the
// next 'playing' re-engages.
const decoupleFromTts = useCallback(() => {
const sync = syncStateRef.current;
if (!sync.following) return;
sync.following = false;
sync.pendingSync = undefined;
const controller = controllerRef.current;
controller?.stopEstimator();
controller?.setExternallyDriven(false);
}, []);
// Re-extract for the section TTS just drove the view into, then apply the
// stashed sync once the view settles on the target section and the stash is
// still the latest. Reuses the controller's section-load path
// (loadNextPageContent) — the same re-extract the rsvp-request-next-page flow
// uses — instead of navigating the view (TTS already navigated it).
const reextractForTtsSection = useCallback(() => {
const view = getView(bookKey);
const controller = controllerRef.current;
if (!view || !controller) return;
const targetSection = syncStateRef.current.pendingSync?.sectionIndex;
if (targetSection === undefined) return;
let cleanup: ReturnType<typeof setTimeout> | null = null;
const applyPending = () => {
rsvpSectionRef.current = view.renderer.primaryIndex;
syncStateRef.current.currentSectionIndex = view.renderer.primaryIndex;
const progress = getProgress(bookKey);
if (progress?.location) controller.setCurrentCfi(progress.location);
controller.loadNextPageContent();
// Apply the stash only if it's still the latest position and its section
// matches what we just extracted (a newer event may have superseded it).
const pending = syncStateRef.current.pendingSync;
if (
pending &&
pending.sequence === syncStateRef.current.lastSequenceSeen &&
pending.sectionIndex === view.renderer.primaryIndex
) {
if (syncStateRef.current.pendingSync?.cfi) {
controller.syncToCfi(pending.cfi);
}
syncStateRef.current.pendingSync = undefined;
}
};
// If the view is already on the target section, re-extract immediately;
// otherwise wait for TTS's own page-follow relocate to land there.
if (view.renderer.primaryIndex === targetSection) {
applyPending();
return;
}
const onRelocate = () => {
if (view.renderer.primaryIndex !== targetSection) return; // keep waiting
view.removeEventListener('relocate', onRelocate);
if (cleanup) clearTimeout(cleanup);
applyPending();
};
view.addEventListener('relocate', onRelocate);
cleanup = setTimeout(() => view.removeEventListener('relocate', onRelocate), 5000);
}, [bookKey, getProgress, getView]);
// TTS-sync follower (slice 5, #3235). While an RSVP session is active, follow
// the spoken position; TTS is the clock. Filtered to this bookKey, decoupled
// by manual nav, re-engaged on the next 'playing'.
// Fixed-layout books can't host the synthetic word stream sync drives, so
// sync is unsupported there (decision D7): playing never engages and
// positions are dropped. The derived ttsSyncStatus feeds the overlay
// indicator (slice 8b).
useEffect(() => {
if (!isActive) return;
const controller = controllerRef.current;
if (!controller) return;
const isFixedLayout = !!getBookData(bookKey)?.isFixedLayout;
let isPlaying = false;
// A TTS session exists (playing OR paused). Distinct from isPlaying so a
// pause keeps the indicator + "Audio pace" present (no layout shift) — the
// mode is still on; only a full stop clears it.
let sessionActive = false;
const refreshSyncStatus = () => {
if (isFixedLayout) {
setTtsSyncStatus('unsupported');
return;
}
const sync = syncStateRef.current;
if (!sessionActive) {
setTtsSyncStatus('idle');
} else if (!isPlaying) {
// Engaged but paused: keep the indicator visible (mode still on).
setTtsSyncStatus('paused');
} else if (!sync.following) {
// Manual nav decoupled following while TTS still plays.
setTtsSyncStatus('decoupled');
} else if (sync.pendingSync) {
// Cross-section re-extract in flight.
setTtsSyncStatus('syncing');
} else {
setTtsSyncStatus('following');
}
};
refreshSyncStatusRef.current = refreshSyncStatus;
refreshSyncStatus();
const handlePlaybackState = (event: CustomEvent) => {
const detail = event.detail as { bookKey?: string; state?: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sync = syncStateRef.current;
if (detail.state === 'playing') {
isPlaying = true;
sessionActive = true;
setTtsActive(true);
setTtsPlaying(true);
// D7: never engage following on fixed-layout.
if (!isFixedLayout) {
// (Re-)engage following from the current section.
sync.following = true;
sync.currentSectionIndex =
rsvpSectionRef.current >= 0
? rsvpSectionRef.current
: (getView(bookKey)?.renderer.primaryIndex ?? -1);
controller.setExternallyDriven(true);
}
} else if (detail.state === 'paused') {
// Paused, still engaged: keep RSVP suspended (frozen on the current
// word) so its own timer can't run away while audio is paused. The
// transport button (mapped to TTS) resumes it.
isPlaying = false;
sessionActive = true;
setTtsActive(true);
setTtsPlaying(false);
sync.following = false;
sync.pendingSync = undefined;
setTtsEstimated(false);
controller.stopEstimator();
} else if (detail.state === 'stopped') {
isPlaying = false;
sessionActive = false;
setTtsActive(false);
setTtsPlaying(false);
sync.following = false;
sync.pendingSync = undefined;
// A full stop may be followed by a voice switch; re-detect word support.
sync.hasWordPositions = false;
setTtsEstimated(false);
controller.stopEstimator();
controller.setExternallyDriven(false);
}
refreshSyncStatus();
};
const handlePosition = (event: CustomEvent) => {
const detail = event.detail as RsvpTtsPositionDetail | undefined;
if (!detail) return;
const decision = decideRsvpTtsPosition(syncStateRef.current, detail, bookKey, {
unsupported: isFixedLayout,
});
syncStateRef.current = decision.nextState;
switch (decision.action) {
case 'sync':
// Word-boundary engine: words are authoritative. Kill any estimator
// started by the sentence mark that preceded the first word, so the
// two never co-run (the cause of jump-ahead-then-snap-back flashing).
controller.stopEstimator();
if (decision.cfi) controller.syncToCfi(decision.cfi);
setTtsEstimated(false);
break;
case 'drive-estimator': {
if (!decision.cfi) break;
const viewSettings = getViewSettings(bookKey);
const wpm = RSVPController.estimatedWpmFromRate(viewSettings?.ttsRate ?? 1);
controller.driveEstimatedFromCfi(decision.cfi, wpm);
setTtsEstimated(true);
break;
}
case 'reextract':
reextractForTtsSection();
break;
case 'ignore':
default:
break;
}
refreshSyncStatus();
};
const handleManualNav = () => {
decoupleFromTts();
refreshSyncStatus();
};
eventDispatcher.on('tts-playback-state', handlePlaybackState);
eventDispatcher.on('tts-position', handlePosition);
controller.addEventListener('rsvp-manual-nav', handleManualNav);
return () => {
eventDispatcher.off('tts-playback-state', handlePlaybackState);
eventDispatcher.off('tts-position', handlePosition);
controller.removeEventListener('rsvp-manual-nav', handleManualNav);
// Leaving sync: restore the controller's own pacing.
syncStateRef.current.following = false;
syncStateRef.current.pendingSync = undefined;
controller.stopEstimator();
controller.setExternallyDriven(false);
setTtsSyncStatus('idle');
setTtsEstimated(false);
setTtsActive(false);
setTtsPlaying(false);
};
}, [
isActive,
bookKey,
getBookData,
getView,
getViewSettings,
decoupleFromTts,
reextractForTtsSection,
]);
// One-time-per-session decouple toast: the first time following drops while
// TTS still plays, tell the user once. Reset when following re-engages so a
// later decouple notifies again.
const decoupleToastShownRef = useRef(false);
useEffect(() => {
if (ttsSyncStatus === 'decoupled') {
if (!decoupleToastShownRef.current) {
decoupleToastShownRef.current = true;
eventDispatcher.dispatch('toast', {
message: _('Stopped following audio'),
type: 'info',
});
}
} else if (ttsSyncStatus === 'following') {
decoupleToastShownRef.current = false;
}
}, [ttsSyncStatus, _]);
const handleStart = useCallback(
(selectionText?: string) => {
// RSVP can be started from the menu or the keyboard shortcut (#4473);
@@ -463,6 +880,9 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const view = getView(bookKey);
if (!view) return;
// A chapter jump is a user-initiated nav: decouple from TTS following.
decoupleFromTts();
const onRelocate = (e: Event) => {
view.removeEventListener('relocate', onRelocate);
const detail = (e as CustomEvent).detail as { section?: PageInfo; tocItem?: TOCItem };
@@ -480,7 +900,7 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
view.addEventListener('relocate', onRelocate);
view.goTo(href);
},
[bookKey, getProgress, getView],
[bookKey, getProgress, getView, decoupleFromTts],
);
const handleRequestNextPage = useCallback(async () => {
@@ -546,11 +966,54 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
// Book language drives dictionary provider selection for context lookups (#4475).
const dictionaryLang = bookData?.bookDoc?.metadata?.language as string | undefined;
const handleManageDictionary = useCallback(() => {
// Open dictionary management OVER the RSVP overlay (RSVP stays open). The
// settings dialog is raised above the overlay's z-[10000] (see SettingsDialog),
// and RSVP's capture-phase keyboard handler bails while it's open so the
// settings inputs / Escape work (see RSVPOverlay).
setSettingsDialogBookKey(bookKey);
setActiveSettingsItemId('settings.language.dictionaries.manage');
setSettingsDialogOpen(true);
}, [bookKey, setActiveSettingsItemId, setSettingsDialogBookKey, setSettingsDialogOpen]);
// Audio (TTS) toggle from the overlay (slice 7, decision 5, #3235). When TTS
// is engaged, stop it; otherwise start it from the displayed RSVP word with
// start-alignment — the word's range (validated live) + its section index — so
// audio begins at the same word RSVP is flashing.
const handleToggleTtsAudio = useCallback(() => {
if (ttsActive) {
eventDispatcher.dispatch('tts-stop', { bookKey });
return;
}
const controller = controllerRef.current;
const currentWord = controller?.currentDisplayWord ?? null;
const view = getView(bookKey);
const currentDoc =
typeof currentWord?.docIndex === 'number'
? view?.renderer.getContents().find((c) => c.index === currentWord.docIndex)?.doc
: undefined;
const detail = buildRsvpTtsSpeakDetail(currentWord, bookKey, currentDoc);
eventDispatcher.dispatch('tts-speak', detail ?? { bookKey });
}, [bookKey, getView, ttsActive]);
// RSVP transport (center play/pause) mapped to TTS play/pause while read-along
// is engaged (#3235): the button should pause/resume the audio, not RSVP's own
// (suspended) timer. Reuses the existing tts-toggle-play bus event.
const handleToggleTtsPlay = useCallback(() => {
eventDispatcher.dispatch('tts-toggle-play', { bookKey });
}, [bookKey]);
// Change the TTS playback rate from the overlay's rate picker (decision 6).
// The TTS rate panel is unreachable behind the full-screen overlay, so dispatch
// a one-shot tts-set-rate the TTS hook applies via its existing rate path.
const handleSetTtsRate = useCallback(
(rate: number) => {
eventDispatcher.dispatch('tts-set-rate', { bookKey, rate });
},
[bookKey],
);
const ttsRate = viewSettings?.ttsRate ?? 1;
// Use portal to render overlay at body level to avoid stacking context issues
const portalContainer = typeof document !== 'undefined' ? document.body : null;
@@ -581,6 +1044,15 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
currentChapterHref={currentChapterHref}
fontFamily={fontFamily}
lang={dictionaryLang}
ttsSyncStatus={ttsSyncStatus}
estimated={ttsEstimated}
ttsActive={ttsActive}
ttsPlaying={ttsPlaying}
ttsRate={ttsRate}
onToggleTtsAudio={handleToggleTtsAudio}
onToggleTtsPlay={handleToggleTtsPlay}
onSetTtsRate={handleSetTtsRate}
onResumeTtsFollow={reengageTtsFollow}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
@@ -590,6 +1062,6 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
)}
</>
);
};
});
export default RSVPControl;
@@ -6,6 +6,7 @@ import { Insets } from '@/types/misc';
import { RsvpState, RSVPController } from '@/services/rsvp';
import { containsCJK } from '@/services/rsvp/utils';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { TOCItem } from '@/libs/document';
import {
IoClose,
@@ -20,12 +21,16 @@ import {
IoChevronDown,
IoSettingsSharp,
IoSearch,
IoVolumeHigh,
IoVolumeMediumOutline,
IoLockClosed,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { getPopupPosition, Position } from '@/utils/sel';
import { Overlay } from '@/components/Overlay';
import DictionarySheet from '@/app/reader/components/annotator/DictionarySheet';
import DictionaryPopup from '@/app/reader/components/annotator/DictionaryPopup';
import TTSFollowIndicator, { TtsSyncStatus } from '@/app/reader/components/tts/TTSFollowIndicator';
interface FlatChapter {
label: string;
@@ -79,6 +84,10 @@ const CONTEXT_CHUNK_SIZE = 50;
const CONTEXT_WINDOW_BEFORE = 200;
const CONTEXT_WINDOW_AFTER = 1000;
// TTS rate options for the overlay's rate picker (decision 6) — mirrors the
// 0.53.0 range the TTS panel slider clamps to, in 0.25 steps.
const TTS_RATE_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0];
// Dictionary lookup popup sizing (mirrors the reader's Annotator popup).
const DICT_POPUP_PADDING = 10;
const DICT_POPUP_MAX_WIDTH = 480;
@@ -97,6 +106,24 @@ interface RSVPOverlayProps {
fontFamily?: string;
/** Book language, used to pick dictionary providers for context lookups. */
lang?: string;
/** Derived TTS-sync status driving the "following audio" indicator (#3235). */
ttsSyncStatus?: TtsSyncStatus;
/** True when following is paced by the estimator (non-Edge sentence sync). */
estimated?: boolean;
/** True when TTS audio is engaged (playing/paused) — drives the audio toggle. */
ttsActive?: boolean;
/** True when TTS is actively playing (vs paused) — drives the transport icon. */
ttsPlaying?: boolean;
/** Current TTS playback rate, shown selected in the rate picker (decision 6). */
ttsRate?: number;
/** Toggle TTS audio: start from the current word, or stop when engaged. */
onToggleTtsAudio?: () => void;
/** Pause/resume TTS — the transport play/pause maps here while read-along is on. */
onToggleTtsPlay?: () => void;
/** Set the TTS rate (one-shot) when the WPM control is TTS-driven. */
onSetTtsRate?: (rate: number) => void;
/** Re-engage following after a manual nav decoupled it (indicator action). */
onResumeTtsFollow?: () => void;
onClose: () => void;
onChapterSelect: (href: string) => void;
onRequestNextPage: () => void;
@@ -111,6 +138,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
currentChapterHref,
fontFamily,
lang,
ttsSyncStatus = 'idle',
estimated = false,
ttsActive = false,
ttsPlaying = false,
ttsRate = 1,
onToggleTtsAudio,
onToggleTtsPlay,
onSetTtsRate,
onResumeTtsFollow,
onClose,
onChapterSelect,
onRequestNextPage,
@@ -118,12 +154,23 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}) => {
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
const isSettingsDialogOpen = useSettingsStore((s) => s.isSettingsDialogOpen);
const [state, setState] = useState<RsvpState>(controller.currentState);
const currentWord = controller.currentDisplayWord;
// The transport (center) play/pause controls TTS while read-along is engaged,
// otherwise RSVP's own timer (#3235). A ref keeps the latest closure so the
// capture-phase keyboard/tap effects don't need it in their dep arrays.
const transportToggleRef = useRef<() => void>(() => {});
transportToggleRef.current = () => {
if (ttsActive && onToggleTtsPlay) onToggleTtsPlay();
else controller.togglePlayPause();
};
const transportPlaying = ttsActive ? ttsPlaying : state.playing;
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
const chapterDropdownRef = useRef<HTMLDivElement>(null);
const [showWpmDropdown, setShowWpmDropdown] = useState(false);
const [showRateDropdown, setShowRateDropdown] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [contextCollapsed, setContextCollapsed] = useState(() => {
try {
@@ -236,12 +283,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
// While the dictionary is open it owns the keyboard (e.g. Escape closes
// the dictionary, not the whole RSVP session).
if (dict) return;
// Dictionary management (settings dialog) opens OVER RSVP; let it own the
// keyboard so its inputs accept space and Escape closes it, not RSVP.
if (isSettingsDialogOpen) return;
switch (event.key) {
case ' ':
event.preventDefault();
event.stopPropagation();
controller.togglePlayPause();
transportToggleRef.current();
break;
case 'Escape':
event.preventDefault();
@@ -292,7 +342,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
// Use capture phase to handle events before they reach dropdown/select elements
document.addEventListener('keydown', handleKeyboard, { capture: true });
return () => document.removeEventListener('keydown', handleKeyboard, { capture: true });
}, [state.active, controller, onClose, dict]);
}, [state.active, controller, onClose, dict, isSettingsDialogOpen]);
const effectiveChapterHref = currentChapterHref;
@@ -452,7 +502,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
} else if (tapX > screenWidth * 0.75) {
controller.skipForward(15);
} else {
controller.togglePlayPause();
transportToggleRef.current();
}
}
};
@@ -620,6 +670,13 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const currentFontSize =
FONT_SIZE_OPTIONS[fontSizeIndex] ?? FONT_SIZE_OPTIONS[DEFAULT_FONT_SIZE_INDEX]!;
// The WPM timer doesn't drive pacing while RSVP follows TTS — the voice does.
// Replace the WPM control with an "Audio pace" affordance that opens a TTS
// rate picker instead (decision 6, #3235).
// 'paused' keeps the WPM "Audio pace" lock too, so pausing doesn't shift layout.
const ttsDriven =
ttsSyncStatus === 'following' || ttsSyncStatus === 'syncing' || ttsSyncStatus === 'paused';
return (
<div
data-testid='rsvp-overlay'
@@ -697,31 +754,57 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
)}
</div>
{/* WPM selector */}
{/* WPM selector while RSVP follows TTS the timer no longer paces, so it
becomes an "Audio pace" affordance that opens a TTS rate picker
instead (decision 6). aria-disabled (not hard-disabled) keeps it
focusable as a hint; the lock glyph + border reads in e-ink without
relying on opacity. */}
<div className='relative shrink-0'>
<button
className='flex items-center gap-1 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-gray-500/20'
onClick={() => setShowWpmDropdown(!showWpmDropdown)}
aria-label={_('Select reading speed')}
title={_('Select reading speed')}
>
<span className='font-semibold'>{state.wpm}</span>
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ml-0.5 h-3 w-3 shrink-0 opacity-50'
{ttsDriven ? (
<button
className='eink-bordered flex items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20'
onClick={() => setShowRateDropdown(!showRateDropdown)}
aria-disabled='true'
aria-label={_('Audio pace')}
title={_('Speed follows audio')}
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
{showWpmDropdown && (
<IoLockClosed className='h-3.5 w-3.5 shrink-0 opacity-70' aria-hidden='true' />
<span className='font-medium'>{_('Audio pace')}</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ms-0.5 h-3 w-3 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
) : (
<button
className='flex items-center gap-1 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-gray-500/20'
onClick={() => setShowWpmDropdown(!showWpmDropdown)}
aria-label={_('Select reading speed')}
title={_('Select reading speed')}
>
<span className='font-semibold'>{state.wpm}</span>
<span className='ms-0.5 text-xs opacity-50'>WPM</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ms-0.5 h-3 w-3 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
)}
{showWpmDropdown && !ttsDriven && (
<>
<Overlay onDismiss={() => setShowWpmDropdown(false)} />
<div
className='absolute right-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
className='absolute end-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{controller.getWpmOptions().map((wpm) => (
@@ -744,9 +827,53 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</div>
</>
)}
{showRateDropdown && ttsDriven && (
<>
<Overlay onDismiss={() => setShowRateDropdown(false)} />
<div
className='absolute end-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{TTS_RATE_OPTIONS.map((rate) => (
<button
key={rate}
className={clsx(
'flex w-full items-center justify-between gap-3 whitespace-nowrap rounded-md border-none bg-transparent px-4 py-1.5 text-sm tabular-nums transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
Math.abs(ttsRate - rate) < 0.001 &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
)}
onClick={() => {
onSetTtsRate?.(rate);
setShowRateDropdown(false);
}}
>
<span>{rate.toFixed(2)}×</span>
</button>
))}
</div>
</>
)}
</div>
</div>
{/* 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') && (
<div className='flex shrink-0 justify-center px-3 pb-1 md:px-4'>
<TTSFollowIndicator
status={ttsSyncStatus}
estimated={estimated}
onResume={onResumeTtsFollow}
variant='plain'
/>
</div>
)}
{/* Context panel (always visible, collapsible) */}
<div className='mx-3 overflow-hidden rounded-lg border border-gray-500/20 bg-gray-500/10 md:mx-4 md:rounded-xl'>
<button
@@ -974,15 +1101,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</button>
<button
aria-label={state.playing ? _('Pause') : _('Play')}
aria-label={transportPlaying ? _('Pause') : _('Play')}
className={clsx(
'flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
state.playing ? '' : 'ps-1',
transportPlaying ? '' : 'ps-1',
)}
onClick={() => controller.togglePlayPause()}
title={state.playing ? _('Pause (Space)') : _('Play (Space)')}
onClick={() => transportToggleRef.current()}
title={transportPlaying ? _('Pause (Space)') : _('Play (Space)')}
>
{state.playing ? (
{transportPlaying ? (
<IoPause className='h-7 w-7 md:h-8 md:w-8' />
) : (
<IoPlay className='h-7 w-7 md:h-8 md:w-8' />
@@ -1017,17 +1144,48 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<span className='text-xs font-semibold opacity-80'>15</span>
</button>
<button
aria-label={_('Settings')}
className={clsx(
'absolute right-0 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
{/* 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. */}
<div className='absolute end-0 flex items-center gap-1'>
<button
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
className={clsx(
'touch-target flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95',
ttsActive
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
: 'bg-transparent hover:bg-gray-500/20',
)}
onClick={() => onToggleTtsAudio?.()}
title={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh
className='h-4 w-4 md:h-5 md:w-5'
style={{ color: accentColor }}
aria-hidden='true'
/>
) : (
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
)}
</button>
<span className='h-5 w-px bg-gray-500/30' aria-hidden='true' />
<button
aria-label={_('Settings')}
className={clsx(
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
</div>
</div>
{/* Settings row (collapsible) */}
@@ -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;
};
@@ -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<NonNullable<TTSFollowIndicatorProps['variant']>, string> = {
base: 'bg-base-content/10 text-base-content',
plain: 'bg-gray-500/15',
};
const ACTION_FILL: Record<NonNullable<TTSFollowIndicatorProps['variant']>, 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<TTSFollowIndicatorProps> = ({
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 (
<button
type='button'
aria-label={_('Resume audio')}
onClick={onResume}
className={clsx(
PILL_BASE,
'touch-target',
ACTION_FILL[variant],
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
className,
)}
>
<IoSync className='h-3.5 w-3.5 shrink-0' aria-hidden='true' />
<span>{_('Resume audio')}</span>
</button>
);
}
// following / syncing: a filled, non-interactive status pill. syncing adds the
// loading-dots affordance while a cross-section re-extract is in flight.
return (
<div
role='status'
aria-live='polite'
className={clsx(PILL_BASE, STATUS_FILL[variant], className)}
>
{status === 'syncing' ? (
<span
className='loading loading-dots loading-xs shrink-0'
aria-hidden='true'
data-testid='tts-follow-syncing'
/>
) : (
<IoVolumeHigh className='h-3.5 w-3.5 shrink-0' aria-hidden='true' />
)}
<span>{_('Following audio')}</span>
{estimated && <span className='not-eink:opacity-70'>{_(' · estimated')}</span>}
</div>
);
};
export default TTSFollowIndicator;
@@ -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<FoliateView | null>;
}
// 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<ParagraphIterator | null>(null);
const currentDocIndexRef = useRef<number | undefined>(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<ParagraphState>({
@@ -51,6 +100,33 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
currentRange: null,
});
const [ttsSyncStatus, setTtsSyncStatus] = useState<TtsSyncStatus>(
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<void>;
};
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<boolean> => {
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,
};
@@ -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
@@ -347,7 +347,11 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<Dialog
isOpen={true}
onClose={handleClose}
className='modal-open'
// Settings is the top-level modal: raise it above the full-screen RSVP
// speed-reader overlay (z-[10000]) so dictionary management opened from
// inside RSVP shows on top instead of behind it (#3235). !important beats
// the Dialog's hardcoded z-50.
className='modal-open !z-[10050]'
bgClassName={bookKey ? 'sm:!bg-black/20' : 'sm:!bg-black/50'}
boxClassName={clsx(
'sm:min-w-[520px] overflow-hidden not-eink:bg-base-200',
@@ -3,6 +3,7 @@ import { RsvpWord, RsvpState, RsvpPosition, RsvpStopPosition, RsvpStartChoice }
import { containsCJK, isCJKPunctuation, splitTextIntoWords, getHyphenParts } from './utils';
import { compare as compareCFI } from 'foliate-js/epubcfi.js';
import { XCFI } from '@/utils/xcfi';
import { isRangeLike } from '@/utils/range';
const DEFAULT_WPM = 300;
const MIN_WPM = 100;
@@ -14,6 +15,20 @@ const DEFAULT_SPLIT_HYPHENS = false;
const DEFAULT_CJK_CHAR_MODE = false;
const DEFAULT_START_DELAY_SECONDS = 3;
const START_DELAY_OPTIONS = [0, 1, 2, 3];
// Slice 5 (#3235): non-Edge TTS estimator. Sentence-only voices give us one
// mark per sentence; RSVP jumps to the sentence's first word then SELF-PACES
// forward through the following words at a rate estimated from the TTS voice
// rate, until the next sentence mark snaps it back into alignment.
// wpm = clamp(BASE * ttsRate, FLOOR, CEIL)
const ESTIMATED_TTS_WPM_BASE = 190;
const ESTIMATED_TTS_WPM_FLOOR = 60;
const ESTIMATED_TTS_WPM_CEIL = 600;
// Cap self-advance to this many words past the sentence's first word. The
// sentence end isn't known until the next mark arrives, so without a cap a fast
// estimate could outrun the audio across the rest of the section. At the cap the
// estimator HOLDS (stops its timer) and waits for the next snap.
const ESTIMATED_MAX_WORDS_AHEAD = 60;
const STORAGE_KEY_PREFIX = 'readest_rsvp_wpm_';
const PUNCTUATION_PAUSE_KEY_PREFIX = 'readest_rsvp_pause_';
const POSITION_KEY_PREFIX = 'readest_rsvp_pos_';
@@ -51,6 +66,22 @@ export class RSVPController extends EventTarget {
private countdown: number | null = null;
private cachedWords: { docIndex: number; doc: Document; words: RsvpWord[] } | null = null;
// Slice 3a (#3235): externally-driven sync (e.g. TTS drives RSVP word display).
// #lastSyncIndex is a monotonic cursor so forward word-by-word sync scans from
// the previous match (~O(1)) instead of from 0; backward seeks binary-search.
#lastSyncIndex = -1;
// While true, scheduleNextWord becomes a no-op so the auto-advance timer never
// fires — the external driver owns advancement via syncToCfi.
#externallyDriven = false;
// Slice 5 (#3235): non-Edge estimator pacing. Its own timer + the cap anchor
// it self-advances from. Kept separate from playbackTimer so the estimator and
// the normal WPM auto-advance can never co-run; both are gated by
// #externallyDriven anyway, but separate state keeps the cap bookkeeping clean.
#estimatorTimer: ReturnType<typeof setTimeout> | 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) {
@@ -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 {}
}
+76
View File
@@ -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<Range | null> {
if (!targetRange) return this.first();
+15
View File
@@ -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';