* 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:
@@ -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 d’outils.",
|
||||
"Add all": "Tout ajouter",
|
||||
"Clear all": "Tout effacer",
|
||||
"No tools, drag one here": "Aucun outil, glissez-en un ici"
|
||||
"No tools, drag one here": "Aucun outil, glissez-en un ici",
|
||||
"Stopped following audio": "Suivi de l'audio arrêté",
|
||||
"Audio pace": "Rythme audio",
|
||||
"Speed follows audio": "La vitesse suit l'audio",
|
||||
"Pause audio": "Mettre l'audio en pause",
|
||||
"Play audio": "Lire l'audio",
|
||||
"Resume audio": "Reprendre l'audio",
|
||||
"Following audio": "Suit l'audio",
|
||||
" · estimated": " · estimé"
|
||||
}
|
||||
|
||||
@@ -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> </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.5–3.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 {}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user