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

* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)

Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): emit canonical tts-position event from TTSController (#3235)

Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)

RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.

ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)

useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): paragraph mode follows TTS playback (#3235)

When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): speed reader follows TTS playback (#3235)

Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)

Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): 'following audio' indicator for TTS sync (#3235)

5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)

Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(reader): translate TTS-sync strings across 33 locales (#3235)

Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)

RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)

Two read-along refinements (verified live via CDP with Edge TTS):

1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
   BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
   estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
   snapped it back. Now once a word position is seen, sentence positions are
   ignored and any running estimator is stopped, so words alone drive RSVP.

2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
   play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
   own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
   stop releases it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)

- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
  pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
  WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
  Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
  the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
  handleManageDictionary now exits RSVP first (position saved/resumable) so
  management shows over the reader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)

Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)

The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)

Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)

Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).

- TTSController already tags tts-position with kind word|sentence. The hook
  decides granularity: word boundaries (Edge) drive a per-word highlight; once
  seen, the coarse sentence event is skipped so the whole sentence doesn't
  flicker over the current word. Engines without word boundaries
  (WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
  clone's text) and tagged with the paragraph index so a stale highlight never
  paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.

Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-14 15:46:18 +08:00
committed by GitHub
parent cc618b8739
commit bfb85c2f68
63 changed files with 4941 additions and 89 deletions
@@ -25,7 +25,10 @@ const DictionarySheet: React.FC<DictionarySheetProps> = ({ word, lang, onDismiss
dismissible
header={
<DictionaryResultsHeader
headerClassName='-mt-4'
// The -mt-4 compensates for Dialog's drag handle, which is `sm:hidden`
// (shown only below sm). Mirror that breakpoint so on sm+ (no handle)
// the header isn't pulled up into the top edge.
headerClassName='-mt-4 sm:mt-0'
currentWord={state.currentWord}
canGoBack={state.canGoBack}
goBack={state.goBack}
@@ -9,6 +9,7 @@ import {
MdKeyboardArrowDown,
MdKeyboardArrowUp,
} from 'react-icons/md';
import { IoVolumeHigh, IoVolumeMediumOutline } from 'react-icons/io5';
import { ViewSettings } from '@/types/book';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
@@ -30,6 +31,10 @@ interface ParagraphBarProps {
onPrev: () => void;
onNext: () => void;
onClose: () => void;
/** True when a TTS session is engaged (playing/paused) — drives the audio glyph. */
ttsActive?: boolean;
/** Toggle read-along: start TTS from the focused paragraph, or stop it. */
onToggleTtsAudio?: () => void;
viewSettings?: ViewSettings;
gridInsets: Insets;
}
@@ -51,6 +56,8 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
onPrev,
onNext,
onClose,
ttsActive = false,
onToggleTtsAudio,
viewSettings,
gridInsets,
}) => {
@@ -286,6 +293,37 @@ const ParagraphBar: React.FC<ParagraphBarProps> = ({
<NextIcon size={iconSize} />
</button>
{onToggleTtsAudio && (
<>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
{/* Audio (TTS) toggle — starts read-along from the focused
paragraph, or stops it when engaged (#3235). Active state uses a
filled glyph + eink-bordered surface so it reads in e-ink
without relying on color. */}
<button
onClick={onToggleTtsAudio}
disabled={isLoading}
className={clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out active:scale-90',
ttsActive
? 'text-primary eink-bordered not-eink:bg-base-200'
: 'not-eink:hover:bg-base-200',
isLoading && 'pointer-events-none opacity-50',
)}
title={ttsActive ? _('Pause audio') : _('Play audio')}
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh size={iconSize} aria-hidden='true' />
) : (
<IoVolumeMediumOutline size={iconSize} aria-hidden='true' />
)}
</button>
</>
)}
<button
onClick={onClose}
disabled={isLoading}
@@ -1,9 +1,11 @@
'use client';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { FoliateView } from '@/types/view';
import { Insets } from '@/types/misc';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { useParagraphMode } from '../../hooks/useParagraphMode';
import ParagraphBar from './ParagraphBar';
import ParagraphOverlay from './ParagraphOverlay';
@@ -17,17 +19,40 @@ interface ParagraphControlProps {
}
const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, gridInsets }) => {
const _ = useTranslation();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const {
paragraphState,
paragraphConfig,
ttsSyncStatus,
ttsActive,
toggleParagraphMode,
goToNextParagraph,
goToPrevParagraph,
toggleTtsAudio,
reengageTtsFollow,
} = useParagraphMode({ bookKey, viewRef });
// One-time-per-session decouple toast: the first time following drops while
// TTS still plays, tell the user once. Reset when following re-engages so a
// later decouple notifies again.
const decoupleToastShownRef = useRef(false);
useEffect(() => {
if (ttsSyncStatus === 'decoupled') {
if (!decoupleToastShownRef.current) {
decoupleToastShownRef.current = true;
eventDispatcher.dispatch('toast', {
message: _('Stopped following audio'),
type: 'info',
});
}
} else if (ttsSyncStatus === 'following') {
decoupleToastShownRef.current = false;
}
}, [ttsSyncStatus, _]);
if (!paragraphConfig?.enabled) {
return null;
}
@@ -39,6 +64,8 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
dimOpacity={DIM_OPACITY}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
ttsSyncStatus={ttsSyncStatus}
onResumeTtsFollow={reengageTtsFollow}
onClose={toggleParagraphMode}
/>
<ParagraphBar
@@ -49,6 +76,8 @@ const ParagraphControl: React.FC<ParagraphControlProps> = ({ bookKey, viewRef, g
onPrev={goToPrevParagraph}
onNext={goToNextParagraph}
onClose={toggleParagraphMode}
ttsActive={ttsActive}
onToggleTtsAudio={toggleTtsAudio}
viewSettings={viewSettings ?? undefined}
gridInsets={gridInsets}
/>
@@ -12,12 +12,23 @@ import {
getParagraphLayoutContext,
ParagraphPresentation,
} from '@/utils/paragraphPresentation';
import { getTextSubRange } from '@/services/tts/wordHighlight';
import TTSFollowIndicator, { TtsSyncStatus } from '../tts/TTSFollowIndicator';
import { buildTtsHighlightCssText } from './paragraphTts';
// CSS Custom Highlight registry name for the in-paragraph TTS word/sentence
// highlight (#3235). Unique per app so it never collides with other highlights.
const TTS_HIGHLIGHT_NAME = 'readest-tts-paragraph';
interface ParagraphOverlayProps {
bookKey: string;
dimOpacity: number;
viewSettings?: ViewSettings;
gridInsets?: Insets;
/** Derived TTS-sync status driving the "following audio" indicator (#3235). */
ttsSyncStatus?: TtsSyncStatus;
/** Re-engage following after a manual nav decoupled it (indicator action). */
onResumeTtsFollow?: () => void;
onClose?: () => void;
}
@@ -119,6 +130,8 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
dimOpacity,
viewSettings,
gridInsets = { top: 0, right: 0, bottom: 0, left: 0 },
ttsSyncStatus = 'idle',
onResumeTtsFollow,
onClose,
}) => {
const { appService } = useEnv();
@@ -127,6 +140,14 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
const [isOverlayMounted, setIsOverlayMounted] = useState(false);
const [isChangingSection, setIsChangingSection] = useState(false);
const [sectionDirection, setSectionDirection] = useState<'next' | 'prev'>('next');
// Index of the currently focused paragraph, used to gate the TTS word/sentence
// highlight so a stale highlight never lands on the wrong paragraph (#3235).
const [focusIndex, setFocusIndex] = useState(-1);
const [ttsHighlight, setTtsHighlight] = useState<{
index: number;
start: number;
end: number;
} | null>(null);
const paragraphIdCounter = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -196,6 +217,12 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}) as React.CSSProperties,
[],
);
// `::highlight()` declaration matching the user's TTS highlight color/style so
// the in-paragraph word/sentence highlight looks like normal mode (#3235).
const ttsHighlightCss = useMemo(
() => buildTtsHighlightCssText(viewSettings?.ttsHighlightOptions),
[viewSettings?.ttsHighlightOptions],
);
const fallbackPresentation = useMemo(
(): ParagraphPresentation => ({
dir: layoutContext.rtl ? 'rtl' : 'ltr',
@@ -245,6 +272,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
setIsChangingSection(false);
setIsVisible(true);
setIsOverlayMounted(true);
setFocusIndex(typeof event.detail?.index === 'number' ? event.detail.index : -1);
addParagraph(range, presentation);
}
};
@@ -257,6 +285,7 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
}
setIsOverlayMounted(false);
setIsChangingSection(false);
setTtsHighlight(null);
setTimeout(() => {
setIsVisible(false);
setParagraphs([]);
@@ -267,18 +296,36 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
if (event.detail?.bookKey !== bookKey) return;
setSectionDirection(event.detail?.direction || 'next');
setParagraphs([]);
setTtsHighlight(null);
setIsChangingSection(true);
};
// TTS word/sentence highlight within the focused paragraph (#3235). The hook
// sends character offsets relative to the paragraph start (+ its index, to
// guard against landing on the wrong paragraph) or a clear when TTS stops.
const handleTtsHighlight = (event: CustomEvent) => {
if (event.detail?.bookKey !== bookKey) return;
const detail = event.detail as
| { clear?: boolean; index?: number; start?: number; end?: number }
| undefined;
if (detail?.clear || typeof detail?.start !== 'number' || typeof detail?.end !== 'number') {
setTtsHighlight(null);
return;
}
setTtsHighlight({ index: detail.index ?? -1, start: detail.start, end: detail.end });
};
eventDispatcher.on('paragraph-focus', handleFocus);
eventDispatcher.on('paragraph-mode-disabled', handleDisabled);
eventDispatcher.on('paragraph-section-changing', handleSectionChanging);
eventDispatcher.on('paragraph-tts-highlight', handleTtsHighlight);
return () => {
if (sectionChangeTimeoutId) clearTimeout(sectionChangeTimeoutId);
eventDispatcher.off('paragraph-focus', handleFocus);
eventDispatcher.off('paragraph-mode-disabled', handleDisabled);
eventDispatcher.off('paragraph-section-changing', handleSectionChanging);
eventDispatcher.off('paragraph-tts-highlight', handleTtsHighlight);
};
}, [bookKey, addParagraph]);
@@ -334,6 +381,39 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
return () => window.removeEventListener('wheel', handleWheel, true);
}, [isVisible, bookKey]);
// Paint the current TTS word/sentence onto the cloned paragraph using the CSS
// Custom Highlight API (#3235). It highlights a Range without mutating the DOM
// and natively spans inline element boundaries (sentences), so the fade-in
// animation and the clone's markup stay untouched. Re-runs when the clone
// (paragraphs) or the offsets change; gated on the index so a highlight from a
// previous paragraph never paints the wrong text. No-op where unsupported.
useEffect(() => {
const registry = typeof CSS !== 'undefined' ? CSS.highlights : undefined;
if (!registry || typeof Highlight === 'undefined') return undefined;
const clear = () => {
registry.delete(TTS_HIGHLIGHT_NAME);
};
if (!ttsHighlight || ttsHighlight.index !== focusIndex) {
clear();
return clear;
}
const contentEl = contentRef.current?.querySelector('.paragraph-content');
if (!contentEl) {
clear();
return clear;
}
const base = document.createRange();
base.selectNodeContents(contentEl);
const range = getTextSubRange(base, ttsHighlight.start, ttsHighlight.end);
if (!range) {
clear();
return clear;
}
registry.set(TTS_HIGHLIGHT_NAME, new Highlight(range));
return clear;
}, [ttsHighlight, focusIndex, paragraphs]);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const touchStartY = e.touches[0]?.clientY ?? 0;
@@ -465,6 +545,21 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
onTouchStart={handleTouchStart}
onKeyDown={(e) => e.stopPropagation()}
>
{/* TTS "following audio" indicator, pinned top-center. Anchored below the
top safe-area inset the overlay already accounts for; idle/unsupported
render nothing so it stays out of the way when TTS isn't driving. */}
<div
className='pointer-events-none absolute inset-x-0 z-10 flex justify-center'
style={{
top: appService?.hasSafeAreaInset ? `calc(${gridInsets.top}px + 0.75rem)` : '0.75rem',
}}
>
{/* Only the indicator itself takes pointer events (its decoupled state is
a button); the wrapper stays transparent to backdrop/region taps. */}
<div className='pointer-events-auto'>
<TTSFollowIndicator status={ttsSyncStatus} onResume={onResumeTtsFollow} />
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={contentRef}
@@ -492,6 +587,10 @@ const ParagraphOverlay: React.FC<ParagraphOverlayProps> = ({
.paragraph-content > :last-child {
margin-block-end: 0;
}
::highlight(${TTS_HIGHLIGHT_NAME}) {
${ttsHighlightCss}
}
`}</style>
{activeParagraph ? (
<div
@@ -0,0 +1,112 @@
import { TTSHighlightOptions } from '@/services/tts/types';
// Detail payload for the app-bus `tts-speak` event (see useTTSControl.handleTTSSpeak,
// which honors a passed `range` + `index`). Paragraph mode starts audio from the
// focused paragraph so the listener and the highlighted paragraph stay aligned.
// Mirrors rsvpTts.ts (decision 5, #3235).
export interface ParagraphTtsSpeakDetail {
bookKey: string;
// Section spine index of the focused paragraph — starts TTS in the right section.
index?: number;
// Live DOM range of the focused paragraph — starts TTS at the exact paragraph.
// Omitted when there is no range or the range is stale (its document no longer
// matches the current content), so TTS falls back to its own start position.
range?: Range;
}
// Build the `tts-speak` detail for "start audio from the focused paragraph"
// (#3235). Returns `{ bookKey }` only when there is nothing to align to.
//
// Start-alignment rules (mirror buildRsvpTtsSpeakDetail):
// - index = the paragraph's spine index (when known), so audio begins in the
// focused section even if the range can't be used.
// - range is included ONLY when it is live: it exists and its ownerDocument is
// the document paragraph mode is currently rendering (`currentDoc`). A stale
// or cross-document range would resolve to the wrong place, so it is dropped
// and TTS falls back to its own start position.
export const buildParagraphTtsSpeakDetail = (
range: Range | null | undefined,
docIndex: number | undefined,
bookKey: string,
currentDoc: Document | null | undefined,
): ParagraphTtsSpeakDetail => {
const detail: ParagraphTtsSpeakDetail = { bookKey };
if (typeof docIndex === 'number') {
detail.index = docIndex;
}
if (range && currentDoc && range.startContainer.ownerDocument === currentDoc) {
detail.range = range;
}
return detail;
};
// Character offsets of a spoken word/sentence range relative to the start of the
// focused paragraph's text (#3235). Paragraph mode renders a CLONE of the
// paragraph in the overlay, so the iframe's TTS highlight isn't visible there;
// the overlay re-creates it from these offsets (which map 1:1 onto the clone's
// text content because both the clone and these offsets start at the paragraph
// start). Returns null when the target isn't inside the paragraph or is empty.
export const computeParagraphHighlightOffsets = (
paragraphRange: Range,
targetRange: Range,
): { start: number; end: number } | null => {
try {
const doc = paragraphRange.startContainer.ownerDocument;
if (!doc) return null;
// The target must begin within the paragraph; isPointInRange returns false
// (no throw) for points before/after the range or in a different root.
if (!paragraphRange.isPointInRange(targetRange.startContainer, targetRange.startOffset)) {
return null;
}
const pre = doc.createRange();
pre.setStart(paragraphRange.startContainer, paragraphRange.startOffset);
pre.setEnd(targetRange.startContainer, targetRange.startOffset);
const start = pre.toString().length;
const length = targetRange.toString().length;
if (length <= 0) return null;
return { start, end: start + length };
} catch {
return null;
}
};
export type ParagraphTtsHighlightAction = 'word' | 'sentence' | 'skip';
// Decide what to highlight for a `tts-position` event (#3235). Edge TTS emits
// BOTH a per-sentence mark and per-word boundaries; once words have been seen we
// keep the fine-grained word highlight and skip the coarse sentence event so the
// whole sentence doesn't flicker over the current word. Engines without word
// boundaries (WebSpeech/Native) only emit sentence events → sentence highlight.
export const decideParagraphTtsHighlight = (input: {
kind?: 'word' | 'sentence';
hasWordPositions: boolean;
}): ParagraphTtsHighlightAction => {
if (input.kind === 'word') return 'word';
if (input.hasWordPositions) return 'skip';
return 'sentence';
};
// CSS declaration body for the overlay's `::highlight()` rule, derived from the
// user's TTS highlight options so the in-paragraph highlight matches normal mode
// (#3235). The CSS Custom Highlight pseudo only supports a handful of properties
// (background-color, text-decoration, color, …), so styles map onto those:
// - highlight/outline → a translucent background of the chosen color
// - underline/squiggly/strikethrough → a text-decoration in the chosen color
export const buildTtsHighlightCssText = (options?: TTSHighlightOptions): string => {
const color = options?.color || '#808080';
switch (options?.style) {
case 'underline':
return `text-decoration: underline; text-decoration-color: ${color}; text-decoration-thickness: 2px; text-underline-offset: 2px;`;
case 'squiggly':
return `text-decoration: underline wavy; text-decoration-color: ${color};`;
case 'strikethrough':
return `text-decoration: line-through; text-decoration-color: ${color};`;
case 'highlight':
case 'outline':
default:
return `background-color: color-mix(in srgb, ${color} 40%, transparent);`;
}
};
@@ -1,6 +1,6 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
import { createPortal } from 'react-dom';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
@@ -14,6 +14,7 @@ import {
buildRsvpExitConfigUpdate,
} from '@/services/rsvp';
import { eventDispatcher } from '@/utils/event';
import { buildRsvpTtsSpeakDetail } from './rsvpTts';
import { getBaseFontFamily } from '@/utils/style';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -29,6 +30,140 @@ interface RSVPControlProps {
gridInsets: Insets;
}
// Imperative handle so the later TTS-sync indicator slice (and tests) can read
// the derived sync status without RSVPControl having a sibling to surface it to.
export interface RSVPControlHandle {
ttsSyncStatus: TtsSyncStatus;
}
// ─── TTS-sync decision logic (slice 5, #3235) ──────────────────────────
// RSVP follows the spoken position while TTS plays (TTS is the clock). The
// pure decision below maps an incoming tts-position event to the action the
// component should take, so the stale-sequence / decouple / section-stash
// rules can be unit-tested without mounting the heavy component.
export interface RsvpTtsPositionDetail {
bookKey?: string;
cfi?: string;
kind?: 'word' | 'sentence';
sectionIndex?: number;
sequence?: number;
}
// Derived sync state surfaced to RSVPOverlay for the later indicator slice.
// idle — RSVP not following TTS (not playing / not engaged)
// following — engaged and mapping the spoken position
// syncing — a cross-section re-extract is pending
// decoupled — a manual RSVP nav dropped following while TTS still plays
// unsupported — fixed-layout book; TTS sync can never engage (D7)
export type TtsSyncStatus =
| 'idle'
| 'following'
| 'syncing'
| 'decoupled'
| 'paused'
| 'unsupported';
export interface RsvpTtsPendingSync {
cfi: string;
sequence: number;
sectionIndex: number;
}
export interface RsvpTtsSyncState {
// Whether RSVP is currently following TTS. A manual RSVP nav decouples
// (sets false); the next 'playing' re-engages.
following: boolean;
// Monotonic guard: drop tts-position with sequence <= this.
lastSequenceSeen: number;
// The section RSVP currently has extracted words for.
currentSectionIndex: number;
// Latest sync stashed during a pending section re-extract.
pendingSync?: RsvpTtsPendingSync;
// True once a word-level position has arrived — i.e. the engine emits word
// boundaries (Edge). Word-boundary engines ALSO emit sentence marks, so once
// this is set we must NOT drive the estimator on sentence positions (it
// outruns the audio and the word positions snap RSVP back → flashing). Reset
// on a full stop so a later voice switch (e.g. to a sentence-only engine)
// re-enables the estimator.
hasWordPositions: boolean;
}
export interface RsvpTtsPositionDecision {
// sync → Edge word-level: controller.syncToCfi(cfi)
// drive-estimator→ non-Edge sentence: controller.driveEstimatedFromCfi(cfi)
// reextract → different section: trigger re-extract, apply stash after
// ignore → drop (wrong book / stale seq / decoupled / malformed /
// fixed-layout where sync is unsupported, decision D7)
action: 'sync' | 'drive-estimator' | 'reextract' | 'ignore';
cfi?: string;
nextState: RsvpTtsSyncState;
}
export interface RsvpTtsDecisionOptions {
// Fixed-layout books can't host the synthetic word stream RSVP sync drives,
// so TTS sync is unsupported (decision D7). When set, every position is
// dropped without mapping or advancing state.
unsupported?: boolean;
}
export const decideRsvpTtsPosition = (
state: RsvpTtsSyncState,
detail: RsvpTtsPositionDetail,
bookKey: string,
options: RsvpTtsDecisionOptions = {},
): RsvpTtsPositionDecision => {
const ignore = (next: RsvpTtsSyncState = state): RsvpTtsPositionDecision => ({
action: 'ignore',
nextState: next,
});
// D7: never engage on fixed-layout. Checked first so state is left untouched.
if (options.unsupported) return ignore();
if (detail.bookKey !== bookKey) return ignore();
if (!state.following) return ignore();
if (typeof detail.cfi !== 'string' || typeof detail.sectionIndex !== 'number') return ignore();
const sequence = detail.sequence ?? -Infinity;
if (sequence <= state.lastSequenceSeen) return ignore();
// Different section: don't map. Stash the latest sync + bump the sequence,
// and let the caller trigger RSVP's re-extract for the new section; the stash
// is applied once re-extract completes and the sequence is still current.
if (detail.sectionIndex !== state.currentSectionIndex) {
return {
action: 'reextract',
nextState: {
...state,
lastSequenceSeen: sequence,
pendingSync: { cfi: detail.cfi, sequence, sectionIndex: detail.sectionIndex },
},
};
}
// Word-level position (Edge): the authoritative path. Mark the engine as
// word-capable so subsequent sentence marks don't also drive the estimator.
if (detail.kind === 'word') {
return {
action: 'sync',
cfi: detail.cfi,
nextState: { ...state, lastSequenceSeen: sequence, hasWordPositions: true },
};
}
// Sentence mark. Word-boundary engines emit these too — but once we've seen a
// word position, the words drive RSVP and the estimator must stay off (running
// it makes RSVP outrun the audio, then word positions snap it back → flashing).
if (state.hasWordPositions) {
return { action: 'ignore', nextState: { ...state, lastSequenceSeen: sequence } };
}
return {
action: 'drive-estimator',
cfi: detail.cfi,
nextState: { ...state, lastSequenceSeen: sequence },
};
};
// Helper to expand a range to include the full sentence
const expandRangeToSentence = (range: Range, doc: Document): Range => {
const sentenceRange = doc.createRange();
@@ -112,7 +247,10 @@ const expandRangeToSentence = (range: Range, doc: Document): Range => {
return range;
};
const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const RSVPControl = forwardRef<RSVPControlHandle, RSVPControlProps>(function RSVPControl(
{ bookKey, gridInsets },
ref,
) {
const _ = useTranslation();
const { envConfig } = useEnv();
const settings = useSettingsStore((s) => s.settings);
@@ -131,6 +269,20 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const [isActive, setIsActive] = useState(false);
const [showStartDialog, setShowStartDialog] = useState(false);
const [startChoice, setStartChoice] = useState<RsvpStartChoice | null>(null);
// Derived TTS-sync status for the overlay indicator (slice 8b, #3235).
const [ttsSyncStatus, setTtsSyncStatus] = useState<TtsSyncStatus>('idle');
// True when the last accepted position was sentence-level (non-Edge), so
// following is paced by the estimator — the indicator appends " · estimated".
const [ttsEstimated, setTtsEstimated] = useState(false);
// Whether TTS audio is currently engaged (playing or paused) for this book,
// tracked from the tts-playback-state bus. Drives the overlay's audio toggle
// active/idle icon (slice 7, #3235).
const [ttsActive, setTtsActive] = useState(false);
// TTS currently playing (vs paused). Drives the RSVP transport button's icon
// when read-along is engaged so play/pause maps to the audio (slice, #3235).
const [ttsPlaying, setTtsPlaying] = useState(false);
useImperativeHandle(ref, () => ({ ttsSyncStatus }), [ttsSyncStatus]);
const controllerRef = useRef<RSVPController | null>(null);
const tempHighlightRef = useRef<BookNote | null>(null);
// renderer.primaryIndex reverts after navigation (paginator #detectPrimaryView),
@@ -138,6 +290,29 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const rsvpSectionRef = useRef<number>(-1);
const rsvpChapterHrefRef = useRef<string | null>(null);
// TTS-sync follower state (slice 5, #3235). Mutated by the tts-position /
// tts-playback-state handlers and the manual-nav decouple listener.
const syncStateRef = useRef<RsvpTtsSyncState>({
following: false,
lastSequenceSeen: -Infinity,
currentSectionIndex: -1,
hasWordPositions: false,
});
// Lets the indicator's "Resume audio" action re-derive the status outside the
// sync effect that owns refreshSyncStatus (mirrors the paragraph hook).
const refreshSyncStatusRef = useRef<(() => void) | null>(null);
// Re-engage TTS following after a manual nav decoupled it (indicator action).
// Sets following back on and re-derives the status; the next tts-position
// event re-syncs. No-op when the controller is gone or sync isn't running.
const reengageTtsFollow = useCallback(() => {
const sync = syncStateRef.current;
if (sync.following) return;
sync.following = true;
controllerRef.current?.setExternallyDriven(true);
refreshSyncStatusRef.current?.();
}, []);
// Helper to remove any existing RSVP highlight
const removeRsvpHighlight = useCallback(() => {
const view = getView(bookKey);
@@ -192,6 +367,248 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
// Drop TTS following on a user-initiated manual RSVP nav (skip/seek/word-step;
// chapter-jump decouples in handleChapterSelect). Stays decoupled until the
// next 'playing' re-engages.
const decoupleFromTts = useCallback(() => {
const sync = syncStateRef.current;
if (!sync.following) return;
sync.following = false;
sync.pendingSync = undefined;
const controller = controllerRef.current;
controller?.stopEstimator();
controller?.setExternallyDriven(false);
}, []);
// Re-extract for the section TTS just drove the view into, then apply the
// stashed sync once the view settles on the target section and the stash is
// still the latest. Reuses the controller's section-load path
// (loadNextPageContent) — the same re-extract the rsvp-request-next-page flow
// uses — instead of navigating the view (TTS already navigated it).
const reextractForTtsSection = useCallback(() => {
const view = getView(bookKey);
const controller = controllerRef.current;
if (!view || !controller) return;
const targetSection = syncStateRef.current.pendingSync?.sectionIndex;
if (targetSection === undefined) return;
let cleanup: ReturnType<typeof setTimeout> | null = null;
const applyPending = () => {
rsvpSectionRef.current = view.renderer.primaryIndex;
syncStateRef.current.currentSectionIndex = view.renderer.primaryIndex;
const progress = getProgress(bookKey);
if (progress?.location) controller.setCurrentCfi(progress.location);
controller.loadNextPageContent();
// Apply the stash only if it's still the latest position and its section
// matches what we just extracted (a newer event may have superseded it).
const pending = syncStateRef.current.pendingSync;
if (
pending &&
pending.sequence === syncStateRef.current.lastSequenceSeen &&
pending.sectionIndex === view.renderer.primaryIndex
) {
if (syncStateRef.current.pendingSync?.cfi) {
controller.syncToCfi(pending.cfi);
}
syncStateRef.current.pendingSync = undefined;
}
};
// If the view is already on the target section, re-extract immediately;
// otherwise wait for TTS's own page-follow relocate to land there.
if (view.renderer.primaryIndex === targetSection) {
applyPending();
return;
}
const onRelocate = () => {
if (view.renderer.primaryIndex !== targetSection) return; // keep waiting
view.removeEventListener('relocate', onRelocate);
if (cleanup) clearTimeout(cleanup);
applyPending();
};
view.addEventListener('relocate', onRelocate);
cleanup = setTimeout(() => view.removeEventListener('relocate', onRelocate), 5000);
}, [bookKey, getProgress, getView]);
// TTS-sync follower (slice 5, #3235). While an RSVP session is active, follow
// the spoken position; TTS is the clock. Filtered to this bookKey, decoupled
// by manual nav, re-engaged on the next 'playing'.
// Fixed-layout books can't host the synthetic word stream sync drives, so
// sync is unsupported there (decision D7): playing never engages and
// positions are dropped. The derived ttsSyncStatus feeds the overlay
// indicator (slice 8b).
useEffect(() => {
if (!isActive) return;
const controller = controllerRef.current;
if (!controller) return;
const isFixedLayout = !!getBookData(bookKey)?.isFixedLayout;
let isPlaying = false;
// A TTS session exists (playing OR paused). Distinct from isPlaying so a
// pause keeps the indicator + "Audio pace" present (no layout shift) — the
// mode is still on; only a full stop clears it.
let sessionActive = false;
const refreshSyncStatus = () => {
if (isFixedLayout) {
setTtsSyncStatus('unsupported');
return;
}
const sync = syncStateRef.current;
if (!sessionActive) {
setTtsSyncStatus('idle');
} else if (!isPlaying) {
// Engaged but paused: keep the indicator visible (mode still on).
setTtsSyncStatus('paused');
} else if (!sync.following) {
// Manual nav decoupled following while TTS still plays.
setTtsSyncStatus('decoupled');
} else if (sync.pendingSync) {
// Cross-section re-extract in flight.
setTtsSyncStatus('syncing');
} else {
setTtsSyncStatus('following');
}
};
refreshSyncStatusRef.current = refreshSyncStatus;
refreshSyncStatus();
const handlePlaybackState = (event: CustomEvent) => {
const detail = event.detail as { bookKey?: string; state?: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sync = syncStateRef.current;
if (detail.state === 'playing') {
isPlaying = true;
sessionActive = true;
setTtsActive(true);
setTtsPlaying(true);
// D7: never engage following on fixed-layout.
if (!isFixedLayout) {
// (Re-)engage following from the current section.
sync.following = true;
sync.currentSectionIndex =
rsvpSectionRef.current >= 0
? rsvpSectionRef.current
: (getView(bookKey)?.renderer.primaryIndex ?? -1);
controller.setExternallyDriven(true);
}
} else if (detail.state === 'paused') {
// Paused, still engaged: keep RSVP suspended (frozen on the current
// word) so its own timer can't run away while audio is paused. The
// transport button (mapped to TTS) resumes it.
isPlaying = false;
sessionActive = true;
setTtsActive(true);
setTtsPlaying(false);
sync.following = false;
sync.pendingSync = undefined;
setTtsEstimated(false);
controller.stopEstimator();
} else if (detail.state === 'stopped') {
isPlaying = false;
sessionActive = false;
setTtsActive(false);
setTtsPlaying(false);
sync.following = false;
sync.pendingSync = undefined;
// A full stop may be followed by a voice switch; re-detect word support.
sync.hasWordPositions = false;
setTtsEstimated(false);
controller.stopEstimator();
controller.setExternallyDriven(false);
}
refreshSyncStatus();
};
const handlePosition = (event: CustomEvent) => {
const detail = event.detail as RsvpTtsPositionDetail | undefined;
if (!detail) return;
const decision = decideRsvpTtsPosition(syncStateRef.current, detail, bookKey, {
unsupported: isFixedLayout,
});
syncStateRef.current = decision.nextState;
switch (decision.action) {
case 'sync':
// Word-boundary engine: words are authoritative. Kill any estimator
// started by the sentence mark that preceded the first word, so the
// two never co-run (the cause of jump-ahead-then-snap-back flashing).
controller.stopEstimator();
if (decision.cfi) controller.syncToCfi(decision.cfi);
setTtsEstimated(false);
break;
case 'drive-estimator': {
if (!decision.cfi) break;
const viewSettings = getViewSettings(bookKey);
const wpm = RSVPController.estimatedWpmFromRate(viewSettings?.ttsRate ?? 1);
controller.driveEstimatedFromCfi(decision.cfi, wpm);
setTtsEstimated(true);
break;
}
case 'reextract':
reextractForTtsSection();
break;
case 'ignore':
default:
break;
}
refreshSyncStatus();
};
const handleManualNav = () => {
decoupleFromTts();
refreshSyncStatus();
};
eventDispatcher.on('tts-playback-state', handlePlaybackState);
eventDispatcher.on('tts-position', handlePosition);
controller.addEventListener('rsvp-manual-nav', handleManualNav);
return () => {
eventDispatcher.off('tts-playback-state', handlePlaybackState);
eventDispatcher.off('tts-position', handlePosition);
controller.removeEventListener('rsvp-manual-nav', handleManualNav);
// Leaving sync: restore the controller's own pacing.
syncStateRef.current.following = false;
syncStateRef.current.pendingSync = undefined;
controller.stopEstimator();
controller.setExternallyDriven(false);
setTtsSyncStatus('idle');
setTtsEstimated(false);
setTtsActive(false);
setTtsPlaying(false);
};
}, [
isActive,
bookKey,
getBookData,
getView,
getViewSettings,
decoupleFromTts,
reextractForTtsSection,
]);
// One-time-per-session decouple toast: the first time following drops while
// TTS still plays, tell the user once. Reset when following re-engages so a
// later decouple notifies again.
const decoupleToastShownRef = useRef(false);
useEffect(() => {
if (ttsSyncStatus === 'decoupled') {
if (!decoupleToastShownRef.current) {
decoupleToastShownRef.current = true;
eventDispatcher.dispatch('toast', {
message: _('Stopped following audio'),
type: 'info',
});
}
} else if (ttsSyncStatus === 'following') {
decoupleToastShownRef.current = false;
}
}, [ttsSyncStatus, _]);
const handleStart = useCallback(
(selectionText?: string) => {
// RSVP can be started from the menu or the keyboard shortcut (#4473);
@@ -463,6 +880,9 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
const view = getView(bookKey);
if (!view) return;
// A chapter jump is a user-initiated nav: decouple from TTS following.
decoupleFromTts();
const onRelocate = (e: Event) => {
view.removeEventListener('relocate', onRelocate);
const detail = (e as CustomEvent).detail as { section?: PageInfo; tocItem?: TOCItem };
@@ -480,7 +900,7 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
view.addEventListener('relocate', onRelocate);
view.goTo(href);
},
[bookKey, getProgress, getView],
[bookKey, getProgress, getView, decoupleFromTts],
);
const handleRequestNextPage = useCallback(async () => {
@@ -546,11 +966,54 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
// Book language drives dictionary provider selection for context lookups (#4475).
const dictionaryLang = bookData?.bookDoc?.metadata?.language as string | undefined;
const handleManageDictionary = useCallback(() => {
// Open dictionary management OVER the RSVP overlay (RSVP stays open). The
// settings dialog is raised above the overlay's z-[10000] (see SettingsDialog),
// and RSVP's capture-phase keyboard handler bails while it's open so the
// settings inputs / Escape work (see RSVPOverlay).
setSettingsDialogBookKey(bookKey);
setActiveSettingsItemId('settings.language.dictionaries.manage');
setSettingsDialogOpen(true);
}, [bookKey, setActiveSettingsItemId, setSettingsDialogBookKey, setSettingsDialogOpen]);
// Audio (TTS) toggle from the overlay (slice 7, decision 5, #3235). When TTS
// is engaged, stop it; otherwise start it from the displayed RSVP word with
// start-alignment — the word's range (validated live) + its section index — so
// audio begins at the same word RSVP is flashing.
const handleToggleTtsAudio = useCallback(() => {
if (ttsActive) {
eventDispatcher.dispatch('tts-stop', { bookKey });
return;
}
const controller = controllerRef.current;
const currentWord = controller?.currentDisplayWord ?? null;
const view = getView(bookKey);
const currentDoc =
typeof currentWord?.docIndex === 'number'
? view?.renderer.getContents().find((c) => c.index === currentWord.docIndex)?.doc
: undefined;
const detail = buildRsvpTtsSpeakDetail(currentWord, bookKey, currentDoc);
eventDispatcher.dispatch('tts-speak', detail ?? { bookKey });
}, [bookKey, getView, ttsActive]);
// RSVP transport (center play/pause) mapped to TTS play/pause while read-along
// is engaged (#3235): the button should pause/resume the audio, not RSVP's own
// (suspended) timer. Reuses the existing tts-toggle-play bus event.
const handleToggleTtsPlay = useCallback(() => {
eventDispatcher.dispatch('tts-toggle-play', { bookKey });
}, [bookKey]);
// Change the TTS playback rate from the overlay's rate picker (decision 6).
// The TTS rate panel is unreachable behind the full-screen overlay, so dispatch
// a one-shot tts-set-rate the TTS hook applies via its existing rate path.
const handleSetTtsRate = useCallback(
(rate: number) => {
eventDispatcher.dispatch('tts-set-rate', { bookKey, rate });
},
[bookKey],
);
const ttsRate = viewSettings?.ttsRate ?? 1;
// Use portal to render overlay at body level to avoid stacking context issues
const portalContainer = typeof document !== 'undefined' ? document.body : null;
@@ -581,6 +1044,15 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
currentChapterHref={currentChapterHref}
fontFamily={fontFamily}
lang={dictionaryLang}
ttsSyncStatus={ttsSyncStatus}
estimated={ttsEstimated}
ttsActive={ttsActive}
ttsPlaying={ttsPlaying}
ttsRate={ttsRate}
onToggleTtsAudio={handleToggleTtsAudio}
onToggleTtsPlay={handleToggleTtsPlay}
onSetTtsRate={handleSetTtsRate}
onResumeTtsFollow={reengageTtsFollow}
onClose={handleClose}
onChapterSelect={handleChapterSelect}
onRequestNextPage={handleRequestNextPage}
@@ -590,6 +1062,6 @@ const RSVPControl: React.FC<RSVPControlProps> = ({ bookKey, gridInsets }) => {
)}
</>
);
};
});
export default RSVPControl;
@@ -6,6 +6,7 @@ import { Insets } from '@/types/misc';
import { RsvpState, RSVPController } from '@/services/rsvp';
import { containsCJK } from '@/services/rsvp/utils';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { TOCItem } from '@/libs/document';
import {
IoClose,
@@ -20,12 +21,16 @@ import {
IoChevronDown,
IoSettingsSharp,
IoSearch,
IoVolumeHigh,
IoVolumeMediumOutline,
IoLockClosed,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { getPopupPosition, Position } from '@/utils/sel';
import { Overlay } from '@/components/Overlay';
import DictionarySheet from '@/app/reader/components/annotator/DictionarySheet';
import DictionaryPopup from '@/app/reader/components/annotator/DictionaryPopup';
import TTSFollowIndicator, { TtsSyncStatus } from '@/app/reader/components/tts/TTSFollowIndicator';
interface FlatChapter {
label: string;
@@ -79,6 +84,10 @@ const CONTEXT_CHUNK_SIZE = 50;
const CONTEXT_WINDOW_BEFORE = 200;
const CONTEXT_WINDOW_AFTER = 1000;
// TTS rate options for the overlay's rate picker (decision 6) — mirrors the
// 0.53.0 range the TTS panel slider clamps to, in 0.25 steps.
const TTS_RATE_OPTIONS = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0];
// Dictionary lookup popup sizing (mirrors the reader's Annotator popup).
const DICT_POPUP_PADDING = 10;
const DICT_POPUP_MAX_WIDTH = 480;
@@ -97,6 +106,24 @@ interface RSVPOverlayProps {
fontFamily?: string;
/** Book language, used to pick dictionary providers for context lookups. */
lang?: string;
/** Derived TTS-sync status driving the "following audio" indicator (#3235). */
ttsSyncStatus?: TtsSyncStatus;
/** True when following is paced by the estimator (non-Edge sentence sync). */
estimated?: boolean;
/** True when TTS audio is engaged (playing/paused) — drives the audio toggle. */
ttsActive?: boolean;
/** True when TTS is actively playing (vs paused) — drives the transport icon. */
ttsPlaying?: boolean;
/** Current TTS playback rate, shown selected in the rate picker (decision 6). */
ttsRate?: number;
/** Toggle TTS audio: start from the current word, or stop when engaged. */
onToggleTtsAudio?: () => void;
/** Pause/resume TTS — the transport play/pause maps here while read-along is on. */
onToggleTtsPlay?: () => void;
/** Set the TTS rate (one-shot) when the WPM control is TTS-driven. */
onSetTtsRate?: (rate: number) => void;
/** Re-engage following after a manual nav decoupled it (indicator action). */
onResumeTtsFollow?: () => void;
onClose: () => void;
onChapterSelect: (href: string) => void;
onRequestNextPage: () => void;
@@ -111,6 +138,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
currentChapterHref,
fontFamily,
lang,
ttsSyncStatus = 'idle',
estimated = false,
ttsActive = false,
ttsPlaying = false,
ttsRate = 1,
onToggleTtsAudio,
onToggleTtsPlay,
onSetTtsRate,
onResumeTtsFollow,
onClose,
onChapterSelect,
onRequestNextPage,
@@ -118,12 +154,23 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
}) => {
const _ = useTranslation();
const { themeCode, isDarkMode: _isDarkMode } = useThemeStore();
const isSettingsDialogOpen = useSettingsStore((s) => s.isSettingsDialogOpen);
const [state, setState] = useState<RsvpState>(controller.currentState);
const currentWord = controller.currentDisplayWord;
// The transport (center) play/pause controls TTS while read-along is engaged,
// otherwise RSVP's own timer (#3235). A ref keeps the latest closure so the
// capture-phase keyboard/tap effects don't need it in their dep arrays.
const transportToggleRef = useRef<() => void>(() => {});
transportToggleRef.current = () => {
if (ttsActive && onToggleTtsPlay) onToggleTtsPlay();
else controller.togglePlayPause();
};
const transportPlaying = ttsActive ? ttsPlaying : state.playing;
const [countdown, setCountdown] = useState<number | null>(controller.currentCountdown);
const [showChapterDropdown, setShowChapterDropdown] = useState(false);
const chapterDropdownRef = useRef<HTMLDivElement>(null);
const [showWpmDropdown, setShowWpmDropdown] = useState(false);
const [showRateDropdown, setShowRateDropdown] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [contextCollapsed, setContextCollapsed] = useState(() => {
try {
@@ -236,12 +283,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
// While the dictionary is open it owns the keyboard (e.g. Escape closes
// the dictionary, not the whole RSVP session).
if (dict) return;
// Dictionary management (settings dialog) opens OVER RSVP; let it own the
// keyboard so its inputs accept space and Escape closes it, not RSVP.
if (isSettingsDialogOpen) return;
switch (event.key) {
case ' ':
event.preventDefault();
event.stopPropagation();
controller.togglePlayPause();
transportToggleRef.current();
break;
case 'Escape':
event.preventDefault();
@@ -292,7 +342,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
// Use capture phase to handle events before they reach dropdown/select elements
document.addEventListener('keydown', handleKeyboard, { capture: true });
return () => document.removeEventListener('keydown', handleKeyboard, { capture: true });
}, [state.active, controller, onClose, dict]);
}, [state.active, controller, onClose, dict, isSettingsDialogOpen]);
const effectiveChapterHref = currentChapterHref;
@@ -452,7 +502,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
} else if (tapX > screenWidth * 0.75) {
controller.skipForward(15);
} else {
controller.togglePlayPause();
transportToggleRef.current();
}
}
};
@@ -620,6 +670,13 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
const currentFontSize =
FONT_SIZE_OPTIONS[fontSizeIndex] ?? FONT_SIZE_OPTIONS[DEFAULT_FONT_SIZE_INDEX]!;
// The WPM timer doesn't drive pacing while RSVP follows TTS — the voice does.
// Replace the WPM control with an "Audio pace" affordance that opens a TTS
// rate picker instead (decision 6, #3235).
// 'paused' keeps the WPM "Audio pace" lock too, so pausing doesn't shift layout.
const ttsDriven =
ttsSyncStatus === 'following' || ttsSyncStatus === 'syncing' || ttsSyncStatus === 'paused';
return (
<div
data-testid='rsvp-overlay'
@@ -697,31 +754,57 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
)}
</div>
{/* WPM selector */}
{/* WPM selector — while RSVP follows TTS the timer no longer paces, so it
becomes an "Audio pace" affordance that opens a TTS rate picker
instead (decision 6). aria-disabled (not hard-disabled) keeps it
focusable as a hint; the lock glyph + border reads in e-ink without
relying on opacity. */}
<div className='relative shrink-0'>
<button
className='flex items-center gap-1 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-gray-500/20'
onClick={() => setShowWpmDropdown(!showWpmDropdown)}
aria-label={_('Select reading speed')}
title={_('Select reading speed')}
>
<span className='font-semibold'>{state.wpm}</span>
<span className='ml-0.5 text-xs opacity-50'>WPM</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ml-0.5 h-3 w-3 shrink-0 opacity-50'
{ttsDriven ? (
<button
className='eink-bordered flex items-center gap-1.5 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm transition-colors hover:bg-gray-500/20'
onClick={() => setShowRateDropdown(!showRateDropdown)}
aria-disabled='true'
aria-label={_('Audio pace')}
title={_('Speed follows audio')}
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
{showWpmDropdown && (
<IoLockClosed className='h-3.5 w-3.5 shrink-0 opacity-70' aria-hidden='true' />
<span className='font-medium'>{_('Audio pace')}</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ms-0.5 h-3 w-3 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
) : (
<button
className='flex items-center gap-1 rounded-full border border-gray-500/20 bg-gray-500/10 px-3 py-1.5 text-sm tabular-nums transition-colors hover:bg-gray-500/20'
onClick={() => setShowWpmDropdown(!showWpmDropdown)}
aria-label={_('Select reading speed')}
title={_('Select reading speed')}
>
<span className='font-semibold'>{state.wpm}</span>
<span className='ms-0.5 text-xs opacity-50'>WPM</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='ms-0.5 h-3 w-3 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
)}
{showWpmDropdown && !ttsDriven && (
<>
<Overlay onDismiss={() => setShowWpmDropdown(false)} />
<div
className='absolute right-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
className='absolute end-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{controller.getWpmOptions().map((wpm) => (
@@ -744,9 +827,53 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</div>
</>
)}
{showRateDropdown && ttsDriven && (
<>
<Overlay onDismiss={() => setShowRateDropdown(false)} />
<div
className='absolute end-0 top-full z-[100] mt-1.5 max-h-64 min-w-[7rem] overflow-y-auto rounded-2xl border border-gray-500/20 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{TTS_RATE_OPTIONS.map((rate) => (
<button
key={rate}
className={clsx(
'flex w-full items-center justify-between gap-3 whitespace-nowrap rounded-md border-none bg-transparent px-4 py-1.5 text-sm tabular-nums transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
Math.abs(ttsRate - rate) < 0.001 &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
)}
onClick={() => {
onSetTtsRate?.(rate);
setShowRateDropdown(false);
}}
>
<span>{rate.toFixed(2)}×</span>
</button>
))}
</div>
</>
)}
</div>
</div>
{/* TTS "following audio" status row — slim, below the header and above the
context panel (never inside the transport row). Uses the 'plain' variant
to match the overlay's own theme-painted surface. idle/unsupported
collapse to nothing. */}
{(ttsSyncStatus === 'following' ||
ttsSyncStatus === 'syncing' ||
ttsSyncStatus === 'decoupled' ||
ttsSyncStatus === 'paused') && (
<div className='flex shrink-0 justify-center px-3 pb-1 md:px-4'>
<TTSFollowIndicator
status={ttsSyncStatus}
estimated={estimated}
onResume={onResumeTtsFollow}
variant='plain'
/>
</div>
)}
{/* Context panel (always visible, collapsible) */}
<div className='mx-3 overflow-hidden rounded-lg border border-gray-500/20 bg-gray-500/10 md:mx-4 md:rounded-xl'>
<button
@@ -974,15 +1101,15 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</button>
<button
aria-label={state.playing ? _('Pause') : _('Play')}
aria-label={transportPlaying ? _('Pause') : _('Play')}
className={clsx(
'flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
state.playing ? '' : 'ps-1',
transportPlaying ? '' : 'ps-1',
)}
onClick={() => controller.togglePlayPause()}
title={state.playing ? _('Pause (Space)') : _('Play (Space)')}
onClick={() => transportToggleRef.current()}
title={transportPlaying ? _('Pause (Space)') : _('Play (Space)')}
>
{state.playing ? (
{transportPlaying ? (
<IoPause className='h-7 w-7 md:h-8 md:w-8' />
) : (
<IoPlay className='h-7 w-7 md:h-8 md:w-8' />
@@ -1017,17 +1144,48 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<span className='text-xs font-semibold opacity-80'>15</span>
</button>
<button
aria-label={_('Settings')}
className={clsx(
'absolute right-0 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
{/* Trailing cluster: audio (TTS) toggle + divider + settings gear.
The audio toggle starts TTS from the displayed word (or stops it
when engaged) — never a second play triangle (decision 5). Active
state uses a filled glyph + eink-bordered surface so it reads in
e-ink without relying on color. */}
<div className='absolute end-0 flex items-center gap-1'>
<button
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
className={clsx(
'touch-target flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95',
ttsActive
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
: 'bg-transparent hover:bg-gray-500/20',
)}
onClick={() => onToggleTtsAudio?.()}
title={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh
className='h-4 w-4 md:h-5 md:w-5'
style={{ color: accentColor }}
aria-hidden='true'
/>
) : (
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
)}
</button>
<span className='h-5 w-px bg-gray-500/30' aria-hidden='true' />
<button
aria-label={_('Settings')}
className={clsx(
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
</div>
</div>
{/* Settings row (collapsible) */}
@@ -0,0 +1,45 @@
import { RsvpWord } from '@/services/rsvp';
// Detail payload for the app-bus `tts-speak` event (see useTTSControl.handleTTSSpeak,
// which honors a passed `range` + `index`). RSVP starts audio from the displayed
// word so the listener and the flashing word stay aligned.
export interface RsvpTtsSpeakDetail {
bookKey: string;
// Section spine index of the displayed word — starts TTS in the right section.
index?: number;
// Live DOM range of the displayed word — starts TTS at the exact word. Omitted
// when the word has no range or the range is stale (its document no longer
// matches the current content), so TTS falls back to its own start position.
range?: Range;
}
// Build the `tts-speak` detail for "start audio from the current RSVP word"
// (decision 5, #3235). Returns null when there is no current word to align to.
//
// Start-alignment rules:
// - index = the word's spine index (when known), so audio begins in the
// displayed section even if the range can't be used.
// - range is included ONLY when it is live: it exists and its ownerDocument is
// the document RSVP is currently rendering (`currentDoc`). A stale or
// cross-document range would resolve to the wrong place, so it is dropped and
// TTS falls back to its own start position.
export const buildRsvpTtsSpeakDetail = (
currentWord: RsvpWord | null | undefined,
bookKey: string,
currentDoc: Document | null | undefined,
): RsvpTtsSpeakDetail | null => {
if (!currentWord) return null;
const detail: RsvpTtsSpeakDetail = { bookKey };
if (typeof currentWord.docIndex === 'number') {
detail.index = currentWord.docIndex;
}
const range = currentWord.range;
if (range && currentDoc && range.startContainer.ownerDocument === currentDoc) {
detail.range = range;
}
return detail;
};
@@ -0,0 +1,117 @@
'use client';
import React from 'react';
import clsx from 'clsx';
import { IoVolumeHigh, IoSync } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
// The derived states both reader modes expose (slice 5, #3235). idle and
// unsupported render nothing — we never nag, especially not on fixed-layout.
// 'paused' keeps the pill visible while TTS is engaged but paused (no layout
// shift) — it renders like 'following'.
export type TtsSyncStatus =
| 'idle'
| 'following'
| 'syncing'
| 'decoupled'
| 'paused'
| 'unsupported';
interface TTSFollowIndicatorProps {
status: TtsSyncStatus;
/**
* RSVP non-Edge sentence-level following is paced by an estimator, not exact
* word marks, so the label is qualified with " · estimated".
*/
estimated?: boolean;
/** Re-engage following. Only wired through on the decoupled (action) state. */
onResume?: () => void;
/**
* Surface idiom. 'base' (default) uses daisyui base tokens — for the paragraph
* overlay, where the global [data-eink] rules apply. 'plain' uses the neutral
* gray-500 overlay + currentColor text to match the RSVP overlay's own
* theme-painted surface (the same idiom as its chapter/WPM pills and "Look up"
* pill), where daisyui base tokens would clash with the book theme.
*/
variant?: 'base' | 'plain';
className?: string;
}
// Shared pill chassis. eink-bordered is applied unconditionally: in eink mode it
// swaps to bg-base-100 + a 1px base-content border, and the RSVP overlay paints
// its own theme surface where the global [data-eink] rules don't auto-apply
// (mirrors the RSVP "Look up" pill). Glyph + text together so the meaning never
// rests on color alone — required to read in e-ink monochrome.
const PILL_BASE =
'eink-bordered inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium leading-none whitespace-nowrap';
// Per-variant fills. 'plain' inherits text color (currentColor) so it reads on
// any book theme the RSVP overlay paints.
const STATUS_FILL: Record<NonNullable<TTSFollowIndicatorProps['variant']>, string> = {
base: 'bg-base-content/10 text-base-content',
plain: 'bg-gray-500/15',
};
const ACTION_FILL: Record<NonNullable<TTSFollowIndicatorProps['variant']>, string> = {
base: 'bg-base-200 text-base-content transition-colors hover:bg-base-300',
plain: 'bg-gray-500/20 transition-colors hover:bg-gray-500/30',
};
const TTSFollowIndicator: React.FC<TTSFollowIndicatorProps> = ({
status,
estimated = false,
onResume,
variant = 'base',
className,
}) => {
const _ = useTranslation();
// Never nag: idle has nothing to follow, unsupported (fixed-layout) can never
// engage. Both render null rather than a placeholder.
if (status === 'idle' || status === 'unsupported') return null;
if (status === 'decoupled') {
// The only actionable state — the whole pill is the button. touch-target
// extends the hit area to 44px on mobile without growing the rendered pill.
return (
<button
type='button'
aria-label={_('Resume audio')}
onClick={onResume}
className={clsx(
PILL_BASE,
'touch-target',
ACTION_FILL[variant],
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
className,
)}
>
<IoSync className='h-3.5 w-3.5 shrink-0' aria-hidden='true' />
<span>{_('Resume audio')}</span>
</button>
);
}
// following / syncing: a filled, non-interactive status pill. syncing adds the
// loading-dots affordance while a cross-section re-extract is in flight.
return (
<div
role='status'
aria-live='polite'
className={clsx(PILL_BASE, STATUS_FILL[variant], className)}
>
{status === 'syncing' ? (
<span
className='loading loading-dots loading-xs shrink-0'
aria-hidden='true'
data-testid='tts-follow-syncing'
/>
) : (
<IoVolumeHigh className='h-3.5 w-3.5 shrink-0' aria-hidden='true' />
)}
<span>{_('Following audio')}</span>
{estimated && <span className='not-eink:opacity-70'>{_(' · estimated')}</span>}
</div>
);
};
export default TTSFollowIndicator;