Files
readest/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx
T
Huang Xin bfb85c2f68 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>
2026-06-14 09:46:18 +02:00

1370 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import clsx from 'clsx';
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,
IoPlay,
IoPause,
IoPlaySkipBack,
IoPlaySkipForward,
IoCaretBack,
IoCaretForward,
IoRemove,
IoAdd,
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;
href: string;
level: number;
}
interface ContextWordProps {
text: string;
wordIndex: number;
isCurrent: boolean;
currentRef?: React.Ref<HTMLSpanElement>;
orpColor?: string;
}
const ContextWord = React.memo(function ContextWord({
text,
wordIndex,
isCurrent,
currentRef,
orpColor,
}: ContextWordProps) {
return (
<span
ref={currentRef}
data-rsvp-word-button=''
data-rsvp-word-index={wordIndex}
role={isCurrent ? undefined : 'button'}
tabIndex={isCurrent ? undefined : 0}
className={isCurrent ? undefined : 'cursor-pointer opacity-70 hover:opacity-100'}
style={isCurrent && orpColor ? { color: orpColor } : undefined}
>
{text}{' '}
</span>
);
});
// Display settings
const FONT_SIZE_OPTIONS = [1.25, 1.5, 1.875, 2.25, 3, 3.75, 4.25, 5, 6, 8];
const DEFAULT_FONT_SIZE_INDEX = 4;
const ORP_COLOR_OPTIONS = ['', '#EF4444', '#3B82F6', '#22C55E', '#F97316', '#A855F7'];
const STORAGE_KEY_FONT_SIZE = 'readest_rsvp_fontsize';
const STORAGE_KEY_ORP_COLOR = 'readest_rsvp_orp_color';
const STORAGE_KEY_CONTEXT = 'readest_rsvp_context';
const STORAGE_KEY_HIGHLIGHT_WORD = 'readest_rsvp_cjk_highlight_word';
// Context panel windowing — long sections (e.g. AZW3 chapters with 40k+ words)
// would otherwise render tens of thousands of <span> elements and freeze the UI
// for many seconds on each section load.
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;
const DICT_POPUP_MAX_HEIGHT = 360;
interface RSVPOverlayProps {
gridInsets: Insets;
controller: RSVPController;
chapters: TOCItem[];
currentChapterHref: string | null;
/**
* Resolved CSS font-family for the displayed word, mirroring the reader's
* font face/family settings. When undefined, the word keeps the monospace
* fallback. See getBaseFontFamily in utils/style.
*/
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;
/** Opens the dictionary management settings from the lookup header gear. */
onManageDictionary?: () => void;
}
const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
gridInsets,
controller,
chapters,
currentChapterHref,
fontFamily,
lang,
ttsSyncStatus = 'idle',
estimated = false,
ttsActive = false,
ttsPlaying = false,
ttsRate = 1,
onToggleTtsAudio,
onToggleTtsPlay,
onSetTtsRate,
onResumeTtsFollow,
onClose,
onChapterSelect,
onRequestNextPage,
onManageDictionary,
}) => {
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 {
return localStorage.getItem(STORAGE_KEY_CONTEXT) === '1';
} catch {
return false;
}
});
const [fontSizeIndex, setFontSizeIndex] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_FONT_SIZE);
if (saved !== null) {
const idx = parseInt(saved, 10);
if (idx >= 0 && idx < FONT_SIZE_OPTIONS.length) return idx;
}
} catch {
/* ignore */
}
return DEFAULT_FONT_SIZE_INDEX;
});
const [orpColorIndex, setOrpColorIndex] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_ORP_COLOR);
if (saved !== null) {
const idx = parseInt(saved, 10);
if (idx >= 0 && idx < ORP_COLOR_OPTIONS.length) return idx;
}
} catch {
/* ignore */
}
return 0;
});
const [highlightWholeWord, setHighlightWholeWord] = useState(() => {
try {
return localStorage.getItem(STORAGE_KEY_HIGHLIGHT_WORD) === '1';
} catch {
return false;
}
});
const contextWordRef = useRef<HTMLSpanElement>(null);
const contextPanelRef = useRef<HTMLDivElement>(null);
// Dictionary lookup from a context-panel selection (#4475). `lookup` is the
// pending selection (drives the "Look up" pill); `dict` holds the resolved
// word + popup placement once the dictionary is open.
const [lookup, setLookup] = useState<{
text: string;
range: Range;
left: number;
top: number;
} | null>(null);
const [dict, setDict] = useState<{
word: string;
position: Position;
trianglePosition: Position;
} | null>(null);
const touchStartX = useRef(0);
const touchStartY = useRef(0);
const touchStartTime = useRef(0);
const isDraggingProgressBar = useRef(false);
const wasPlayingBeforeDrag = useRef(false);
const [isProgressBarDragging, setIsProgressBarDragging] = useState(false);
const SWIPE_THRESHOLD = 50;
const TAP_THRESHOLD = 10;
// Flatten chapters for dropdown
const flatChapters = useMemo(() => {
const flatten = (items: TOCItem[], level = 0): FlatChapter[] => {
const result: FlatChapter[] = [];
for (const item of items) {
result.push({ label: item.label || '', href: item.href || '', level });
if (item.subitems?.length) {
result.push(...flatten(item.subitems, level + 1));
}
}
return result;
};
return flatten(chapters);
}, [chapters]);
// Subscribe to controller events
useEffect(() => {
const handleStateChange = (e: Event) => {
const newState = (e as CustomEvent<RsvpState>).detail;
setState(newState);
};
const handleCountdownChange = (e: Event) => {
setCountdown((e as CustomEvent<number | null>).detail);
};
const handleRequestNextPage = () => {
onRequestNextPage();
};
controller.addEventListener('rsvp-state-change', handleStateChange);
controller.addEventListener('rsvp-countdown-change', handleCountdownChange);
controller.addEventListener('rsvp-request-next-page', handleRequestNextPage);
return () => {
controller.removeEventListener('rsvp-state-change', handleStateChange);
controller.removeEventListener('rsvp-countdown-change', handleCountdownChange);
controller.removeEventListener('rsvp-request-next-page', handleRequestNextPage);
};
}, [controller, onRequestNextPage]);
// Keyboard shortcuts - use capture phase to intercept before native elements
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
if (!state.active) return;
// 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();
transportToggleRef.current();
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
onClose();
break;
case 'ArrowLeft':
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) {
controller.skipBackward(15);
} else {
controller.decreaseSpeed();
}
break;
case 'ArrowRight':
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) {
controller.skipForward(15);
} else {
controller.increaseSpeed();
}
break;
case 'ArrowUp':
event.preventDefault();
event.stopPropagation();
controller.increaseSpeed();
break;
case 'ArrowDown':
event.preventDefault();
event.stopPropagation();
controller.decreaseSpeed();
break;
case '.':
event.preventDefault();
event.stopPropagation();
controller.nextWord();
break;
case ',':
event.preventDefault();
event.stopPropagation();
controller.prevWord();
break;
}
};
// 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, isSettingsDialogOpen]);
const effectiveChapterHref = currentChapterHref;
// Word display helpers
const wordBefore = currentWord ? currentWord.text.substring(0, currentWord.orpIndex) : '';
const orpChar = currentWord ? currentWord.text.charAt(currentWord.orpIndex) : '';
const wordAfter = currentWord ? currentWord.text.substring(currentWord.orpIndex + 1) : '';
const isCJKWord = currentWord ? containsCJK(currentWord.text) : false;
const wordLetterSpacing = undefined;
const wordSideOffset = isCJKWord ? '0.45em' : '0.3em';
// Time remaining calculation
const getTimeRemaining = useCallback((): string | null => {
if (!state || state.words.length === 0) return null;
const wordsLeft = state.words.length - state.currentIndex;
const minutesLeft = wordsLeft / state.wpm;
if (minutesLeft < 1) {
const seconds = Math.ceil(minutesLeft * 60);
return `${seconds}s`;
} else if (minutesLeft < 60) {
const mins = Math.floor(minutesLeft);
const secs = Math.round((minutesLeft - mins) * 60);
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
} else {
const hours = Math.floor(minutesLeft / 60);
const mins = Math.round(minutesLeft % 60);
return `${hours}h ${mins}m`;
}
}, [state]);
// Auto-scroll: keep highlighted word in view. Suppressed while the user is
// selecting text or has the dictionary open, so the panel does not yank the
// selection out from under them (#4475).
useEffect(() => {
if (contextCollapsed || lookup || dict) return;
contextWordRef.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
}, [state.currentIndex, contextCollapsed, lookup, dict]);
useEffect(() => {
if (!showChapterDropdown) return;
const raf = requestAnimationFrame(() => {
const container = chapterDropdownRef.current;
if (!container) return;
const activeItem = container.querySelector<HTMLElement>('[data-active="true"]');
if (activeItem) {
activeItem.scrollIntoView({ block: 'center' });
}
});
return () => cancelAnimationFrame(raf);
}, [showChapterDropdown]);
const toggleContext = useCallback(() => {
setContextCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(STORAGE_KEY_CONTEXT, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}, []);
const updateFontSize = useCallback((idx: number) => {
const clamped = Math.max(0, Math.min(FONT_SIZE_OPTIONS.length - 1, idx));
setFontSizeIndex(clamped);
try {
localStorage.setItem(STORAGE_KEY_FONT_SIZE, String(clamped));
} catch {
/* ignore */
}
}, []);
const updateOrpColor = useCallback((idx: number) => {
setOrpColorIndex(idx);
try {
localStorage.setItem(STORAGE_KEY_ORP_COLOR, String(idx));
} catch {
/* ignore */
}
}, []);
const updateHighlightWholeWord = useCallback((value: boolean) => {
setHighlightWholeWord(value);
try {
localStorage.setItem(STORAGE_KEY_HIGHLIGHT_WORD, value ? '1' : '0');
} catch {
/* ignore */
}
}, []);
// Chapter helpers
const getCurrentChapterLabel = useCallback((): string => {
if (!effectiveChapterHref) return _('Select Chapter');
const exactMatch = flatChapters.find((c) => c.href === effectiveChapterHref);
if (exactMatch) return exactMatch.label;
const normalizedCurrent = effectiveChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const chapter = flatChapters.find((c) => {
const normalizedHref = c.href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
});
return chapter?.label || _('Select Chapter');
}, [_, effectiveChapterHref, flatChapters]);
const isChapterActive = useCallback(
(href: string): boolean => {
if (!effectiveChapterHref) return false;
if (href === effectiveChapterHref) return true;
const normalizedCurrent = effectiveChapterHref.split('#')[0]?.replace(/^\//, '') || '';
const normalizedHref = href.split('#')[0]?.replace(/^\//, '') || '';
return normalizedHref === normalizedCurrent;
},
[effectiveChapterHref],
);
// Touch handlers
const handleTouchStart = (event: React.TouchEvent) => {
if (event.touches.length !== 1) return;
const touch = event.touches[0]!;
touchStartX.current = touch.clientX;
touchStartY.current = touch.clientY;
touchStartTime.current = Date.now();
};
const handleTouchEnd = (event: React.TouchEvent) => {
if (event.changedTouches.length !== 1) return;
// Touches starting on the header or footer controls (progress bar, buttons,
// dropdowns) own their own gestures — never let a horizontal drag here be
// hijacked as a speed-change swipe, or a tap as a region tap.
const target = event.target as HTMLElement;
if (target.closest('.rsvp-controls') || target.closest('.rsvp-header')) {
return;
}
const touch = event.changedTouches[0]!;
const deltaX = touch.clientX - touchStartX.current;
const deltaY = touch.clientY - touchStartY.current;
const duration = Date.now() - touchStartTime.current;
if (Math.abs(deltaX) > SWIPE_THRESHOLD && Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > 0) {
controller.decreaseSpeed();
} else {
controller.increaseSpeed();
}
return;
}
if (Math.abs(deltaX) < TAP_THRESHOLD && Math.abs(deltaY) < TAP_THRESHOLD && duration < 300) {
const screenWidth = window.innerWidth;
const tapX = touch.clientX;
if (tapX < screenWidth * 0.25) {
controller.skipBackward(15);
} else if (tapX > screenWidth * 0.75) {
controller.skipForward(15);
} else {
transportToggleRef.current();
}
}
};
const handleWordClick = useCallback(
(wordIndex: number) => {
const wasPlaying = state.playing;
if (wasPlaying) controller.pause();
controller.seekToIndex(wordIndex);
if (wasPlaying) setTimeout(() => controller.resume(), 50);
},
[state.playing, controller],
);
const handleContextClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
// A drag that selects text also ends in a click; don't seek then, so the
// user can select words for dictionary lookup (#4475).
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.toString().trim()) return;
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
if (!target) return;
if (target.getAttribute('role') !== 'button') return;
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
if (Number.isNaN(idx)) return;
handleWordClick(idx);
},
[handleWordClick],
);
// Detect a selection inside the context panel and surface a "Look up" pill.
const handleContextSelection = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
setLookup(null);
return;
}
const text = selection.toString().trim();
const anchor = selection.anchorNode;
if (!text || !anchor || !contextPanelRef.current?.contains(anchor)) {
setLookup(null);
return;
}
// Clone the range so the placement survives the selection being collapsed
// when the user taps the "Look up" pill.
const range = selection.getRangeAt(0).cloneRange();
const rect = range.getBoundingClientRect();
const left = Math.min(window.innerWidth - 8, Math.max(8, rect.left + rect.width / 2));
setLookup({ text, range, left, top: rect.top });
}, []);
const openLookup = useCallback(() => {
if (!lookup) return;
if (state.playing) controller.pause();
// Anchor the popup to the selection: prefer below it, flip above when the
// lower half of the screen is too short. The whole-window rect keeps the
// popup clamped on-screen (the overlay root sits at the viewport origin).
const rect = lookup.range.getBoundingClientRect();
const windowRect = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight };
const popupWidth = Math.min(DICT_POPUP_MAX_WIDTH, window.innerWidth - 2 * DICT_POPUP_PADDING);
const popupHeight = Math.min(
DICT_POPUP_MAX_HEIGHT,
window.innerHeight - 2 * DICT_POPUP_PADDING,
);
const dir: Position['dir'] =
window.innerHeight - rect.bottom > popupHeight + DICT_POPUP_PADDING ? 'down' : 'up';
const trianglePosition: Position = {
point: { x: rect.left + rect.width / 2, y: dir === 'down' ? rect.bottom + 6 : rect.top - 12 },
dir,
};
const position = getPopupPosition(
trianglePosition,
windowRect,
popupWidth,
popupHeight,
DICT_POPUP_PADDING,
);
setDict({ word: lookup.text, position, trianglePosition });
setLookup(null);
}, [lookup, state.playing, controller]);
const closeLookup = useCallback(() => {
setDict(null);
try {
window.getSelection()?.removeAllRanges();
} catch {
/* ignore */
}
}, []);
const handleContextKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const target = (event.target as HTMLElement).closest<HTMLElement>('[data-rsvp-word-index]');
if (!target) return;
if (target.getAttribute('role') !== 'button') return;
const idx = parseInt(target.getAttribute('data-rsvp-word-index') || '', 10);
if (Number.isNaN(idx)) return;
event.preventDefault();
handleWordClick(idx);
},
[handleWordClick],
);
const contextWindow = useMemo(() => {
const len = state.words.length;
if (len === 0) return { start: 0, end: 0 };
const chunkStart = Math.floor(state.currentIndex / CONTEXT_CHUNK_SIZE) * CONTEXT_CHUNK_SIZE;
const start = Math.max(0, chunkStart - CONTEXT_WINDOW_BEFORE);
const end = Math.min(len, chunkStart + CONTEXT_CHUNK_SIZE + CONTEXT_WINDOW_AFTER);
return { start, end };
}, [state.currentIndex, state.words.length]);
const hasMoreBefore = contextWindow.start > 0;
const hasMoreAfter = contextWindow.end < state.words.length;
const getProgressBarPercentage = (clientX: number, target: HTMLElement): number => {
const rect = target.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
return (x / rect.width) * 100;
};
const handleProgressBarPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
isDraggingProgressBar.current = true;
setIsProgressBarDragging(true);
wasPlayingBeforeDrag.current = state.playing;
if (state.playing) controller.pause();
controller.seekToPosition(getProgressBarPercentage(event.clientX, event.currentTarget));
};
const handleProgressBarPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingProgressBar.current) return;
controller.seekToPosition(getProgressBarPercentage(event.clientX, event.currentTarget));
};
const handleProgressBarPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingProgressBar.current) return;
isDraggingProgressBar.current = false;
setIsProgressBarDragging(false);
// pointercancel can fire after the browser has already released the
// capture itself (e.g. multitouch, app backgrounding), so calling
// releasePointerCapture unconditionally would throw NotFoundError.
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (wasPlayingBeforeDrag.current) setTimeout(() => controller.resume(), 50);
};
const handleChapterSelect = (href: string) => {
setShowChapterDropdown(false);
controller.pause();
onChapterSelect(href);
};
if (!state.active) return null;
// Use theme colors directly from themeCode (bg, fg, primary are already resolved from palette)
const bgColor = themeCode.bg;
const fgColor = themeCode.fg;
const accentColor = themeCode.primary;
const effectiveOrpColor = ORP_COLOR_OPTIONS[orpColorIndex] || accentColor;
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'
aria-label={_('Speed Reading')}
className='fixed inset-0 z-[10000] flex select-none flex-col'
style={{
paddingTop: `${gridInsets.top}px`,
paddingBottom: `${gridInsets.bottom * 0.33}px`,
backgroundColor: bgColor,
color: fgColor,
backdropFilter: 'none',
// @ts-expect-error CSS custom properties
'--rsvp-accent': accentColor,
'--rsvp-fg': fgColor,
'--rsvp-bg': bgColor,
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* ── Header ── */}
<div className='rsvp-header flex shrink-0 items-center gap-2 px-3 py-2 md:gap-3 md:px-5 md:py-3'>
<button
aria-label={_('Close Speed Reading')}
title={_('Close')}
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-gray-500/20'
onClick={onClose}
>
<IoClose className='h-5 w-5' />
</button>
{/* Chapter selector */}
<div className='relative min-w-0 flex-1'>
<button
className='flex w-full 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={() => setShowChapterDropdown(!showChapterDropdown)}
>
<span className='min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left'>
{getCurrentChapterLabel()}
</span>
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
className='h-3.5 w-3.5 shrink-0 opacity-50'
>
<path d='M6 9l6 6 6-6' />
</svg>
</button>
{showChapterDropdown && (
<>
<Overlay onDismiss={() => setShowChapterDropdown(false)} />
<div
ref={chapterDropdownRef}
className='absolute left-0 right-0 top-full z-[100] mt-1.5 max-h-64 overflow-y-auto rounded-2xl border border-gray-500/20 px-2 shadow-2xl'
style={{ backgroundColor: bgColor }}
>
{flatChapters.map((chapter, idx) => (
<button
key={`${chapter.href}-${idx}`}
data-active={isChapterActive(chapter.href) ? 'true' : undefined}
className={clsx(
'block w-full rounded-md border-none bg-transparent px-4 py-2.5 text-left text-sm transition-colors first:rounded-t-2xl last:rounded-b-2xl hover:bg-gray-500/15',
isChapterActive(chapter.href) &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
)}
style={{ paddingLeft: `${1 + chapter.level * 0.875}rem` }}
onClick={() => handleChapterSelect(chapter.href)}
>
{chapter.label}
</button>
))}
</div>
</>
)}
</div>
{/* 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'>
{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')}
>
<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 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) => (
<button
key={wpm}
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',
state.wpm === wpm &&
'bg-[color-mix(in_srgb,var(--rsvp-accent)_15%,transparent)] font-semibold',
)}
onClick={() => {
controller.setWpm(wpm);
setShowWpmDropdown(false);
}}
>
<span>{wpm}</span>
<span className='text-xs opacity-40'>WPM</span>
</button>
))}
</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
className='flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide opacity-60 transition-opacity hover:opacity-80 md:px-4 md:py-3'
onClick={toggleContext}
aria-expanded={!contextCollapsed}
aria-label={contextCollapsed ? _('Show context') : _('Hide context')}
>
<svg
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='md:h-4 md:w-4'
>
<path d='M4 6h16M4 12h16M4 18h10' />
</svg>
<span className='flex-1 text-left'>{_('Context')}</span>
<IoChevronDown
className={clsx(
'h-3.5 w-3.5 transition-transform duration-200',
!contextCollapsed && 'rotate-180',
)}
/>
</button>
{!contextCollapsed && (
<div
className='max-h-[20vh] overflow-y-auto px-3 pb-3 md:px-4 md:pb-4'
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div
ref={contextPanelRef}
data-testid='rsvp-context-panel'
className='select-text text-left text-base leading-relaxed md:text-lg'
onClick={handleContextClick}
onKeyDown={handleContextKeyDown}
onMouseUp={handleContextSelection}
onTouchEnd={handleContextSelection}
>
{hasMoreBefore && <span className='opacity-30'> </span>}
{state.words.slice(contextWindow.start, contextWindow.end).map((w, i) => {
const wordIndex = contextWindow.start + i;
const isCurrent = wordIndex === state.currentIndex;
return (
<ContextWord
key={wordIndex}
text={w.text}
wordIndex={wordIndex}
isCurrent={isCurrent}
currentRef={isCurrent ? contextWordRef : undefined}
orpColor={isCurrent ? effectiveOrpColor : undefined}
/>
);
})}
{hasMoreAfter && <span className='opacity-30'></span>}
</div>
</div>
)}
</div>
{/* Main content area */}
<div className='flex flex-1 flex-col items-center justify-center p-4 md:p-6'>
<div className='flex h-full w-full flex-col items-center justify-center'>
<div className='flex h-full w-full flex-col items-center'>
{/* Top guide line */}
<div className='w-px flex-1 bg-current opacity-30' />
{/* Word section */}
<div className='flex flex-col items-center justify-center'>
{/* Countdown */}
{countdown !== null && (
<div className='mb-2 flex items-center justify-center'>
<span
className='animate-pulse text-5xl font-bold sm:text-6xl md:text-7xl'
style={{ color: accentColor }}
>
{countdown}
</span>
</div>
)}
{/* Word display */}
<div
className={clsx(
'rsvp-word relative flex min-h-16 w-full items-center justify-center whitespace-nowrap px-2 py-2 font-medium leading-none tracking-wide sm:min-h-20 sm:px-4 sm:py-4',
// Fall back to a fixed-width font only when the reader has no
// configured font face/family to apply.
!fontFamily && 'font-mono',
)}
style={{
fontSize: `${currentFontSize}rem`,
letterSpacing: wordLetterSpacing,
fontFamily,
}}
>
{currentWord ? (
isCJKWord && highlightWholeWord ? (
// Whole-word mode: center the full CJK word and color every
// character, instead of anchoring a single focus character.
<span
className='rsvp-word-whole relative z-10 font-bold'
style={{ color: effectiveOrpColor }}
>
{currentWord.text}
</span>
) : (
<>
<span
className='rsvp-word-before absolute text-right opacity-60'
style={{ right: `calc(50% + ${wordSideOffset})` }}
>
{wordBefore}
</span>
<span
className='rsvp-word-orp relative z-10 font-bold'
style={{ color: effectiveOrpColor }}
>
{orpChar}
</span>
<span
className='rsvp-word-after absolute text-left opacity-60'
style={{ left: `calc(50% + ${wordSideOffset})` }}
>
{wordAfter}
</span>
</>
)
) : (
<span className='italic opacity-30'>{_('Ready')}</span>
)}
</div>
</div>
{/* Bottom guide line */}
<div className='w-px flex-1 bg-current opacity-30' />
</div>
</div>
</div>
{/* Footer */}
<div className='rsvp-controls shrink-0 px-3 pb-6 pt-3 md:px-4 md:pb-8 md:pt-4'>
{/* Progress section */}
<div className='mb-3 flex flex-col gap-1.5 md:mb-4 md:gap-2'>
<div className='flex flex-col gap-1 text-xs sm:flex-row sm:items-center sm:justify-between'>
<span className='font-semibold uppercase tracking-wide opacity-70'>
{_('Chapter Progress')}
</span>
<span className='tabular-nums opacity-60'>
{(state.currentIndex + 1).toLocaleString()} / {state.words.length.toLocaleString()}{' '}
{_('words')}
{getTimeRemaining() && (
<span className='opacity-80'>
{' '}
· {_('{{time}} left', { time: getTimeRemaining() })}
</span>
)}
</span>
</div>
<div
role='slider'
tabIndex={0}
aria-label={_('Reading progress')}
aria-valuenow={Math.round(state.progress)}
aria-valuemin={0}
aria-valuemax={100}
className='relative h-2 cursor-pointer overflow-visible rounded bg-gray-500/30'
// touch-action: none keeps mobile browsers from claiming the
// gesture for scroll/pan, which would fire pointercancel and
// break the drag-to-seek pointer capture mid-gesture.
style={{ touchAction: 'none' }}
onPointerDown={handleProgressBarPointerDown}
onPointerMove={handleProgressBarPointerMove}
onPointerUp={handleProgressBarPointerUp}
onPointerCancel={handleProgressBarPointerUp}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'ArrowLeft') controller.skipBackward();
else if (e.key === 'ArrowRight') controller.skipForward();
}}
title={_('Drag to seek')}
>
<div
className={`absolute left-0 top-0 h-full rounded ${isProgressBarDragging ? '' : 'transition-[width] duration-100'}`}
style={{ width: `${state.progress}%`, backgroundColor: accentColor }}
/>
<div
className={`absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full shadow ${isProgressBarDragging ? '' : 'transition-[left] duration-100'}`}
style={{ left: `${state.progress}%`, backgroundColor: accentColor }}
/>
</div>
</div>
{/* Playback controls */}
<div className='relative flex items-center justify-center gap-1 md:gap-2'>
<button
aria-label={_('Skip back 15 words')}
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => controller.skipBackward(15)}
title={_('Back 15 words (Shift+Left)')}
>
<span className='text-xs font-semibold opacity-80'>15</span>
<IoPlaySkipBack className='h-5 w-5 md:h-6 md:w-6' />
</button>
<button
aria-label={_('Decrease speed')}
className='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'
onClick={() => controller.decreaseSpeed()}
title={_('Slower (Left/Down)')}
>
<IoRemove className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={_('Previous word')}
className='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'
onClick={() => controller.prevWord()}
title={_('Previous word (,)')}
>
<IoCaretBack className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
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',
transportPlaying ? '' : 'ps-1',
)}
onClick={() => transportToggleRef.current()}
title={transportPlaying ? _('Pause (Space)') : _('Play (Space)')}
>
{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' />
)}
</button>
<button
aria-label={_('Next word')}
className='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'
onClick={() => controller.nextWord()}
title={_('Next word (.)')}
>
<IoCaretForward className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={_('Increase speed')}
className='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'
onClick={() => controller.increaseSpeed()}
title={_('Faster (Right/Up)')}
>
<IoAdd className='h-4 w-4 md:h-5 md:w-5' />
</button>
<button
aria-label={_('Skip forward 15 words')}
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => controller.skipForward(15)}
title={_('Forward 15 words (Shift+Right)')}
>
<IoPlaySkipForward className='h-5 w-5 md:h-6 md:w-6' />
<span className='text-xs font-semibold opacity-80'>15</span>
</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) */}
{showSettings && (
<div className='mt-3 flex flex-wrap items-center justify-evenly gap-x-8 gap-y-4 text-xs md:justify-center'>
{/* Punctuation pause */}
<label className='flex cursor-pointer items-center gap-1.5 font-medium opacity-80'>
<span className='mr-0.5 font-medium opacity-50'>{_('Punctuation Delay')}</span>
<select
className='cursor-pointer rounded border border-gray-500/30 bg-gray-500/20 px-1.5 py-1 text-xs font-medium transition-colors hover:border-gray-500/40 hover:bg-gray-500/30'
style={{ color: 'inherit' }}
value={state.punctuationPauseMs}
onChange={(e) => controller.setPunctuationPause(parseInt(e.target.value, 10))}
>
{controller.getPunctuationPauseOptions().map((option) => (
<option key={option} value={option}>
{option}ms
</option>
))}
</select>
</label>
{/* Pre-start countdown delay */}
<label className='flex cursor-pointer items-center gap-1.5 font-medium opacity-80'>
<span className='mr-0.5 font-medium opacity-50'>{_('Start Delay')}</span>
<select
data-testid='rsvp-start-delay-select'
className='cursor-pointer rounded border border-gray-500/30 bg-gray-500/20 px-1.5 py-1 text-xs font-medium transition-colors hover:border-gray-500/40 hover:bg-gray-500/30'
style={{ color: 'inherit' }}
value={state.startDelaySeconds}
onChange={(e) => controller.setStartDelay(parseInt(e.target.value, 10))}
>
{controller.getStartDelayOptions().map((option) => (
<option key={option} value={option}>
{option === 0 ? _('Off') : `${option}s`}
</option>
))}
</select>
</label>
{/* Font size */}
<div className='flex items-center gap-0.5'>
<span className='mr-0.5 font-medium opacity-50'>{_('Font')}</span>
<button
aria-label={_('Decrease font size')}
className='flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => updateFontSize(fontSizeIndex - 1)}
disabled={fontSizeIndex <= 0}
>
<IoRemove className='h-3 w-3' />
</button>
<span className='min-w-4 text-center font-medium tabular-nums'>
{fontSizeIndex + 1}
</span>
<button
aria-label={_('Increase font size')}
className='flex h-6 w-6 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
onClick={() => updateFontSize(fontSizeIndex + 1)}
disabled={fontSizeIndex >= FONT_SIZE_OPTIONS.length - 1}
>
<IoAdd className='h-3 w-3' />
</button>
</div>
{/* Split hyphenated words */}
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Split Hyphens')}</span>
<input
type='checkbox'
className='toggle'
checked={state.splitHyphens}
onChange={(e) => controller.setSplitHyphens(e.target.checked)}
/>
</div>
{/* CJK character mode — split CJK text per-character */}
{state.hasCJK && (
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Character Mode')}</span>
<input
type='checkbox'
data-testid='rsvp-char-mode-toggle'
className='toggle'
checked={state.cjkCharMode}
onChange={(e) => controller.setCjkCharMode(e.target.checked)}
/>
</div>
)}
{/* CJK whole-word highlight — color and center the full word */}
{state.hasCJK && (
<div className='config-item gap-2'>
<span className='opacity-50'>{_('Highlight Word')}</span>
<input
type='checkbox'
data-testid='rsvp-highlight-word-toggle'
className='toggle'
checked={highlightWholeWord}
onChange={(e) => updateHighlightWholeWord(e.target.checked)}
/>
</div>
)}
{/* ORP color */}
<div className='flex items-center gap-1.5'>
<span className='mr-0.5 font-medium opacity-50'>{_('Focus')}</span>
{ORP_COLOR_OPTIONS.map((color, idx) => (
<button
key={idx}
onClick={() => updateOrpColor(idx)}
className={clsx(
'h-6 min-h-6 w-6 min-w-6 rounded-full border-2 transition-transform',
orpColorIndex === idx
? 'scale-110 border-current'
: 'border-transparent hover:scale-105',
)}
style={{ backgroundColor: color || accentColor }}
aria-label={idx === 0 ? _('Theme color') : `Color ${idx}`}
title={idx === 0 ? _('Theme color') : undefined}
/>
))}
</div>
</div>
)}
</div>
{/* Dictionary lookup from a context selection (#4475) */}
{lookup && (
<button
aria-label={_('Look up')}
className='eink-bordered fixed z-[10001] flex -translate-x-1/2 -translate-y-full items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold shadow-lg'
style={{
left: `${lookup.left}px`,
top: `${lookup.top}px`,
backgroundColor: accentColor,
color: bgColor,
}}
onClick={openLookup}
>
<IoSearch className='h-4 w-4' />
{_('Look up')}
</button>
)}
{dict &&
// Below `sm` (or short landscape) present a bottom sheet; otherwise an
// anchored popup — mirroring the reader's selection dictionary.
(window.innerWidth < 640 || window.innerHeight < 640 ? (
<DictionarySheet
word={dict.word}
lang={lang}
onDismiss={closeLookup}
onManage={onManageDictionary}
/>
) : (
// Transparent full-screen catcher so a click outside the popup
// dismisses it (the popup container sits above it at z-50).
<>
<Overlay onDismiss={closeLookup} />
<DictionaryPopup
word={dict.word}
lang={lang}
position={dict.position}
trianglePosition={dict.trianglePosition}
popupWidth={Math.min(
DICT_POPUP_MAX_WIDTH,
window.innerWidth - 2 * DICT_POPUP_PADDING,
)}
popupHeight={Math.min(
DICT_POPUP_MAX_HEIGHT,
window.innerHeight - 2 * DICT_POPUP_PADDING,
)}
onDismiss={closeLookup}
onManage={onManageDictionary}
/>
</>
))}
</div>
);
};
export default RSVPOverlay;