/** * Text-to-Speech (TTS) Context Provider * * This module provides a React context for managing text-to-speech functionality. * It handles audio playback, sentence processing, and integration with OpenAI's TTS API. * * Key features: * - Audio playback control (play/pause/skip) * - Sentence-by-sentence processing * - Audio caching for better performance * - Voice and speed control * - Document navigation */ 'use client'; import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, ReactNode, ReactElement } from 'react'; import { Howl } from 'howler'; import toast from 'react-hot-toast'; import { useParams, usePathname } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/client/dexie'; import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client/api/user-state'; import { withRetry, ensureTtsSegments } from '@/lib/client/api/audiobooks'; import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; import { buildSegmentKeyPrefix, planCanonicalTtsSegments, type CanonicalTtsSegment, type CanonicalTtsSourceUnit, } from '@/lib/shared/tts-segment-plan'; import { buildWalkerPlanningSourceUnits, selectUpcomingWalkerItems, } from '@/lib/client/epub/tts-epub-preload'; import { releaseWarmAudio, upsertWarmAudioEntry, type WarmAudioCacheEntry, } from '@/lib/client/tts/audio-warm-cache'; import { isRetryableSegmentStatus, resolveSegmentStatusRetryDelayMs, shouldDeferSegmentRetry, } from '@/lib/client/tts/segment-retry-policy'; import { completedEpubBoundarySegment, resolveEpubBoundaryHandoffStartIndex, resolveEpubReplaySuppressionAction, type CompletedEpubBoundarySegment, } from '@/lib/client/epub/tts-epub-handoff'; import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsLanguage } from '@/lib/shared/language'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { EpubRenderedLocationWalker, TTSLocation, TTSPageTurnEstimate, TTSPlaybackState, TTSSentenceAlignment, } from '@/types/tts'; import type { TTSRequestHeaders, TTSSegmentInput, TTSSegmentLocator, TTSRetryOptions, TTSSegmentManifestItem, } from '@/types/client'; import { isStableEpubLocator } from '@/types/client'; /** * Resolves an EPUB segment's draft locator (typically `{ readerType: 'epub', * location: }`) into a stable book coordinate. The resolver lives in * the route-local EPUB reader hook where the live `Book` instance is available; TTSContext calls it * just before posting segments to the server so what gets persisted is * viewport-independent. Returns null when the CFI can't be resolved. */ export type EpubLocatorResolver = ( draft: TTSSegmentLocator, segmentText: string, options?: { segmentIndex: number; segmentKey?: string | null; keyPrefix: string; maxBlockLength: number; }, ) => Promise<{ locator: TTSSegmentLocator; segmentKey?: string | null; segmentIndex?: number; text?: string; } | null>; import type { ReaderType } from '@/types/user-state'; import { clampSegmentPreloadDepth, clampSegmentPreloadSentenceLookahead, } from '@/types/config'; // Media globals declare global { interface Window { webkitAudioContext: typeof AudioContext; } } /** * Interface defining all available methods and properties in the TTS context */ interface TTSContextType extends TTSPlaybackState { // Voice settings voice: string; availableVoices: string[]; // Sentence/segment list and cursor (for the segments sidebar) sentences: string[]; currentSentenceIndex: number; // Alignment metadata for the current sentence currentSentenceAlignment?: TTSSentenceAlignment; currentWordIndex?: number | null; // Control functions togglePlay: () => void; skipForward: () => void; skipBackward: () => void; pause: () => void; stop: () => void; stopAndPlayFromIndex: (index: number) => void; playFromSegment: (index: number, locator?: TTSSegmentLocator | null) => void; setText: (text: string, options?: boolean | SetTextOptions) => void; setCurrDocPages: (num: number | undefined) => void; setSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; documentLanguage: string; resolvedLanguage: string; setDocumentLanguage: (language: string) => void; clearSegmentCaches: () => void; skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void; registerEpubLocatorResolver: (resolver: EpubLocatorResolver | null) => void; // EPUB-only: resolves CFI drafts to stable spine coords before persist registerVisualPageChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; setIsEPUB: (isEPUB: boolean) => void; /** Effective reader type used to mint segmentKeys (see buildSegmentKeyPrefix). */ activeReaderType: ReaderType; } interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; sourceUnits?: CanonicalTtsSourceUnit[]; previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; nextSourceUnits?: CanonicalTtsSourceUnit[]; previousText?: string; upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>; /** * EPUB canonical path: pre-windowed segments for the current page, sliced * from the chapter's single canonical plan (stable key + global `ordinal`). * When present, setText uses them verbatim and skips preview-based planning. */ canonicalSegments?: CanonicalTtsSegment[]; /** Spine identity of `canonicalSegments`, for the ordinal-continuity gate. */ canonicalSpine?: { spineHref: string; spineIndex: number }; /** Canonical segments for the next page, staged for the hidden-tab fast path. */ canonicalNextSegments?: CanonicalTtsSegment[]; } type TTSSegmentPlaybackSource = { presignUrl: string; fallbackUrl: string; manifest: TTSSegmentManifestItem; }; type TTSPendingJumpTarget = { locationKey: string; index: number; }; type EpubLocationPreloadCandidate = { sentence: string; segmentKey: string; segmentIndex: number; location: string; requestKey: string; cacheKey: string; }; type PersistResolution = { segments: TTSSegmentInput[]; sourceIndices: number[]; }; type JumpResolutionInput = { isEPUB: boolean; newSentenceCount: number; resolvedLocationKey: string; pendingEpubJump: { index: number; epoch: number } | null; currentEpubEpoch: number; pendingStrictJump: { locationKey: string; index: number } | null; }; type JumpResolution = | { kind: 'epub-resolved'; index: number } | { kind: 'strict-resolved'; index: number } | { kind: 'fresh' }; const LOOP_GUARD_MIN_INDEX = 2; const LOOP_GUARD_MIN_PROGRESS = 0.6; const AUDIO_CACHE_MAX_ITEMS = 25; const WARM_AUDIO_CACHE_MAX_ITEMS = 6; // Read once per module load from SSR-injected runtime config. This sits at // module scope because the highlight pipeline is constructed lazily and the // flag rarely changes within a session — admin toggling it picks up on // reload, matching the SSR-injection model. const wordHighlightFeatureEnabled = (() => { if (typeof window === 'undefined') return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any const injected = (window as any).__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return true; return typeof injected.computeAvailable === 'boolean' ? injected.computeAvailable : true; })(); // Tiny silent WAV used to unlock HTML5 audio on iOS/Safari. const SILENT_WAV_DATA_URI = 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; const normalizeLocationKey = normalizeTtsLocationKey; const normalizeBlockFingerprint = (text: string): string => { const normalized = preprocessSentenceForAudio(text) .normalize('NFKC') .toLowerCase() .replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ') .replace(/\s+/g, ' ') .trim(); return normalized.slice(0, 200); }; const buildCacheKey = ( sentence: string, voice: string, speed: number, provider: string, model: string, providerType: string, instructions: string, language: string, ) => { return [ `provider=${provider || ''}`, `providerType=${providerType || ''}`, `model=${model || ''}`, `voice=${voice || ''}`, `speed=${Number.isFinite(speed) ? speed : ''}`, `instructions=${instructions || ''}`, `language=${language || ''}`, `text=${sentence}`, ].join('|'); }; const buildScopedSegmentCacheKey = ( locator: TTSSegmentLocator, segmentIndex: number, sentence: string, voice: string, speed: number, provider: string, model: string, providerType: string, instructions: string, language: string, segmentKey?: string | null, ) => { return [ buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions, language), `segmentKey=${segmentKey || ''}`, `locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`, `segmentIndex=${segmentKey ? '' : segmentIndex}`, ].join('|'); }; const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => { // Stable EPUB locators: use spine identity + charOffset for a unique, // viewport-independent cache key. Falling through to `locator.location` // would yield the empty string for the new shape and collide across // segments. if (locator.readerType === 'epub') { if ( typeof locator.spineHref === 'string' && typeof locator.spineIndex === 'number' && typeof locator.charOffset === 'number' ) { return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; } if (typeof locator.cfi === 'string' && locator.cfi) { return normalizeLocationKey(locator.cfi); } } if (typeof locator.location === 'string' && locator.location) { return normalizeLocationKey(locator.location); } if (locator.readerType === 'pdf') { const page = Number(locator.page || 1); const block = typeof locator.blockId === 'string' && locator.blockId ? locator.blockId : ''; return normalizeLocationKey(`pdf:${page}:${block}`); } return normalizeLocationKey(Number(locator.page || 1)); }; const buildSegmentRequestKey = ( locator: TTSSegmentLocator, segmentIndex: number, sentence: string, segmentKey?: string | null, ): string => { return segmentKey ? `${segmentKey}::${sentence}` : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; }; const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { if (input.newSentenceCount <= 0) { return { kind: 'fresh' }; } const clamp = (raw: number): number => Math.max(0, Math.min(raw, input.newSentenceCount - 1)); if ( input.isEPUB && input.pendingEpubJump && input.pendingEpubJump.epoch === input.currentEpubEpoch ) { return { kind: 'epub-resolved', index: clamp(input.pendingEpubJump.index) }; } if ( input.pendingStrictJump && input.pendingStrictJump.locationKey === input.resolvedLocationKey ) { return { kind: 'strict-resolved', index: clamp(input.pendingStrictJump.index) }; } return { kind: 'fresh' }; }; /** * Resolve the local start index for an EPUB canonical-window page using ordinal * continuity. The window's segments carry chapter-global `ordinal`s; we begin at * the first one past the highest ordinal already spoken in this same spine item. * * Only trims on a *forward, contiguous* turn — when the window overlaps or sits * just after what we've played. A backward turn or a non-contiguous jump returns * 0 (play the whole window from the top); a different spine item returns 0. */ const resolveCanonicalStartIndex = ( segments: CanonicalTtsSegment[], spine: { spineHref: string; spineIndex: number } | null, lastPlayed: { spineHref: string; spineIndex: number; ordinal: number } | null, ): number => { if (segments.length === 0) return 0; if (!spine || !lastPlayed) return 0; if (lastPlayed.spineHref !== spine.spineHref || lastPlayed.spineIndex !== spine.spineIndex) return 0; const windowStart = segments[0].ordinal; const windowEnd = segments[segments.length - 1].ordinal; if (windowStart > lastPlayed.ordinal + 1) return 0; // jumped ahead → play whole window if (windowEnd < lastPlayed.ordinal) return 0; // moved backward → play whole window const idx = segments.findIndex((segment) => segment.ordinal > lastPlayed.ordinal); return idx < 0 ? segments.length : idx; }; const sourceKeyForLocation = (location: TTSLocation | undefined, fallback: TTSLocation): string => normalizeLocationKey(location ?? fallback); const locatorForLocation = ( location: TTSLocation, readerType: ReaderType, ): TTSSegmentLocator => { if (typeof location === 'string') { return { location, readerType }; } if (readerType === 'html') { return { location: String(location || 1), readerType }; } return { page: Math.max(1, Math.floor(Number(location || 1))), readerType }; }; // Create the context const TTSContext = createContext(undefined); /** * Main provider component that manages the TTS state and functionality. * Handles initialization of OpenAI client, audio context, and media session. * * @param {Object} props - Component props * @param {ReactNode} props.children - Child components to be wrapped by the provider * @returns {JSX.Element} TTSProvider component */ export function TTSProvider({ children }: { children: ReactNode }): ReactElement { // Configuration context consumption const { apiKey: openApiKey, baseUrl: openApiBaseUrl, isLoading: configIsLoading, voiceSpeed, audioPlayerSpeed, voice: configVoice, providerRef: configProviderRef, providerType: configProviderType, ttsModel: configTTSModel, ttsInstructions: configTTSInstructions, updateConfigKey, skipBlank, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, } = useConfig(); // Audio and voice management hooks const audioContext = useAudioContext(); const { availableVoices, fetchVoices } = useVoiceManagement( openApiKey, openApiBaseUrl, configProviderRef, configProviderType, configTTSModel, ); const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit, } = useAuthRateLimit(); // Get document ID and reader type from URL const { id } = useParams(); const pathname = usePathname(); const documentId = useMemo(() => { if (typeof id === 'string') return id; if (Array.isArray(id)) return id[0]; return ''; }, [id]); const currentReaderType: ReaderType = useMemo(() => { if (pathname.startsWith('/epub/')) return 'epub'; if (pathname.startsWith('/html/')) return 'html'; return 'pdf'; }, [pathname]); // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); const visualPageChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); /** * Registers a handler function for location changes in EPUB documents * This is only used for EPUB documents to handle chapter navigation * * @param {Function} handler - Function to handle location changes */ const registerLocationChangeHandler = useCallback((handler: ((location: TTSLocation) => void) | null) => { locationChangeHandlerRef.current = handler; }, []); const epubLocationWalkerRef = useRef(null); const registerEpubLocationWalker = useCallback((walker: EpubRenderedLocationWalker | null) => { epubLocationWalkerRef.current = walker; }, []); /** * Resolves a CFI + segment text into stable EPUB coordinates. Registered by * the route-local EPUB reader hook (which owns the live `Book` instance). Used at server-persist * time so the saved locator carries `spineHref`/`spineIndex`/`charOffset` * rather than the viewport-dependent CFI string. Returns null when the CFI * can't be resolved — callers should drop the segment from the persist * payload in that case to avoid writing legacy-shape locators. */ const epubLocatorResolverRef = useRef(null); const registerEpubLocatorResolver = useCallback((resolver: EpubLocatorResolver | null) => { epubLocatorResolverRef.current = resolver; }, []); /** * Walks the segment payload that's about to be POSTed to * /api/tts/segments/ensure and canonicalizes EPUB entries through the * registered resolver. This normalizes both draft and already-stable locators * to a spine-level canonical segment identity (text/key/index/locator). * Non-EPUB locators are untouched. * * Segments whose EPUB locators can't be resolved are DROPPED from the * payload — we'd rather persist nothing than persist a viewport-dependent * locator that will misbehave across devices and resizes. */ const resolveSegmentsForPersist = useCallback(async ( segments: TTSSegmentInput[], ): Promise => { const resolver = epubLocatorResolverRef.current; const out: TTSSegmentInput[] = []; const sourceIndices: number[] = []; for (let idx = 0; idx < segments.length; idx += 1) { const segment = segments[idx]; const locator = segment.locator; if (!locator || locator.readerType !== 'epub') { out.push(segment); sourceIndices.push(idx); continue; } if (!resolver) { // No book available to resolve — drop. This can happen during early // boot before the EPUB reader hook has mounted. continue; } const keyPrefix = buildSegmentKeyPrefix(documentId, 'epub'); try { const resolved = await resolver(locator, segment.text, { segmentIndex: segment.segmentIndex, segmentKey: segment.segmentKey ?? null, keyPrefix, maxBlockLength: ttsSegmentMaxBlockLength, }); if (resolved && isStableEpubLocator(resolved.locator)) { out.push({ ...segment, locator: resolved.locator, ...(typeof resolved.segmentKey === 'string' && resolved.segmentKey.trim() ? { segmentKey: resolved.segmentKey.trim() } : {}), ...(typeof resolved.segmentIndex === 'number' && Number.isFinite(resolved.segmentIndex) ? { segmentIndex: Math.max(0, Math.floor(resolved.segmentIndex)) } : {}), ...(typeof resolved.text === 'string' && resolved.text.trim() ? { text: resolved.text } : {}), }); sourceIndices.push(idx); } } catch (error) { console.warn('Failed to resolve EPUB locator; dropping segment', error); } } return { segments: out, sourceIndices, }; }, [documentId, ttsSegmentMaxBlockLength]); /** * Registers a handler function for visual page changes in EPUB documents * This is only used for EPUB documents to handle visual page navigation * * @param {Function} handler - Function to handle visual page changes */ const registerVisualPageChangeHandler = useCallback((handler: ((location: TTSLocation) => void) | null) => { visualPageChangeHandlerRef.current = handler; }, []); /** * State Management */ const [isPlaying, setIsPlaying] = useState(false); const [isEPUB, setIsEPUB] = useState(false); const [isProcessing, setIsProcessing] = useState(false); /** * Resolved reader type for segment planning. Mirrors the `activeReaderType` * used inside `setText`/preload paths so external consumers (e.g. the * sidebar) can compute identical `segmentKey`s from local text and match * them against persisted manifest rows by content identity. */ const activeReaderType: ReaderType = useMemo( () => (isEPUB ? 'epub' : currentReaderType), [isEPUB, currentReaderType], ); const [currDocPage, setCurrDocPage] = useState(1); const currDocPageNumber = (!isEPUB ? parseInt(currDocPage.toString()) : 1); // PDF uses numbers only const [currDocPages, setCurrDocPages] = useState(); const [sentences, setSentences] = useState([]); const [playbackSegments, setPlaybackSegments] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [activeHowl, setActiveHowl] = useState(null); const [speed, setSpeed] = useState(voiceSpeed); const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed); const [voice, setVoice] = useState(configVoice); const [ttsModel, setTTSModel] = useState(configTTSModel); const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions); const [documentLanguage, setDocumentLanguage] = useState('auto'); const resolvedLanguage = useMemo( () => resolveTtsLanguage({ configuredLanguage: documentLanguage, voice }), [documentLanguage, voice], ); const providerModelPolicy = useMemo( () => resolveTtsProviderModelPolicy({ providerRef: configProviderRef, providerType: configProviderType, model: ttsModel, }), [configProviderRef, configProviderType, ttsModel], ); const configModelPolicy = useMemo( () => resolveTtsProviderModelPolicy({ providerRef: configProviderRef, providerType: configProviderType, model: configTTSModel, }), [configProviderRef, configProviderType, configTTSModel], ); const effectiveNativeSpeed = useMemo( () => (providerModelPolicy.supportsNativeModelSpeed ? speed : 1), [providerModelPolicy.supportsNativeModelSpeed, speed], ); // Track pending preload requests const preloadRequests = useRef>>(new Map()); const segmentRetryCooldownRef = useRef>(new Map()); const warmAudioCacheRef = useRef>(new Map()); // Track active abort controllers for TTS requests const activeAbortControllers = useRef>(new Set()); // Synchronous guard to prevent duplicate playAudio calls from the main playback effect. // React state updates (isProcessing, activeHowl) are async, so between the effect firing // and those guards taking effect, the effect can re-fire and start duplicate playback. // This is especially problematic in Firefox where HTML5 Audio events can cause extra renders. const playbackInFlightRef = useRef(false); const playbackRunIdRef = useRef(0); const pendingJumpTargetRef = useRef(null); // EPUB-only jump resolution. epub.js navigation snaps CFIs to page-aligned // values, so the strict locationKey match in pendingJumpTargetRef misses on // cross-spine jumps. We instead bump an epoch on each playFromSegment call // and let the next setText with a matching epoch consume the jump. const pendingEpubJumpRef = useRef<{ index: number; epoch: number; charOffset?: number } | null>(null); const epubJumpEpochRef = useRef(0); const epubPreloadGenerationRef = useRef(0); const epubWalkInFlightRef = useRef>(new Set()); // Guard to coalesce rapid restarts and only resume the latest change const restartSeqRef = useRef(0); // Preserve autoplay intent across location changes. Some browsers can emit pause // events while we stop/unload between pages, which momentarily flips `isPlaying` // false and can prevent automatic resume on the next page. const resumeAfterLocationChangeRef = useRef(false); const pageTurnEstimateRef = useRef(null); const pageTurnTimeoutRef = useRef | null>(null); const pageFirstBlockFingerprintRef = useRef>(new Map()); const sentenceAlignmentCacheRef = useRef>(new Map()); const segmentManifestCacheRef = useRef>(new Map()); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState(); const [currentWordIndex, setCurrentWordIndex] = useState(null); const isPlayingRef = useRef(false); const pauseEpochRef = useRef(0); const pendingPlaybackStartRef = useRef(false); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); const plannedSegmentsByLocationRef = useRef>(new Map()); const currentSourceUnitRef = useRef(null); const currentSourceContextUnitsRef = useRef([]); const completedEpubBoundarySegmentRef = useRef(null); // Highest canonical segment ordinal already spoken in the current spine item. // Drives exact, viewport-independent page-turn handoff: the next page begins // at the first window segment whose ordinal exceeds this. Replaces fuzzy // fingerprint matching for within-chapter turns (the spine→spine handoff in // tts-epub-handoff.ts remains the fallback path's safety net). const lastPlayedCanonicalRef = useRef<{ spineHref: string; spineIndex: number; ordinal: number } | null>(null); const pendingNextLocationRef = useRef(undefined); const audioUnlockAttemptRef = useRef(0); // Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying // the desired rate while a sentence is playing. const rateWatchdogIntervalRef = useRef | null>(null); const clearRateWatchdog = useCallback(() => { if (!rateWatchdogIntervalRef.current) return; clearInterval(rateWatchdogIntervalRef.current); rateWatchdogIntervalRef.current = null; }, []); const clearWarmAudioCache = useCallback(() => { warmAudioCacheRef.current.forEach((entry) => { releaseWarmAudio(entry.audio); }); warmAudioCacheRef.current.clear(); }, []); const warmSegmentAudioUrl = useCallback((requestKey: string, primaryUrl: string | null, fallbackUrl: string | null) => { const candidateUrl = primaryUrl || fallbackUrl; if (!candidateUrl) return; if (typeof window === 'undefined' || typeof Audio === 'undefined') return; upsertWarmAudioEntry({ key: requestKey, url: candidateUrl, cache: warmAudioCacheRef.current, maxEntries: WARM_AUDIO_CACHE_MAX_ITEMS, createAudio: (url) => { const audio = new Audio(url); audio.preload = 'auto'; audio.load(); return audio; }, }); }, []); useEffect(() => () => { clearWarmAudioCache(); }, [clearWarmAudioCache]); const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => { if (!howl) return; try { howl.rate(audioSpeed); } catch { // ignore } // Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly. // This helps on browsers that reset playbackRate/defaultPlaybackRate. try { const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds; const node = sounds?.[0]?._node as unknown; if (node && typeof node === 'object') { const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number }; if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed; if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed; } } catch { // ignore } }, [audioSpeed]); const startRateWatchdog = useCallback((howl: Howl | null) => { if (!howl) return; clearRateWatchdog(); // Apply immediately + keep applying while playback is active. applyPlaybackRateToHowl(howl); rateWatchdogIntervalRef.current = setInterval(() => { applyPlaybackRateToHowl(howl); }, 250); }, [applyPlaybackRateToHowl, clearRateWatchdog]); const unlockPlaybackOnUserGesture = useCallback(() => { // Best-effort; safe to call multiple times. audioUnlockAttemptRef.current += 1; const attempt = audioUnlockAttemptRef.current; try { void audioContext?.resume(); } catch { // ignore } try { const el = new Audio(SILENT_WAV_DATA_URI); try { el.setAttribute('playsinline', 'true'); } catch { // ignore } el.preload = 'auto'; el.volume = 0; const p = el.play(); if (p && typeof (p as Promise).then === 'function') { void (p as Promise) .then(() => { if (audioUnlockAttemptRef.current !== attempt) return; try { el.pause(); el.currentTime = 0; } catch { // ignore } }) .catch(() => { // ignore }); } } catch { // ignore } }, [audioContext]); const setSegmentManifestCache = useCallback((key: string, item: TTSSegmentManifestItem) => { const cache = segmentManifestCacheRef.current; if (cache.has(key)) { cache.delete(key); } cache.set(key, item); while (cache.size > AUDIO_CACHE_MAX_ITEMS) { const oldestKey = cache.keys().next().value; if (typeof oldestKey !== 'string') break; cache.delete(oldestKey); } }, []); const cacheCompletedManifestForCandidate = useCallback(( cacheKey: string, segment: TTSSegmentManifestItem, alignmentEnabledForCurrentDoc: boolean, ): boolean => { if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) { return false; } setSegmentManifestCache(cacheKey, segment); if (alignmentEnabledForCurrentDoc && segment.alignment) { sentenceAlignmentCacheRef.current.set(cacheKey, segment.alignment); } return true; }, [setSegmentManifestCache]); const setSegmentRetryCooldown = useCallback((cacheKey: string, delayMs: number) => { const now = Date.now(); const retryAtMs = now + Math.max(0, delayMs); const cooldowns = segmentRetryCooldownRef.current; cooldowns.set(cacheKey, retryAtMs); while (cooldowns.size > AUDIO_CACHE_MAX_ITEMS) { const oldestKey = cooldowns.keys().next().value; if (typeof oldestKey !== 'string') break; cooldowns.delete(oldestKey); } }, []); const waitForRetryDelayWithAbort = useCallback((delayMs: number, signal: AbortSignal): Promise => { if (!Number.isFinite(delayMs) || delayMs <= 0) return Promise.resolve(); if (signal.aborted) { return Promise.reject(new DOMException('Aborted', 'AbortError')); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, delayMs); const onAbort = () => { clearTimeout(timeout); signal.removeEventListener('abort', onAbort); reject(new DOMException('Aborted', 'AbortError')); }; signal.addEventListener('abort', onAbort, { once: true }); }); }, []); const invalidatePlaybackRun = useCallback(() => { playbackRunIdRef.current += 1; playbackInFlightRef.current = false; epubPreloadGenerationRef.current += 1; epubWalkInFlightRef.current.clear(); }, []); const bumpEpubPreloadGeneration = useCallback(() => { epubPreloadGenerationRef.current += 1; epubWalkInFlightRef.current.clear(); }, []); const clearPendingEpubJump = useCallback(() => { pendingEpubJumpRef.current = null; epubJumpEpochRef.current += 1; }, []); const isAutoplayBlockedError = useCallback((err: unknown) => { const msg = (() => { if (typeof err === 'string') return err; if (err instanceof Error) return err.message; if (typeof err === 'object' && err !== null && 'message' in err) { const maybe = (err as { message?: unknown }).message; if (typeof maybe === 'string') return maybe; } return ''; })(); return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg); }, []); const isAbortLikeError = useCallback((err: unknown): boolean => { if (err instanceof Error) { return err.name === 'AbortError' || /abort|cancel/i.test(err.message || ''); } if (typeof err === 'string') { return /abort|cancel/i.test(err); } if (typeof err === 'object' && err !== null && 'message' in err) { const maybe = (err as { message?: unknown }).message; if (typeof maybe === 'string') { return /abort|cancel/i.test(maybe); } } return false; }, []); useEffect(() => { sentencesRef.current = sentences; }, [sentences]); useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]); /** * Stops the current audio playback and clears the active Howl instance * @param {boolean} [clearPending=false] - Whether to clear pending requests */ const abortAudio = useCallback((clearPending = false) => { // Ensure next playback attempt is not blocked by a stale in-flight guard. invalidatePlaybackRun(); clearRateWatchdog(); if (activeHowl) { activeHowl.stop(); activeHowl.unload(); setActiveHowl(null); } if (clearPending) { activeAbortControllers.current.forEach(controller => { controller.abort(); }); activeAbortControllers.current.clear(); preloadRequests.current.clear(); segmentRetryCooldownRef.current.clear(); clearWarmAudioCache(); } if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } setCurrentWordIndex(null); }, [activeHowl, clearRateWatchdog, clearWarmAudioCache, invalidatePlaybackRun]); /** * Pauses the current audio playback while preserving seek position. */ const pauseActiveHowl = useCallback(() => { clearRateWatchdog(); if (activeHowl) { try { activeHowl.pause(); } catch (error) { console.warn('Error pausing audio:', error); } } if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } playbackInFlightRef.current = false; setIsProcessing(false); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'paused'; } }, [activeHowl, clearRateWatchdog]); const abortPendingTtsRequests = useCallback((clearPreloadRequests = false) => { activeAbortControllers.current.forEach((controller) => { controller.abort(); }); activeAbortControllers.current.clear(); if (clearPreloadRequests) { preloadRequests.current.clear(); } }, []); const recordManualPause = useCallback(() => { // Cancel any queued auto-resume intent and mark an explicit user pause. resumeAfterLocationChangeRef.current = false; pendingPlaybackStartRef.current = false; pauseEpochRef.current += 1; }, []); /** * Pauses the current audio playback * Used for external control of playback state */ const pause = useCallback(() => { pendingPlaybackStartRef.current = false; recordManualPause(); clearPendingEpubJump(); abortPendingTtsRequests(true); pauseActiveHowl(); setIsPlaying(false); }, [pauseActiveHowl, recordManualPause, clearPendingEpubJump, abortPendingTtsRequests]); /** * Navigates to a specific location in the document * Works for both PDF pages and EPUB locations * * @param {string | number} location - The target location to navigate to * @param {boolean} shouldPause - Whether to pause playback */ const skipToLocation = useCallback((location: TTSLocation, shouldPause = false) => { if (shouldPause) { resumeAfterLocationChangeRef.current = false; } else if (isPlayingRef.current) { resumeAfterLocationChangeRef.current = true; } // Reset state for new content in correct order invalidatePlaybackRun(); abortAudio(true); if (shouldPause) setIsPlaying(false); setCurrentIndex(0); setSentences([]); setPlaybackSegments([]); setCurrDocPage(location); }, [abortAudio, invalidatePlaybackRun]); /** * Moves to the next or previous sentence * * @param {boolean} [backwards=false] - Whether to move backwards */ const advance = useCallback(async (backwards = false) => { const nextIndex = currentIndex + (backwards ? -1 : 1); const movingForward = !backwards && nextIndex >= sentences.length; // Handle within current page bounds if (nextIndex < sentences.length && nextIndex >= 0) { if ( !isEPUB && !backwards && currDocPages !== undefined && currDocPageNumber < currDocPages && nextIndex >= LOOP_GUARD_MIN_INDEX && sentences.length > 0 ) { const locationKey = normalizeLocationKey(currDocPageNumber); const cachedFirstFingerprint = pageFirstBlockFingerprintRef.current.get(locationKey) ?? normalizeBlockFingerprint(sentences[0] || ''); const nextFingerprint = normalizeBlockFingerprint(sentences[nextIndex] || ''); const progress = sentences.length > 1 ? nextIndex / (sentences.length - 1) : 0; if ( cachedFirstFingerprint && nextFingerprint && cachedFirstFingerprint === nextFingerprint && progress >= LOOP_GUARD_MIN_PROGRESS ) { const targetLocation = currDocPageNumber + 1; plannedSegmentsByLocationRef.current.delete(normalizeLocationKey(targetLocation)); pendingNextLocationRef.current = targetLocation; skipToLocation(targetLocation); return; } } setCurrentIndex(nextIndex); return; } // For EPUB documents, always try to advance to next/prev section if (isEPUB && locationChangeHandlerRef.current) { if (movingForward && typeof document !== 'undefined' && document.hidden) { const targetLocation = pendingNextLocationRef.current; if (targetLocation !== undefined) { const bufferKey = normalizeLocationKey(targetLocation); const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey); if (prefetchedSegments?.length) { plannedSegmentsByLocationRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); setPlaybackSegments(prefetchedSegments); setSentences(prefetchedSegments.map((segment) => segment.text)); setCurrentIndex(0); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); // Ask the viewer to continue turning pages/sections; this may be deferred while hidden. locationChangeHandlerRef.current('next'); return; } } } const direction = nextIndex >= sentences.length ? 'next' : 'prev'; // EPUB navigation is asynchronous (rendition.next/prev → relocated → // skipToLocation → setText), unlike PDF which resets synchronously via // skipToLocation right here. Without clearing the just-finished page now, // an unrelated re-render during the async gap — e.g. setActiveHowl(null) // in onend changing playAudio's identity — re-fires the playback effect // against the *stale* last index and replays the final segment before the // next page loads. Reset synchronously to get PDF's deterministic handoff. if (isPlayingRef.current) { resumeAfterLocationChangeRef.current = true; } if (backwards) { // Backward navigation breaks forward ordinal continuity. lastPlayedCanonicalRef.current = null; } invalidatePlaybackRun(); setCurrentIndex(0); setSentences([]); setPlaybackSegments([]); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); locationChangeHandlerRef.current(direction); return; } // For PDFs and other documents, check page bounds if (!isEPUB) { // The HTML reader treats the entire document as a single page, so // `currDocPages` is left `undefined`. In that mode there's nowhere to // turn to — when we run past the last sentence, just stop playback. // (We do this before the page-bound checks below so the `< undefined` // / `>= undefined` NaN comparisons don't silently swallow the end.) if (currDocPages === undefined) { if (nextIndex >= sentences.length) { setIsPlaying(false); } return; } // Handle next/previous page transitions if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) || (nextIndex < 0 && currDocPageNumber > 1)) { const targetLocation = currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1); // In background tabs, page text extraction can be delayed. If we already have // prefetched text for the target page, keep speaking without waiting for viewer callbacks. if (movingForward && typeof document !== 'undefined' && document.hidden) { const bufferKey = normalizeLocationKey(targetLocation); const prefetchedSegments = plannedSegmentsByLocationRef.current.get(bufferKey); if (prefetchedSegments?.length) { plannedSegmentsByLocationRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); setPlaybackSegments(prefetchedSegments); setSentences(prefetchedSegments.map((segment) => segment.text)); setCurrentIndex(0); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); return; } } // Pass wasPlaying to maintain playback state during page turn skipToLocation(targetLocation); return; } // Handle end of document (PDF only) if (nextIndex >= sentences.length && currDocPageNumber >= currDocPages!) { setIsPlaying(false); } } }, [currentIndex, sentences, currDocPageNumber, currDocPages, isEPUB, skipToLocation, invalidatePlaybackRun]); /** * Handles blank text sections based on document type * * @param {string[]} sentences - Array of processed sentences * @returns {boolean} - True if blank section was handled */ const handleBlankSection = useCallback((text: string): boolean => { if (!isPlaying || !skipBlank || text.length > 0) { return false; } // Use advance to handle navigation for both EPUB and PDF advance(); toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, { id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`, style: { background: 'var(--background)', color: 'var(--accent)', }, duration: 1000, position: 'top-center', }); return true; }, [isPlaying, skipBlank, advance, isEPUB, currDocPageNumber]); /** * Sets the current text and splits it into sentences * * @param {string} text - The text to be processed */ const setText = useCallback((text: string, options?: boolean | SetTextOptions) => { const normalizedOptions: SetTextOptions = typeof options === 'boolean' ? { shouldPause: options } : (options || {}); const resolvedLocation = normalizedOptions.location !== undefined ? normalizedOptions.location : currDocPage; const resolvedLocationKey = normalizeLocationKey(resolvedLocation); const currentUnits = normalizedOptions.sourceUnits && normalizedOptions.sourceUnits.length > 0 ? normalizedOptions.sourceUnits : null; // Keep currDocPage aligned with whatever the caller declared as the viewport's // location. This is the canonical entry point for "the rendered page now shows // this content at this location" — the navigation flow (handleLocationChanged → // skipToLocation) already set it before calling setText, so this is a no-op // for next/prev/jump. The path that needs it is **resize**: EPUBViewer's // checkResize calls extractPageText directly (bypassing skipToLocation), so // without this, currDocPage would stay pinned to the pre-resize CFI even // though the page has repaginated to a new start CFI. if (normalizedOptions.location !== undefined && normalizedOptions.location !== currDocPage) { setCurrDocPage(normalizedOptions.location); } const currentSourceKey = sourceKeyForLocation(resolvedLocation, currDocPage); const currentSource: CanonicalTtsSourceUnit = { sourceKey: currentSourceKey, text, locator: locatorForLocation(resolvedLocation, activeReaderType), }; const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource]; const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey)); // EPUB canonical path: the caller already windowed this page out of the // chapter's single canonical plan, so we use those segments verbatim and // skip preview-based planning entirely. This is what makes a page-straddling // block identical (same key + global ordinal) on both pages. const useCanonicalEpub = isEPUB && Array.isArray(normalizedOptions.canonicalSegments) && normalizedOptions.canonicalSegments.length > 0; let currentSegments: CanonicalTtsSegment[]; let newSentences: string[]; if (useCanonicalEpub) { currentSegments = normalizedOptions.canonicalSegments!; newSentences = currentSegments.map((segment) => segment.text); plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; // Stage the next page's canonical segments for the hidden-tab fast path. if ( normalizedOptions.nextLocation !== undefined && normalizedOptions.canonicalNextSegments && normalizedOptions.canonicalNextSegments.length > 0 ) { plannedSegmentsByLocationRef.current.set( normalizeLocationKey(normalizedOptions.nextLocation), normalizedOptions.canonicalNextSegments, ); } // The walker reads these for preview fallback only; the canonical path // attaches its own per-chunk segments, so clear them. currentSourceUnitRef.current = null; currentSourceContextUnitsRef.current = []; } else { const contextSourceUnits: CanonicalTtsSourceUnit[] = []; if (normalizedOptions.previousText?.trim()) { const previousLocation = normalizedOptions.previousLocation; contextSourceUnits.push({ sourceKey: previousLocation !== undefined ? sourceKeyForLocation(previousLocation, currDocPage) : `previous:${currentSourceKey}`, text: normalizedOptions.previousText, locator: previousLocation !== undefined ? locatorForLocation(previousLocation, activeReaderType) : null, }); } contextSourceUnits.push(...effectiveCurrentUnits); const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; const pendingPrefetches: Array<{ location: TTSLocation; sourceUnits: CanonicalTtsSourceUnit[]; }> = []; if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; pendingPrefetches.push(provided.length > 0 ? { location: normalizedOptions.nextLocation, sourceUnits: provided, } : { location: normalizedOptions.nextLocation, sourceUnits: [{ sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), text: normalizedOptions.nextText, locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), }], }); } if (Array.isArray(normalizedOptions.upcomingLocations)) { for (const item of normalizedOptions.upcomingLocations) { if (item.location === undefined || !item.text?.trim()) continue; const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; pendingPrefetches.push(provided.length > 0 ? { location: item.location, sourceUnits: provided, } : { location: item.location, sourceUnits: [{ sourceKey: sourceKeyForLocation(item.location, currDocPage), text: item.text, locator: locatorForLocation(item.location, activeReaderType), }], }); } } for (const item of pendingPrefetches) { sourceUnits.push(...item.sourceUnits); } const plan = planCanonicalTtsSegments(sourceUnits, { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, language: resolvedLanguage, }); currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } } currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; currentSourceContextUnitsRef.current = contextSourceUnits; } if (handleBlankSection(newSentences.join(' '))) return; const shouldPause = normalizedOptions.shouldPause ?? false; const pauseEpochAtStart = pauseEpochRef.current; const pendingAutoResume = resumeAfterLocationChangeRef.current; const pendingPlaybackStart = pendingPlaybackStartRef.current; const shouldResumePlayback = !shouldPause && (isPlaying || pendingAutoResume || pendingPlaybackStart); // Keep track of previous state and pause playback invalidatePlaybackRun(); setIsPlaying(false); abortAudio(true); // Clear pending requests since text is changing setIsProcessing(true); // Set processing state before text processing starts try { if (newSentences.length === 0) { console.warn('No sentences found in text'); if (shouldPause || pendingAutoResume) { resumeAfterLocationChangeRef.current = false; } if (pendingPlaybackStart) { pendingPlaybackStartRef.current = false; } setIsProcessing(false); return; } if (shouldPause || pendingAutoResume) { resumeAfterLocationChangeRef.current = false; } if (pendingPlaybackStart) { pendingPlaybackStartRef.current = false; } if (!isEPUB && typeof resolvedLocation === 'number') { const firstFingerprint = normalizeBlockFingerprint(newSentences[0] || ''); if (firstFingerprint) { pageFirstBlockFingerprintRef.current.set( normalizeLocationKey(resolvedLocation), firstFingerprint ); } } setPlaybackSegments(currentSegments); setSentences(newSentences); const resolution = resolveJumpIndex({ isEPUB, newSentenceCount: newSentences.length, resolvedLocationKey, pendingEpubJump: pendingEpubJumpRef.current, currentEpubEpoch: epubJumpEpochRef.current, pendingStrictJump: pendingJumpTargetRef.current, }); let startIndex = 0; if (resolution.kind === 'epub-resolved') { startIndex = resolution.index; // Cross-page jump hardening: the raw index was computed against the // sidebar's view. On the canonical path, re-anchor to the segment whose // per-segment charOffset matches the jump target so chapters with // repeated text land exactly. const jump = pendingEpubJumpRef.current; if (useCanonicalEpub && jump && typeof jump.charOffset === 'number') { const mapped = currentSegments.findIndex( (segment) => segment.ownerLocator?.charOffset === jump.charOffset, ); if (mapped >= 0) startIndex = mapped; } setCurrentIndex(startIndex); pendingEpubJumpRef.current = null; pendingJumpTargetRef.current = null; } else if (resolution.kind === 'strict-resolved') { startIndex = resolution.index; setCurrentIndex(startIndex); pendingJumpTargetRef.current = null; } else { if (isEPUB) { clearPendingEpubJump(); } if (useCanonicalEpub && shouldResumePlayback) { // Exact ordinal handoff: start at the first window segment past the // highest ordinal already spoken in this chapter. startIndex = resolveCanonicalStartIndex( currentSegments, normalizedOptions.canonicalSpine ?? null, lastPlayedCanonicalRef.current, ); } else if (isEPUB && shouldResumePlayback) { startIndex = resolveEpubBoundaryHandoffStartIndex(currentSegments, completedEpubBoundarySegmentRef.current); if (startIndex > 0) { completedEpubBoundarySegmentRef.current = null; } } else { startIndex = 0; } setCurrentIndex(startIndex); } sentenceAlignmentCacheRef.current.clear(); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); if ( !isEPUB && normalizedOptions.nextLocation !== undefined && effectiveCurrentUnits.length === 1 ) { const spanningIndex = currentSegments.findIndex((segment) => segment.spansSourceBoundary && segment.startAnchor.sourceKey === currentSourceKey && segment.endAnchor.sourceKey !== currentSourceKey ); const spanningSegment = spanningIndex >= 0 ? currentSegments[spanningIndex] : null; const currentTextLength = preprocessSentenceForAudio(text).length; const totalLength = spanningSegment ? preprocessSentenceForAudio(spanningSegment.text).length : 0; const baseLength = spanningSegment ? Math.max(0, currentTextLength - spanningSegment.startAnchor.offset) : 0; const fraction = totalLength > 0 ? baseLength / totalLength : 0; pageTurnEstimateRef.current = spanningSegment && fraction > 0 && fraction < 1 ? { location: normalizedOptions.nextLocation, sentenceIndex: spanningIndex, fraction, } : null; } else { pageTurnEstimateRef.current = null; } setIsProcessing(false); if (shouldResumePlayback && startIndex >= newSentences.length) { setIsPlaying(false); return; } if (shouldResumePlayback && pauseEpochRef.current === pauseEpochAtStart) { setIsPlaying(true); } } catch (error) { console.warn('Error processing text:', error); setIsProcessing(false); toast.error('Failed to process text', { duration: 3000, }); } }, [ isPlaying, handleBlankSection, abortAudio, isEPUB, activeReaderType, invalidatePlaybackRun, currDocPage, documentId, ttsSegmentMaxBlockLength, resolvedLanguage, clearPendingEpubJump, ]); /** * Toggles the playback state between playing and paused */ const togglePlay = useCallback(() => { if (isPlaying) { recordManualPause(); clearPendingEpubJump(); abortPendingTtsRequests(true); pauseActiveHowl(); setIsPlaying(false); return; } // Ensure audio is unlocked while we're still in the click/tap handler. unlockPlaybackOnUserGesture(); const hasPreparedSentence = Boolean(playbackSegments[currentIndex]?.text ?? sentences[currentIndex]); if (!activeHowl && !hasPreparedSentence) { pendingPlaybackStartRef.current = true; setIsProcessing(true); return; } // Resume current sentence if we already have a paused Howl. if (activeHowl) { applyPlaybackRateToHowl(activeHowl); playbackInFlightRef.current = true; try { activeHowl.play(); setIsPlaying(true); return; } catch (error) { console.warn('Error resuming audio:', error); playbackInFlightRef.current = false; setActiveHowl(null); } } setIsPlaying(true); }, [ activeHowl, currentIndex, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, playbackSegments, recordManualPause, clearPendingEpubJump, abortPendingTtsRequests, sentences, unlockPlaybackOnUserGesture, ]); /** * Moves forward one sentence in the text */ const skipForward = useCallback(async () => { // Only show processing state if we're currently playing if (isPlaying) { setIsProcessing(true); } invalidatePlaybackRun(); abortAudio(true); await advance(); }, [isPlaying, abortAudio, advance, invalidatePlaybackRun]); /** * Moves backward one sentence in the text */ const skipBackward = useCallback(async () => { // Only show processing state if we're currently playing if (isPlaying) { setIsProcessing(true); } invalidatePlaybackRun(); abortAudio(true); await advance(true); }, [isPlaying, abortAudio, advance, invalidatePlaybackRun]); /** * Updates the voice and speed settings from the configuration */ const updateVoiceAndSpeed = useCallback(() => { setVoice(configVoice); setSpeed(voiceSpeed); setAudioSpeed(audioPlayerSpeed); }, [configVoice, voiceSpeed, audioPlayerSpeed]); /** * Initializes configuration and fetches available voices */ useEffect(() => { if (!configIsLoading) { fetchVoices(); updateVoiceAndSpeed(); setTTSModel(configTTSModel); setTTSInstructions(configTTSInstructions); } }, [configIsLoading, openApiKey, openApiBaseUrl, updateVoiceAndSpeed, fetchVoices, configTTSModel, configTTSInstructions]); const preloadGenerationSignatureRef = useRef(''); useEffect(() => { const signature = [ documentId, configProviderRef, ttsModel, voice, effectiveNativeSpeed, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, ttsSegmentMaxBlockLength, ].join('|'); if (!preloadGenerationSignatureRef.current) { preloadGenerationSignatureRef.current = signature; return; } if (preloadGenerationSignatureRef.current === signature) return; preloadGenerationSignatureRef.current = signature; clearPendingEpubJump(); bumpEpubPreloadGeneration(); // maxBlockLength/language changes re-shape the canonical plan (new ordinals), // so the previous handoff anchor is no longer comparable. lastPlayedCanonicalRef.current = null; }, [ documentId, configProviderRef, ttsModel, voice, effectiveNativeSpeed, providerModelPolicy.supportsInstructions, ttsInstructions, resolvedLanguage, ttsSegmentMaxBlockLength, clearPendingEpubJump, bumpEpubPreloadGeneration, ]); /** * Validates that the current voice is in the available voices list * If voice is empty or invalid, use the first available voice (only in local state, don't save) */ useEffect(() => { if (availableVoices.length > 0) { // Allow Kokoro multi-voice strings (e.g., "voice1(0.5)+voice2(0.5)") for any provider const isKokoro = configModelPolicy.isKokoroModel; const fallbackVoice = configVoice || availableVoices[0]; const providerUnresolved = !configModelPolicy.isResolvedProviderType; if (isKokoro && providerUnresolved && voice.includes('+')) { const firstVoice = voice.split('+')[0]?.replace(/\([^)]*\)/g, '').trim(); if (firstVoice) { setVoice(firstVoice); } return; } if (isKokoro) { // If Kokoro and we have any voice string (including plus/weights), don't override it. // Only default when local voice is empty. if (!voice) { setVoice(fallbackVoice); } return; } // For non-Kokoro, only force a fallback when there is no active local voice. // If a persisted config voice exists, keep it rather than overriding from a // potentially stale in-flight voices response during reload. if (!voice) { setVoice(fallbackVoice); return; } if (!configVoice && !availableVoices.includes(voice)) { setVoice(availableVoices[0]); // Don't save to config - just use it temporarily until user explicitly selects one } } }, [availableVoices, voice, configVoice, configModelPolicy]); const getSegmentPlaybackSource = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, locatorOverride?: TTSSegmentLocator, segmentKey?: string | null, ): Promise => { const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); const locator = locatorOverride || locatorForLocation( isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), currentReaderType, ); const audioCacheKey = buildScopedSegmentCacheKey( locator, sentenceIndex, sentence, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, segmentKey, ); const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); if (cachedManifest?.status === 'completed' && cachedManifest.audioPresignUrl && cachedManifest.audioFallbackUrl) { if (alignmentEnabledForCurrentDoc && cachedManifest.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, cachedManifest.alignment); } return { presignUrl: cachedManifest.audioPresignUrl, fallbackUrl: cachedManifest.audioFallbackUrl, manifest: cachedManifest, }; } if (!documentId) { if (!preload) setIsPlaying(false); return undefined; } if (isAtLimit) { if (!preload) setIsPlaying(false); return undefined; } if (preload) { const retryAtMs = segmentRetryCooldownRef.current.get(audioCacheKey); if (shouldDeferSegmentRetry(Date.now(), retryAtMs)) { return undefined; } } const controller = new AbortController(); activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; if (openApiBaseUrl) { reqHeaders['x-openai-base-url'] = openApiBaseUrl; } const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 300, maxDelay: 300, }; try { onTTSStart(); const maxStatusRetries = preload ? 1 : 3; let statusAttempt = 0; while (true) { const persistResult = await resolveSegmentsForPersist([ { segmentIndex: sentenceIndex, ...(segmentKey ? { segmentKey } : {}), text: sentence, locator, }, ]); const persistSegments = persistResult.segments; if (persistSegments.length === 0) { if (!preload) setIsPlaying(false); return undefined; } const ensured = await withRetry( async () => ensureTtsSegments({ documentId, settings: { providerRef: configProviderRef, providerType: configProviderType, ttsModel, voice, nativeSpeed: effectiveNativeSpeed, ...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}), language: resolvedLanguage, }, segments: persistSegments, }, reqHeaders, controller.signal), retryOptions, ); const segment = ensured.segments[0]; if (segment?.status === 'completed' && segment.audioPresignUrl && segment.audioFallbackUrl) { segmentRetryCooldownRef.current.delete(audioCacheKey); setSegmentManifestCache(audioCacheKey, segment); if (alignmentEnabledForCurrentDoc && segment.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, segment.alignment); } return { presignUrl: segment.audioPresignUrl, fallbackUrl: segment.audioFallbackUrl, manifest: segment, }; } const status = segment?.status ?? 'missing'; const retryAfterSeconds = typeof segment?.error?.retryAfterSeconds === 'number' ? segment.error.retryAfterSeconds : undefined; const delayMs = resolveSegmentStatusRetryDelayMs({ attempt: statusAttempt, retryAfterSeconds, }); const canRetryStatus = isRetryableSegmentStatus(status) && statusAttempt < maxStatusRetries; if (preload) { if (isRetryableSegmentStatus(status)) { setSegmentRetryCooldown(audioCacheKey, delayMs); } if (canRetryStatus) { statusAttempt += 1; await waitForRetryDelayWithAbort(delayMs, controller.signal); continue; } return undefined; } if (canRetryStatus) { statusAttempt += 1; await waitForRetryDelayWithAbort(delayMs, controller.signal); continue; } const detail = (() => { if (typeof segment?.error?.detail === 'string' && segment.error.detail.trim()) { return ` (${segment.error.detail})`; } return ''; })(); throw new Error(`Failed to prepare segment audio: ${status}${detail}`); } } catch (error) { if (isAbortLikeError(error)) { return undefined; } const status = (() => { if (typeof error === 'object' && error !== null && 'status' in error) { const maybe = (error as { status?: unknown }).status; return typeof maybe === 'number' ? maybe : undefined; } return undefined; })(); const code = (() => { if (typeof error === 'object' && error !== null && 'code' in error) { const maybe = (error as { code?: unknown }).code; return typeof maybe === 'string' ? maybe : undefined; } return undefined; })(); if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { if (!preload) { toast.error('Daily TTS limit reached.', { id: 'tts-limit-error', duration: 5000, }); } triggerRateLimit(); refreshRateLimit().catch(console.error); return undefined; } if (!preload) { setIsPlaying(false); toast.error('TTS failed. Skipped sentence and paused.', { id: 'tts-api-error', duration: 7000, }); } throw error; } finally { activeAbortControllers.current.delete(controller); onTTSComplete(); } }, [ voice, effectiveNativeSpeed, ttsModel, ttsInstructions, resolvedLanguage, openApiKey, openApiBaseUrl, configProviderRef, configProviderType, providerModelPolicy.supportsInstructions, isEPUB, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, onTTSComplete, onTTSStart, isAtLimit, documentId, refreshRateLimit, triggerRateLimit, currDocPage, currDocPageNumber, currentReaderType, setSegmentManifestCache, setSegmentRetryCooldown, waitForRetryDelayWithAbort, isAbortLikeError, resolveSegmentsForPersist, ]); /** * Processes and plays the current sentence * * @param {string} sentence - The sentence to process * @param {boolean} [preload=false] - Whether this is a preload request * @returns {Promise} Prepared playback source metadata */ const processSentence = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, locatorOverride?: TTSSegmentLocator, segmentKey?: string | null, ): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); const locator = locatorOverride || locatorForLocation( isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), currentReaderType, ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey); // Check if there's a pending preload request for this sentence const pendingRequest = preloadRequests.current.get(requestKey); if (pendingRequest) { if (preload) { return pendingRequest; } // Foreground playback must not block on batch preload promises, which can // include many other candidates and stall autoplay progression. preloadRequests.current.delete(requestKey); } // Only set processing state if not preloading if (!preload) setIsProcessing(true); // Create the audio processing promise const processPromise = (async () => { try { const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload, locatorOverride, segmentKey); if (preload && source) { warmSegmentAudioUrl(requestKey, source.presignUrl, source.fallbackUrl); } return source || null; } catch (error) { setIsProcessing(false); throw error; } })(); // If this is a preload request, store it in the map if (preload) { preloadRequests.current.set(requestKey, processPromise); // Clean up the map entry once the promise resolves or rejects void processPromise .finally(() => { preloadRequests.current.delete(requestKey); }) .catch(() => { // Prevent unhandled rejections from the cleanup-only chained promise. }); } return processPromise; }, [audioContext, getSegmentPlaybackSource, currDocPage, currDocPageNumber, currentReaderType, isEPUB, warmSegmentAudioUrl]); /** * Plays the current sentence with Howl * * @param {string} sentence - The sentence to play */ const playSentenceWithHowl = useCallback(async ( sentence: string, sentenceIndex: number, runId: number, segmentKey?: string | null, playbackSegment?: CanonicalTtsSegment | null, ) => { if (!sentence) { playbackInFlightRef.current = false; setIsProcessing(false); return; } const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = 1000; // 1 second const createHowl = async ( retryCount = 0, useFallbackSource = false, ): Promise => { if (runId !== playbackRunIdRef.current) return null; let playErrorAttempts = 0; const playbackSource = await processSentence(sentence, sentenceIndex, false, undefined, segmentKey); if (runId !== playbackRunIdRef.current) return null; if (!playbackSource) { // Graceful exit for rate limit / abort / intentionally skipped sentence return null; } // Ensure word highlighting is set even when alignment arrives from a fresh ensure // during this playback attempt (before cache-based pre-read can see it). if (playbackSource.manifest.alignment) { setCurrentSentenceAlignment(playbackSource.manifest.alignment); setCurrentWordIndex(null); } const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; // Force unload any previous Howl instance to free up resources if (activeHowl) { activeHowl.unload(); } // Guard against Firefox firing onend/onstop multiple times for the same Audio element. let howlFinished = false; return new Howl({ src: [audioUrl], format: ['mp3', 'mpeg'], html5: true, preload: true, // We never need overlapping playback for a single sentence. Keeping this low avoids // Safari/HTML5 Audio pool exhaustion when retries happen. pool: 1, rate: audioSpeed, onload: function (this: Howl) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } applyPlaybackRateToHowl(this); const estimate = pageTurnEstimateRef.current; if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; if (!visualPageChangeHandlerRef.current) return; const duration = this.duration(); if (!duration || !Number.isFinite(duration)) return; const delayMs = duration * estimate.fraction * 1000; if (delayMs <= 0 || delayMs >= duration * 1000) return; if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); } pageTurnTimeoutRef.current = setTimeout(() => { if (!isPlaying) return; const currentEstimate = pageTurnEstimateRef.current; if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return; visualPageChangeHandlerRef.current?.(currentEstimate.location); }, delayMs); }, onplay: function (this: Howl) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } setIsProcessing(false); startRateWatchdog(this); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'playing'; } }, onpause: function () { if (runId !== playbackRunIdRef.current) return; clearRateWatchdog(); playbackInFlightRef.current = false; setIsProcessing(false); if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } setIsPlaying(false); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'paused'; } }, onplayerror: function (this: Howl, soundId, error) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } const actualError = error ?? soundId; console.warn('Howl playback error:', actualError); // Common on iOS/Safari when the actual play() call happens after awaiting TTS. // Do not skip/advance in this case; just pause and tell the user to tap play again. if (isAutoplayBlockedError(actualError)) { howlFinished = true; playbackInFlightRef.current = false; setIsProcessing(false); setActiveHowl(null); try { this.unload(); } catch { // ignore unload errors } setIsPlaying(false); toast.error('Playback was blocked by your browser. Tap play again to start.', { id: 'tts-playback-blocked', duration: 4000, }); return; } playErrorAttempts += 1; // Avoid looping for many seconds on Safari: if playback still fails after a single // recovery attempt, skip the sentence and pause. if (playErrorAttempts > 1) { howlFinished = true; playbackInFlightRef.current = false; setIsProcessing(false); setActiveHowl(null); this.unload(); setIsPlaying(false); toast.error('Audio playback failed. Skipped sentence and paused.', { id: 'tts-playback-error', duration: 4000, }); advance(); return; } // Try to recover by reloading once. if (this.state() === 'loaded') { this.unload(); this.once('load', () => this.play()); this.load(); } }, onloaderror: async function (this: Howl, soundId, error) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } const actualError = error ?? soundId; console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); // First load failure on presigned URL should fail over to proxy fallback immediately. if (!useFallbackSource && playbackSource.fallbackUrl && playbackSource.fallbackUrl !== playbackSource.presignUrl) { try { this.unload(); } catch { // ignore unload errors } try { const fallbackHowl = await createHowl(retryCount, true); if (fallbackHowl) { if (runId !== playbackRunIdRef.current) { try { fallbackHowl.unload(); } catch {} return; } setActiveHowl(fallbackHowl); fallbackHowl.play(); return; } } catch (fallbackError) { console.error('Error switching to fallback segment source:', fallbackError); } } if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount); // Free the current Howl/audio objects before retrying to avoid pool exhaustion. try { this.unload(); } catch { // ignore unload errors } // Wait for the delay await new Promise(resolve => setTimeout(resolve, delay)); // Try to create a new Howl instance try { const retryHowl = await createHowl(retryCount + 1, useFallbackSource); if (retryHowl) { if (runId !== playbackRunIdRef.current) { try { retryHowl.unload(); } catch {} return; } setActiveHowl(retryHowl); retryHowl.play(); } else { // No audio generated (quota/abort). Stop cleanly without spamming errors. howlFinished = true; playbackInFlightRef.current = false; setIsProcessing(false); setActiveHowl(null); setIsPlaying(false); } } catch (err) { console.error('Error creating Howl instance:', err); howlFinished = true; playbackInFlightRef.current = false; setIsProcessing(false); setActiveHowl(null); setIsPlaying(false); toast.error('Audio loading failed after retries. Moving to next sentence...', { id: 'audio-load-error', duration: 2000, }); advance(); } } else { console.error('Max retries reached, moving to next sentence'); howlFinished = true; playbackInFlightRef.current = false; setIsProcessing(false); setActiveHowl(null); this.unload(); setIsPlaying(false); toast.error('Audio loading failed after retries. Moving to next sentence...', { id: 'audio-load-error', duration: 2000, }); advance(); } }, onend: function (this: Howl) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } if (howlFinished) return; // Deduplicate – Firefox can fire ended twice howlFinished = true; clearRateWatchdog(); this.unload(); playbackInFlightRef.current = false; setActiveHowl(null); if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } if (isEPUB) { completedEpubBoundarySegmentRef.current = completedEpubBoundarySegment(playbackSegment); // Track the highest canonical ordinal spoken in this spine item so // the next page's window can hand off at exactly ordinal + 1. const ownerLocator = playbackSegment?.ownerLocator; if ( ownerLocator && typeof ownerLocator.spineHref === 'string' && typeof ownerLocator.spineIndex === 'number' && typeof playbackSegment?.ordinal === 'number' ) { const prev = lastPlayedCanonicalRef.current; const sameSpine = prev && prev.spineHref === ownerLocator.spineHref && prev.spineIndex === ownerLocator.spineIndex; lastPlayedCanonicalRef.current = { spineHref: ownerLocator.spineHref, spineIndex: ownerLocator.spineIndex, ordinal: sameSpine ? Math.max(prev!.ordinal, playbackSegment.ordinal) : playbackSegment.ordinal, }; } } if (isPlaying) { advance(); } }, onstop: function (this: Howl) { if (runId !== playbackRunIdRef.current) { try { this.unload(); } catch {} return; } if (howlFinished) return; howlFinished = true; clearRateWatchdog(); playbackInFlightRef.current = false; setIsProcessing(false); this.unload(); } }); }; try { const howl = await createHowl(); if (runId !== playbackRunIdRef.current) { if (howl) { try { howl.unload(); } catch {} } return null; } if (!howl) { // No audio generated (quota hit / aborted / intentionally skipped). Stop cleanly without // advancing or spamming errors. playbackInFlightRef.current = false; setActiveHowl(null); setIsProcessing(false); setIsPlaying(false); return null; } setActiveHowl(howl); return howl; } catch (error) { console.error('Error playing TTS:', error); playbackInFlightRef.current = false; setActiveHowl(null); setIsProcessing(false); // Skip the sentence but pause playback (user can resume manually). abortAudio(true); setIsPlaying(false); advance(); return null; } }, [ abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed, isEPUB, isAutoplayBlockedError, applyPlaybackRateToHowl, startRateWatchdog, clearRateWatchdog, ]); const playAudio = useCallback(async () => { const runId = playbackRunIdRef.current; const playbackSegment = playbackSegments[currentIndex]; const sentence = playbackSegment?.text ?? sentences[currentIndex]; if (isEPUB && playbackSegment && completedEpubBoundarySegmentRef.current) { const suppression = resolveEpubReplaySuppressionAction( playbackSegments, currentIndex, completedEpubBoundarySegmentRef.current, ); if (suppression.kind === 'skip-to-index') { playbackInFlightRef.current = false; setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); completedEpubBoundarySegmentRef.current = null; setCurrentIndex(suppression.index); return; } if (suppression.kind === 'pause') { playbackInFlightRef.current = false; setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); completedEpubBoundarySegmentRef.current = null; setIsProcessing(false); setIsPlaying(false); return; } completedEpubBoundarySegmentRef.current = null; } const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator ?? locatorForLocation( isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), currentReaderType, ); const alignmentKey = buildScopedSegmentCacheKey( activeLocator, currentIndex, sentence, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, playbackSegment?.key, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); if (cachedAlignment) { setCurrentSentenceAlignment(cachedAlignment); setCurrentWordIndex(null); } else { setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); } const howl = await playSentenceWithHowl(sentence, currentIndex, runId, playbackSegment?.key, playbackSegment); if (runId !== playbackRunIdRef.current) { if (howl) { try { howl.unload(); } catch {} } return; } if (howl) { if (!isPlayingRef.current) { playbackInFlightRef.current = false; return; } howl.play(); } }, [ sentences, playbackSegments, currentIndex, playSentenceWithHowl, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions, ttsInstructions, resolvedLanguage, isEPUB, currDocPage, currDocPageNumber, currentReaderType, ]); // Keep the current playback rate applied to the active Howl. Some browsers (notably // iOS Safari with HTML5 audio) can reset playbackRate after initial load/play. useEffect(() => { if (!activeHowl) return; applyPlaybackRateToHowl(activeHowl); if (isPlaying) { startRateWatchdog(activeHowl); } }, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]); // Track the current word index during playback using Howler's seek position useEffect(() => { if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) { setCurrentWordIndex(null); return; } let frameId: number; const tick = () => { try { const pos = activeHowl.seek() as number; if (typeof pos === 'number' && Number.isFinite(pos)) { const words = currentSentenceAlignment.words; let idx = -1; for (let i = 0; i < words.length; i++) { const w = words[i]; if (pos >= w.startSec && pos < w.endSec) { idx = i; break; } } if (idx !== -1) { setCurrentWordIndex((prev) => (prev === idx ? prev : idx)); } } } catch { // ignore seek errors } frameId = requestAnimationFrame(tick); }; frameId = requestAnimationFrame(tick); return () => { if (frameId) { cancelAnimationFrame(frameId); } }; }, [activeHowl, isPlaying, currentSentenceAlignment]); /** * Preloads upcoming sentences in batched ensure requests. * Includes the current location and prefetched future locations when available. */ const preloadNextAudio = useCallback(() => { if (isAtLimit || !documentId) return; const sentenceLookahead = clampSegmentPreloadSentenceLookahead(segmentPreloadSentenceLookahead); const maxDepth = clampSegmentPreloadDepth(segmentPreloadDepthPages); if (isEPUB) { const generationAtStart = epubPreloadGenerationRef.current; const settingsHash = [ configProviderRef, ttsModel, voice, effectiveNativeSpeed, providerModelPolicy.supportsInstructions ? ttsInstructions : '', ].join('|'); const walkStartKey = `${currDocPage}|${settingsHash}|${ttsSegmentMaxBlockLength}|${maxDepth}`; const preloadFromOffset = (offset: number) => { if (offset > sentenceLookahead) return; const sentenceIndex = currentIndex + offset; const nextSegment = playbackSegments[sentenceIndex]; const nextSentence = nextSegment?.text ?? sentences[sentenceIndex]; if (!nextSentence) return; const currentLocator: TTSSegmentLocator = nextSegment?.ownerLocator ?? { location: String(currDocPage), readerType: currentReaderType }; const requestKey = buildSegmentRequestKey(currentLocator, sentenceIndex, nextSentence, nextSegment?.key); const cacheKey = buildScopedSegmentCacheKey( currentLocator, sentenceIndex, nextSentence, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, nextSegment?.key, ); if (segmentManifestCacheRef.current.has(cacheKey)) { preloadFromOffset(offset + 1); return; } const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(cacheKey); if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) { preloadFromOffset(offset + 1); return; } const pending = preloadRequests.current.get(requestKey); if (pending) { void pending .finally(() => preloadFromOffset(offset + 1)) .catch(() => {}); return; } void processSentence(nextSentence, sentenceIndex, true, currentLocator, nextSegment?.key) .catch((error) => { const status = typeof error === 'object' && error !== null && 'status' in error ? ((error as { status?: unknown }).status as number | undefined) : undefined; const code = typeof error === 'object' && error !== null && 'code' in error ? ((error as { code?: unknown }).code as string | undefined) : undefined; if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { console.error(`Error preloading EPUB sentence at offset ${offset}:`, error); } }) .finally(() => preloadFromOffset(offset + 1)); }; preloadFromOffset(1); if ( maxDepth <= 1 || !epubLocationWalkerRef.current || typeof currDocPage !== 'string' || epubWalkInFlightRef.current.has(walkStartKey) ) { return; } epubWalkInFlightRef.current.add(walkStartKey); const controller = new AbortController(); activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; if (openApiBaseUrl) { reqHeaders['x-openai-base-url'] = openApiBaseUrl; } const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 300, maxDelay: 300, }; let started = false; const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && epubHighlightEnabled && epubWordHighlightEnabled; const requestedDepth = Math.max(1, maxDepth); void epubLocationWalkerRef.current(currDocPage, requestedDepth, controller.signal) .then(async (locationItems) => { if (controller.signal.aborted) return; if (generationAtStart !== epubPreloadGenerationRef.current) return; if (!locationItems.length) return; const upcomingLocationItems = selectUpcomingWalkerItems( locationItems, String(currDocPage), maxDepth, ); if (!upcomingLocationItems.length) return; // Build a stable EPUB locator for each rendered chunk from the // walker's spine coordinates. These are viewport-independent — the // same content yields the same locator across devices and resizes. const locatorForWalkerItem = ( item: typeof upcomingLocationItems[number], ): TTSSegmentLocator => ({ readerType: 'epub', spineHref: item.spineHref, spineIndex: item.spineIndex, charOffset: item.chunkOffset, cfi: item.cfi, }); // Prefer canonical per-chunk segments attached by the walker — these // are viewport-independent and mint identical keys/locators to what // playback will request, so warmed audio actually hits the cache. // Only chunks the walker couldn't canonicalize fall back to the // legacy preview-based plan. const fallbackItems = upcomingLocationItems.filter((item) => !item.segments?.length); let fallbackSegments: CanonicalTtsSegment[] = []; if (fallbackItems.length > 0) { const upcomingUnits: CanonicalTtsSourceUnit[] = fallbackItems.map((item) => ({ sourceKey: sourceKeyForLocation(item.cfi, currDocPage), text: item.text, locator: locatorForWalkerItem(item), })); const liveContextUnits = currentSourceContextUnitsRef.current.length > 0 ? currentSourceContextUnitsRef.current : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( liveContextUnits, upcomingUnits, ); fallbackSegments = planCanonicalTtsSegments(sourceUnits, { readerType: 'epub', maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'), language: resolvedLanguage, }).segments; } const uniqueCandidates: Array = []; const seenCandidates = new Set(); for (const item of upcomingLocationItems) { const sourceKey = sourceKeyForLocation(item.cfi, currDocPage); const planned = (item.segments?.length ? item.segments : fallbackSegments.filter((segment) => segment.ownerSourceKey === sourceKey) ).slice(0, sentenceLookahead); for (let index = 0; index < planned.length; index += 1) { const segment = planned[index]; const locator = segment.ownerLocator ?? locatorForWalkerItem(item); const requestKey = buildSegmentRequestKey(locator, index, segment.text, segment.key); const cacheKey = buildScopedSegmentCacheKey( locator, index, segment.text, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, segment.key, ); if (seenCandidates.has(requestKey)) continue; seenCandidates.add(requestKey); if (segmentManifestCacheRef.current.has(cacheKey)) continue; const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(cacheKey); if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) continue; if (preloadRequests.current.has(requestKey)) continue; uniqueCandidates.push({ sentence: segment.text, segmentKey: segment.key, segmentIndex: index, location: item.cfi, requestKey, cacheKey, locator, }); } } if (uniqueCandidates.length === 0) return; const payload = uniqueCandidates.map((candidate) => ({ segmentIndex: candidate.segmentIndex, segmentKey: candidate.segmentKey, text: candidate.sentence, locator: candidate.locator, })); const preloadPromise = (async (): Promise => { onTTSStart(); started = true; const persistResult = await resolveSegmentsForPersist(payload); const persistPayload = persistResult.segments; if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ documentId, settings: { providerRef: configProviderRef, providerType: configProviderType, ttsModel, voice, nativeSpeed: effectiveNativeSpeed, ...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}), language: resolvedLanguage, }, segments: persistPayload, }, reqHeaders, controller.signal), retryOptions, ); if (generationAtStart !== epubPreloadGenerationRef.current) return; ensured.segments.forEach((segment, persistIndex) => { if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return; const sourceIndex = persistResult.sourceIndices[persistIndex]; if (sourceIndex === undefined) return; const candidate = uniqueCandidates[sourceIndex]; if (!candidate) return; cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); }); })(); for (const candidate of uniqueCandidates) { const candidatePromise: Promise = preloadPromise.then(() => { const manifest = segmentManifestCacheRef.current.get(candidate.cacheKey); if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) { return null; } warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl); return { presignUrl: manifest.audioPresignUrl, fallbackUrl: manifest.audioFallbackUrl, manifest, }; }); void candidatePromise.catch(() => {}); preloadRequests.current.set(candidate.requestKey, candidatePromise); } await preloadPromise.finally(() => { for (const candidate of uniqueCandidates) { preloadRequests.current.delete(candidate.requestKey); } }); }) .catch((error) => { const status = typeof error === 'object' && error !== null && 'status' in error ? ((error as { status?: unknown }).status as number | undefined) : undefined; const code = typeof error === 'object' && error !== null && 'code' in error ? ((error as { code?: unknown }).code as string | undefined) : undefined; if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { console.error('Error preloading EPUB location segments:', error); } }) .finally(() => { epubWalkInFlightRef.current.delete(walkStartKey); activeAbortControllers.current.delete(controller); if (started) { onTTSComplete(); } }); return; } const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); const candidates: Array<{ sentence: string; segmentKey?: string | null; segmentIndex: number; locator: TTSSegmentLocator; requestKey: string; cacheKey: string; }> = []; const currentLocator: TTSSegmentLocator = locatorForLocation( isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), currentReaderType, ); for (let offset = 1; offset <= sentenceLookahead; offset += 1) { const sentenceIndex = currentIndex + offset; const plannedSegment = playbackSegments[sentenceIndex]; const sentence = plannedSegment?.text ?? sentences[sentenceIndex]; if (!sentence) break; const locator = plannedSegment?.ownerLocator ?? currentLocator; const cacheKey = buildScopedSegmentCacheKey( locator, sentenceIndex, sentence, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, plannedSegment?.key, ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key); candidates.push({ sentence, segmentKey: plannedSegment?.key, segmentIndex: sentenceIndex, locator, requestKey, cacheKey }); } const prefetched = Array.from(plannedSegmentsByLocationRef.current.entries()).slice(0, maxDepth); for (const [locationKey, planned] of prefetched) { if (!planned.length) continue; const location = locationKey.startsWith('num:') ? Number(locationKey.slice(4)) : locationKey.startsWith('str:') ? locationKey.slice(4) : null; if (location === null) continue; const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType); for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) { const segment = planned[index]; const sentence = segment.text; const segmentLocator = segment.ownerLocator ?? locator; const cacheKey = buildScopedSegmentCacheKey( segmentLocator, index, sentence, voice, effectiveNativeSpeed, configProviderRef, ttsModel, configProviderType, providerModelPolicy.supportsInstructions ? ttsInstructions : '', resolvedLanguage, segment.key, ); const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key); candidates.push({ sentence, segmentKey: segment.key, segmentIndex: index, locator: segmentLocator, requestKey, cacheKey }); } } const uniqueCandidates: typeof candidates = []; const seen = new Set(); for (const candidate of candidates) { if (seen.has(candidate.requestKey)) continue; seen.add(candidate.requestKey); if (segmentManifestCacheRef.current.has(candidate.cacheKey)) continue; const cooldownRetryAtMs = segmentRetryCooldownRef.current.get(candidate.cacheKey); if (shouldDeferSegmentRetry(Date.now(), cooldownRetryAtMs)) continue; if (preloadRequests.current.has(candidate.requestKey)) continue; uniqueCandidates.push(candidate); } if (uniqueCandidates.length === 0) return; const controller = new AbortController(); activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': openApiKey || '', 'x-tts-provider': configProviderRef, }; if (openApiBaseUrl) { reqHeaders['x-openai-base-url'] = openApiBaseUrl; } const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 300, maxDelay: 300, }; const payload = uniqueCandidates.map((candidate) => ({ segmentIndex: candidate.segmentIndex, ...(candidate.segmentKey ? { segmentKey: candidate.segmentKey } : {}), text: candidate.sentence, locator: candidate.locator, })); const candidateLookup = new Map(); const candidateLookupKey = (segmentIndex: number, segmentKey?: string | null) => `${segmentIndex}|${segmentKey || ''}`; for (const candidate of uniqueCandidates) { candidateLookup.set(candidateLookupKey(candidate.segmentIndex, candidate.segmentKey), candidate); } const preloadPromise = (async (): Promise => { try { onTTSStart(); const persistResult = await resolveSegmentsForPersist(payload); const persistPayload = persistResult.segments; if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ documentId, settings: { providerRef: configProviderRef, providerType: configProviderType, ttsModel, voice, nativeSpeed: effectiveNativeSpeed, ...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}), language: resolvedLanguage, }, segments: persistPayload, }, reqHeaders, controller.signal), retryOptions, ); ensured.segments.forEach((segment, persistIndex) => { const sourceIndex = persistResult.sourceIndices[persistIndex]; if (sourceIndex === undefined) return; const candidate = uniqueCandidates[sourceIndex] ?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey)); if (!candidate) return; cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); }); } finally { activeAbortControllers.current.delete(controller); onTTSComplete(); } })(); for (const candidate of uniqueCandidates) { const candidatePromise: Promise = preloadPromise.then(() => { const manifest = segmentManifestCacheRef.current.get(candidate.cacheKey); if (!manifest || manifest.status !== 'completed' || !manifest.audioPresignUrl || !manifest.audioFallbackUrl) { return null; } warmSegmentAudioUrl(candidate.requestKey, manifest.audioPresignUrl, manifest.audioFallbackUrl); return { presignUrl: manifest.audioPresignUrl, fallbackUrl: manifest.audioFallbackUrl, manifest, }; }); // Background preload is best-effort; aborted batches should not surface as // unhandled promise rejections in dev. void candidatePromise.catch(() => {}); preloadRequests.current.set(candidate.requestKey, candidatePromise); } void preloadPromise .catch((error) => { const status = (() => { if (typeof error === 'object' && error !== null && 'status' in error) { const maybe = (error as { status?: unknown }).status; return typeof maybe === 'number' ? maybe : undefined; } return undefined; })(); const code = (() => { if (typeof error === 'object' && error !== null && 'code' in error) { const maybe = (error as { code?: unknown }).code; return typeof maybe === 'string' ? maybe : undefined; } return undefined; })(); if (!isAbortLikeError(error) && !(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { console.error('Error preloading batched segments:', error); } }) .finally(() => { for (const candidate of uniqueCandidates) { preloadRequests.current.delete(candidate.requestKey); } }); }, [ isAtLimit, documentId, isEPUB, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, currDocPage, currDocPageNumber, currentReaderType, currentIndex, sentences, playbackSegments, voice, effectiveNativeSpeed, configProviderRef, configProviderType, ttsModel, openApiKey, openApiBaseUrl, providerModelPolicy.supportsInstructions, ttsInstructions, resolvedLanguage, onTTSStart, onTTSComplete, processSentence, cacheCompletedManifestForCandidate, isAbortLikeError, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, resolveSegmentsForPersist, warmSegmentAudioUrl, ]); /** * Main Playback Driver * Controls the flow of audio playback and sentence processing */ useEffect(() => { if (!isPlaying) { playbackInFlightRef.current = false; return; } if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play // Single synchronous guard: covers the entire window from when playAudio() is // called until the Howl finishes (onend/onstop/error). React state guards // (isProcessing, activeHowl) are async and leave a micro-task gap that allows // the effect to re-fire and start duplicate playback — especially in Firefox // where HTML5 Audio events can trigger extra renders. if (playbackInFlightRef.current) return; playbackInFlightRef.current = true; // Start playing current sentence playAudio(); // Start background lookahead preloading for upcoming sentences. preloadNextAudio(); return () => { // Only abort if we're actually stopping playback if (!isPlaying) { abortAudio(); } }; }, [ isPlaying, currentIndex, sentences, playAudio, preloadNextAudio, abortAudio ]); /** * Stops the current audio playback and resets all state */ const stop = useCallback(() => { // Cancel any ongoing request invalidatePlaybackRun(); abortAudio(); clearWarmAudioCache(); playbackInFlightRef.current = false; pendingPlaybackStartRef.current = false; pendingJumpTargetRef.current = null; clearPendingEpubJump(); bumpEpubPreloadGeneration(); plannedSegmentsByLocationRef.current.clear(); currentSourceUnitRef.current = null; currentSourceContextUnitsRef.current = []; completedEpubBoundarySegmentRef.current = null; lastPlayedCanonicalRef.current = null; pageFirstBlockFingerprintRef.current.clear(); setIsPlaying(false); setCurrentIndex(0); setSentences([]); setPlaybackSegments([]); setCurrDocPage(1); setCurrDocPages(undefined); setIsProcessing(false); setIsEPUB(false); sentenceAlignmentCacheRef.current.clear(); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); }, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); const clearSegmentCaches = useCallback(() => { // Keep the current viewport/sentence list intact, but force all audio/manifest // state to be re-resolved after a server-side clear. abortAudio(true); segmentManifestCacheRef.current.clear(); sentenceAlignmentCacheRef.current.clear(); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); }, [abortAudio]); /** * Stops the current audio playback and starts playing from a specified index * * @param {number} index - The index to start playing from */ const stopAndPlayFromIndex = useCallback((index: number) => { invalidatePlaybackRun(); abortAudio(); // Same autoplay-unlock issue as togglePlay when starting from a fresh load. unlockPlaybackOnUserGesture(); setCurrentIndex(index); setIsPlaying(true); }, [abortAudio, invalidatePlaybackRun, unlockPlaybackOnUserGesture]); const playFromSegment = useCallback((index: number, locator?: TTSSegmentLocator | null) => { if (isEPUB) { clearPendingEpubJump(); } const resolvedLocation: TTSLocation | undefined = (() => { if (!locator) return undefined; // Stable EPUB locators carry the jump-hint CFI in `cfi`, not `location` // (which is now reserved for HTML / legacy rows). if (locator.readerType === 'epub' && typeof locator.cfi === 'string' && locator.cfi) { return locator.cfi; } if (typeof locator.location === 'string' && locator.location) return locator.location; if (typeof locator.page === 'number' && Number.isFinite(locator.page)) return Math.floor(locator.page); return undefined; })(); if (resolvedLocation === undefined) { stopAndPlayFromIndex(index); return; } const isSameLocation = typeof resolvedLocation === 'string' ? String(currDocPage) === String(resolvedLocation) : Number(currDocPageNumber || 1) === Number(resolvedLocation); if (isSameLocation) { stopAndPlayFromIndex(index); return; } invalidatePlaybackRun(); abortAudio(); unlockPlaybackOnUserGesture(); if (isEPUB) { // CFI snapping makes locationKey unreliable; resolve via epoch on next setText. // Carry the target's per-segment charOffset so the canonical window can // re-anchor exactly (raw index is viewport-relative to the sidebar). pendingEpubJumpRef.current = { index: Math.max(0, index), epoch: epubJumpEpochRef.current, charOffset: typeof locator?.charOffset === 'number' ? locator.charOffset : undefined, }; pendingJumpTargetRef.current = null; // A jump breaks ordinal continuity — drop the handoff anchor so the new // page plays from its resolved index rather than being trimmed. lastPlayedCanonicalRef.current = null; } else { pendingJumpTargetRef.current = { locationKey: normalizeLocationKey(resolvedLocation), index: Math.max(0, index), }; } resumeAfterLocationChangeRef.current = true; setCurrentIndex(0); setIsPlaying(true); if (isEPUB && locationChangeHandlerRef.current) { locationChangeHandlerRef.current(resolvedLocation); return; } skipToLocation(resolvedLocation, false); }, [ stopAndPlayFromIndex, currDocPage, currDocPageNumber, isEPUB, invalidatePlaybackRun, abortAudio, unlockPlaybackOnUserGesture, skipToLocation, clearPendingEpubJump, ]); /** * Sets the speed and restarts the playback * * @param {number} newSpeed - The new speed to set */ const setSpeedAndRestart = useCallback((newSpeed: number) => { const wasPlaying = isPlaying; // Bump restart sequence to invalidate older restarts const mySeq = ++restartSeqRef.current; // Set a flag to prevent double audio requests during config update setIsProcessing(true); // First stop any current playback setIsPlaying(false); clearPendingEpubJump(); abortAudio(true); // Clear pending requests since speed changed setActiveHowl(null); // Update speed and config setSpeed(newSpeed); // Update config after state changes updateConfigKey('voiceSpeed', newSpeed).then(() => { setIsProcessing(false); // Resume playback if it was playing before and this is the latest restart if (wasPlaying && mySeq === restartSeqRef.current) { setIsPlaying(true); } }); }, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]); /** * Sets the voice and restarts the playback * * @param {string} newVoice - The new voice to set */ const setVoiceAndRestart = useCallback((newVoice: string) => { const wasPlaying = isPlaying; // Bump restart sequence to invalidate older restarts const mySeq = ++restartSeqRef.current; // Set a flag to prevent double audio requests during config update setIsProcessing(true); // First stop any current playback setIsPlaying(false); clearPendingEpubJump(); abortAudio(true); // Clear pending requests since voice changed setActiveHowl(null); // Update voice and config setVoice(newVoice); // Update config after state changes updateConfigKey('voice', newVoice).then(() => { setIsProcessing(false); // Resume playback if it was playing before and this is the latest restart if (wasPlaying && mySeq === restartSeqRef.current) { setIsPlaying(true); } }); }, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]); /** * Sets the audio player speed and restarts the playback * * @param {number} newSpeed - The new audio player speed to set */ const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => { const wasPlaying = isPlaying; // Bump restart sequence to invalidate older restarts const mySeq = ++restartSeqRef.current; // Set a flag to prevent double audio requests during config update setIsProcessing(true); // First stop any current playback setIsPlaying(false); clearPendingEpubJump(); abortAudio(true); // Clear pending requests since speed changed setActiveHowl(null); // Update audio speed and config setAudioSpeed(newSpeed); // Update config after state changes updateConfigKey('audioPlayerSpeed', newSpeed).then(() => { setIsProcessing(false); // Resume playback if it was playing before and this is the latest restart if (wasPlaying && mySeq === restartSeqRef.current) { setIsPlaying(true); } }); }, [abortAudio, updateConfigKey, isPlaying, clearPendingEpubJump]); /** * Provides the TTS context value to child components */ const value = useMemo(() => ({ isPlaying, isProcessing, currentSentence: sentences[currentIndex] || '', currentSegment: playbackSegments[currentIndex] ?? null, sentences, currentSentenceIndex: currentIndex, currentSentenceAlignment, currentWordIndex, currDocPage, currDocPageNumber, currDocPages, voice, availableVoices, togglePlay, skipForward, skipBackward, stop, pause, stopAndPlayFromIndex, playFromSegment, setText, setCurrDocPages, setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, documentLanguage, resolvedLanguage, setDocumentLanguage, clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, registerEpubLocatorResolver, registerVisualPageChangeHandler, setIsEPUB, activeReaderType, }), [ isPlaying, isProcessing, sentences, playbackSegments, currentIndex, currDocPage, currDocPageNumber, currDocPages, voice, availableVoices, togglePlay, skipForward, skipBackward, stop, pause, stopAndPlayFromIndex, playFromSegment, setText, setCurrDocPages, setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, documentLanguage, resolvedLanguage, clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, registerEpubLocatorResolver, registerVisualPageChangeHandler, setIsEPUB, currentSentenceAlignment, currentWordIndex, activeReaderType, ]); // Use media session hook useMediaSession({ togglePlay, skipForward, skipBackward, }); // Load last location on mount for EPUB/PDF/HTML. // Prefer server-backed progress when available, then fall back to local Dexie. useEffect(() => { if (!id) return; let cancelled = false; const docId = id as string; const applyLocation = (lastLocation: string) => { if (isEPUB && locationChangeHandlerRef.current) { // For EPUB documents, use the location change handler locationChangeHandlerRef.current(lastLocation); return; } if (!isEPUB) { // HTML stores "html::". // PDF stores ":". try { if (currentReaderType === 'html') { const htmlMatch = /^html:([^:]+):(\d+)$/.exec(lastLocation); if (htmlMatch) { const [, rawLocation, sentenceIndexStr] = htmlMatch; const decodedLocation = decodeURIComponent(rawLocation); const parsedNumber = Number(decodedLocation); const location: TTSLocation = Number.isFinite(parsedNumber) && decodedLocation.trim() !== '' ? parsedNumber : decodedLocation || 1; const sentenceIndex = parseInt(sentenceIndexStr, 10); if (!isNaN(sentenceIndex)) { setCurrDocPage(location); pendingJumpTargetRef.current = { locationKey: normalizeLocationKey(location), index: Math.max(0, sentenceIndex), }; return; } } } // Backward-compatible parser for legacy non-EPUB progress format. const [pageStr, sentenceIndexStr] = lastLocation.split(':'); const page = parseInt(pageStr, 10); const sentenceIndex = parseInt(sentenceIndexStr, 10); if (!isNaN(page) && !isNaN(sentenceIndex)) { setCurrDocPage(page); pendingJumpTargetRef.current = { locationKey: normalizeLocationKey(page), index: Math.max(0, sentenceIndex), }; } } catch (error) { console.warn('Error parsing non-EPUB location:', error); } } }; const load = async () => { try { const local = await getLastDocumentLocation(docId); if (!cancelled && local) { applyLocation(local); } } catch (error) { console.warn('Error loading local last location:', error); } try { const remote = await getDocumentProgress(docId); if (!cancelled && remote?.location) { await setLastDocumentLocation(docId, remote.location).catch((error) => { console.warn('Error caching remote location locally:', error); }); applyLocation(remote.location); } } catch (error) { console.warn('Error loading remote progress:', error); } }; load(); return () => { cancelled = true; }; }, [id, isEPUB, currentReaderType]); // Save current position periodically for non-EPUB readers. useEffect(() => { if (id && !isEPUB && sentences.length > 0) { const location = currentReaderType === 'html' ? `html:${encodeURIComponent(String(currDocPage || 1))}:${currentIndex}` : `${currDocPageNumber}:${currentIndex}`; const timeoutId = setTimeout(() => { setLastDocumentLocation(id as string, location).catch(error => { console.warn('Error saving non-EPUB location:', error); }); scheduleDocumentProgressSync({ documentId: id as string, readerType: currentReaderType, location, }); }, 1000); // Debounce saves by 1 second return () => clearTimeout(timeoutId); } }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]); /** * Renders the TTS context provider with its children * * @param {ReactNode} children - Child components to be wrapped * @returns {JSX.Element} */ return ( {children} ); } /** * Custom hook to consume the TTS context * Ensures the context is used within a TTSProvider * * @throws {Error} If used outside of TTSProvider * @returns {TTSContextType} The TTS context value */ export function useTTS() { const context = useContext(TTSContext); if (context === undefined) { throw new Error('useTTS must be used within a TTSProvider'); } return context; }