/** * 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 { useAudioCache } from '@/hooks/audio/useAudioCache'; 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, generateTTS, alignAudio } from '@/lib/client/api/audiobooks'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp'; import { isKokoroModel } from '@/lib/shared/kokoro'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { TTSLocation, TTSSmartMergeResult, TTSPageTurnEstimate, TTSPlaybackState, TTSSentenceAlignment, TTSAudioBuffer, } from '@/types/tts'; import type { TTSRequestPayload, TTSRequestHeaders, TTSRetryOptions, } from '@/types/client'; import type { ReaderType } from '@/types/user-state'; // 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[]; // 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; setText: (text: string, options?: boolean | SetTextOptions) => void; setCurrDocPages: (num: number | undefined) => void; setSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: (location: TTSLocation) => void) => void; // EPUB-only: Handles chapter navigation registerVisualPageChangeHandler: (handler: (location: TTSLocation) => void) => void; setIsEPUB: (isEPUB: boolean) => void; } interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; } const CONTINUATION_LOOKAHEAD = 600; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; const wordHighlightFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false'; // Tiny silent WAV used to unlock HTML5 audio on iOS/Safari. const SILENT_WAV_DATA_URI = 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; const normalizeLocationKey = (location: TTSLocation) => typeof location === 'number' ? `num:${location}` : `str:${location}`; const isWhitespaceChar = (char: string) => /\s/.test(char); const skipWhitespace = (source: string, start: number) => { let index = start; while (index < source.length && isWhitespaceChar(source[index])) { index++; } return index; }; const matchNormalizedPrefixLength = (text: string, prefix: string): number | null => { let textIndex = 0; let prefixIndex = 0; while (prefixIndex < prefix.length) { const prefixChar = prefix[prefixIndex]; if (isWhitespaceChar(prefixChar)) { prefixIndex = skipWhitespace(prefix, prefixIndex); textIndex = skipWhitespace(text, textIndex); continue; } if (textIndex >= text.length) { return null; } const textChar = text[textIndex]; if (textChar === prefixChar || textChar.toLowerCase() === prefixChar.toLowerCase()) { textIndex++; prefixIndex++; continue; } return null; } return textIndex; }; const needsSentenceContinuation = (text: string) => { const trimmed = text.trim(); if (!trimmed) { return false; } return !SENTENCE_ENDING.test(trimmed); }; const stripContinuationPrefix = (text: string, prefix: string) => { if (!prefix) return { text, removed: false }; // Try literal match first since PDF text is normalized already if (text.startsWith(prefix)) { return { text: text.slice(prefix.length).trimStart(), removed: true, }; } const trimmedPrefix = prefix.trimStart(); const trimmedText = text.trimStart(); if (trimmedText.startsWith(trimmedPrefix)) { const offset = text.length - trimmedText.length; return { text: text.slice(offset + trimmedPrefix.length).trimStart(), removed: true, }; } const matchedLength = matchNormalizedPrefixLength(text, prefix); if (matchedLength !== null) { return { text: text.slice(matchedLength).trimStart(), removed: true, }; } return { text, removed: false }; }; const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => { if (!nextText?.trim()) { return null; } const snippet = nextText.trim().slice(0, CONTINUATION_LOOKAHEAD); let boundaryIndex = -1; for (let i = 0; i < snippet.length; i++) { const char = snippet[i]; if (/[.?!…]/.test(char)) { let j = i + 1; while (j < snippet.length && /["'”’)\]]/.test(snippet[j])) { j++; } while (j < snippet.length && /\s/.test(snippet[j])) { j++; } boundaryIndex = j; break; } } if (boundaryIndex === -1) { return null; } const rawSlice = snippet.slice(0, boundaryIndex); const addition = rawSlice.trim(); if (!addition) { return null; } return { text: addition, carried: rawSlice, }; }; const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => { if (!needsSentenceContinuation(text)) { return null; } const slice = extractContinuationSlice(nextText); if (!slice) { return null; } const trimmed = text.trimEnd(); const endsWithHyphen = trimmed.endsWith('-'); const base = endsWithHyphen ? trimmed.slice(0, -1) : trimmed; const joiner = endsWithHyphen ? '' : (base ? ' ' : ''); const mergedText = `${base}${joiner}${slice.text}`.trim(); return { text: mergedText, carried: slice.carried, }; }; const buildCacheKey = ( sentence: string, voice: string, speed: number, provider: string, model: string, ) => { return [ `provider=${provider || ''}`, `model=${model || ''}`, `voice=${voice || ''}`, `speed=${Number.isFinite(speed) ? speed : ''}`, `text=${sentence}`, ].join('|'); }; // 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, ttsProvider: configTTSProvider, ttsModel: configTTSModel, ttsInstructions: configTTSInstructions, updateConfigKey, skipBlank, smartSentenceSplitting, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, } = useConfig(); // Audio and voice management hooks const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); const { authEnabled, onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit, } = useAuthRateLimit(); // 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) => { locationChangeHandlerRef.current = handler; }, []); /** * 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) => { visualPageChangeHandlerRef.current = handler; }, []); // Get document ID from URL params const { id } = useParams(); const pathname = usePathname(); const currentReaderType: ReaderType = useMemo(() => { if (pathname.startsWith('/epub/')) return 'epub'; if (pathname.startsWith('/html/')) return 'html'; return 'pdf'; }, [pathname]); /** * State Management */ const [isPlaying, setIsPlaying] = useState(false); const [isEPUB, setIsEPUB] = useState(false); const [isProcessing, setIsProcessing] = useState(false); 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 [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); // Track pending preload requests const preloadRequests = 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); // Track if we're restoring from a saved position const [pendingRestoreIndex, setPendingRestoreIndex] = useState(null); // Guard to coalesce rapid restarts and only resume the latest change const restartSeqRef = useRef(0); // Track continuation slices for PDF/EPUB page transitions const continuationCarryRef = useRef>(new Map()); const epubContinuationRef = useRef(null); const pageTurnEstimateRef = useRef(null); const pageTurnTimeoutRef = useRef | null>(null); const sentenceAlignmentCacheRef = useRef>(new Map()); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState(); const [currentWordIndex, setCurrentWordIndex] = useState(null); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { }); const prefetchedLocationTextRef = useRef>(new Map()); 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 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 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); }, []); useEffect(() => { sentencesRef.current = sentences; }, [sentences]); useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); /** * Processes text into sentences using the shared NLP utility * * @param {string} text - The text to be processed * @returns {Promise} Array of processed sentences */ const splitTextToTtsBlocksLocal = useCallback(async (text: string): Promise => { if (text.length < 1) { return []; } // Use the shared utility directly instead of making an API call return isEPUB ? splitTextToTtsBlocksEPUB(text) : splitTextToTtsBlocks(text); }, [isEPUB]); /** * 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) => { clearRateWatchdog(); if (activeHowl) { activeHowl.stop(); activeHowl.unload(); setActiveHowl(null); } if (clearPending) { activeAbortControllers.current.forEach(controller => { controller.abort(); }); activeAbortControllers.current.clear(); preloadRequests.current.clear(); } if (pageTurnTimeoutRef.current) { clearTimeout(pageTurnTimeoutRef.current); pageTurnTimeoutRef.current = null; } setCurrentWordIndex(null); }, [activeHowl, clearRateWatchdog]); /** * Pauses the current audio playback * Used for external control of playback state */ const pause = useCallback(() => { abortAudio(); setIsPlaying(false); }, [abortAudio]); /** * 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) => { // Reset state for new content in correct order abortAudio(); if (shouldPause) setIsPlaying(false); setCurrentIndex(0); setSentences([]); setCurrDocPage(location); }, [abortAudio]); /** * 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) { 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 prefetchedText = prefetchedLocationTextRef.current.get(bufferKey); if (prefetchedText?.trim()) { prefetchedLocationTextRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); setTextRef.current(prefetchedText, { shouldPause: false, location: targetLocation, }); // Ask the viewer to continue turning pages/sections; this may be deferred while hidden. locationChangeHandlerRef.current('next'); return; } } } locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev'); return; } // For PDFs and other documents, check page bounds if (!isEPUB) { // 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 prefetchedText = prefetchedLocationTextRef.current.get(bufferKey); if (prefetchedText?.trim()) { prefetchedLocationTextRef.current.delete(bufferKey); pendingNextLocationRef.current = undefined; setCurrDocPage(targetLocation); setTextRef.current(prefetchedText, { shouldPause: false, location: targetLocation, }); 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]); /** * 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 || {}); let workingText = text; // Apply or clear sentence continuation logic based on config let continuationCarried: string | undefined; if (smartSentenceSplitting) { if (isEPUB && epubContinuationRef.current) { const { text: strippedText, removed } = stripContinuationPrefix(workingText, epubContinuationRef.current); workingText = strippedText; if (removed) { epubContinuationRef.current = null; } } if (!isEPUB && normalizedOptions.location !== undefined) { const key = normalizeLocationKey(normalizedOptions.location); const carried = continuationCarryRef.current.get(key); if (carried) { const { text: strippedText, removed } = stripContinuationPrefix(workingText, carried); workingText = strippedText; if (removed) { continuationCarryRef.current.delete(key); } } } if (normalizedOptions.nextText) { const merged = mergeContinuation(workingText, normalizedOptions.nextText); if (merged) { workingText = merged.text; continuationCarried = merged.carried; } } if (continuationCarried) { if (isEPUB) { epubContinuationRef.current = continuationCarried; } else if (normalizedOptions.nextLocation !== undefined) { continuationCarryRef.current.set( normalizeLocationKey(normalizedOptions.nextLocation), continuationCarried ); } } } else { // When disabled, clear any stale continuation state epubContinuationRef.current = null; continuationCarryRef.current.clear(); pageTurnEstimateRef.current = null; } // Keep the next-location text around so background page turns can continue when // viewer/location callbacks are throttled by the browser. prefetchedLocationTextRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { prefetchedLocationTextRef.current.set( normalizeLocationKey(normalizedOptions.nextLocation), normalizedOptions.nextText ); } // Check for blank section after adjustments if (handleBlankSection(workingText)) return; const shouldPause = normalizedOptions.shouldPause ?? false; // Keep track of previous state and pause playback const wasPlaying = isPlaying; setIsPlaying(false); abortAudio(true); // Clear pending requests since text is changing setIsProcessing(true); // Set processing state before text processing starts splitTextToTtsBlocksLocal(workingText) .then(newSentences => { if (newSentences.length === 0) { console.warn('No sentences found in text'); setIsProcessing(false); return; } // Set all state updates in a predictable order setSentences(newSentences); // Check if we have a pending restore index for PDF if (pendingRestoreIndex !== null && !isEPUB) { const restoreIndex = Math.min(pendingRestoreIndex, newSentences.length - 1); console.log(`Restoring sentence index: ${restoreIndex}`); setCurrentIndex(restoreIndex); setPendingRestoreIndex(null); // Clear the pending restore } else { setCurrentIndex(0); } // Reset alignment state whenever the text block changes sentenceAlignmentCacheRef.current.clear(); setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); // Compute auto page-turn estimate for PDFs when we have a continuation if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) { const continuationNormalized = preprocessSentenceForAudio(continuationCarried); if (continuationNormalized) { let bestEstimate: TTSPageTurnEstimate | null = null; newSentences.forEach((sentence, index) => { const normalizedSentence = preprocessSentenceForAudio(sentence); if (!normalizedSentence) return; if (!normalizedSentence.toLowerCase().endsWith(continuationNormalized.toLowerCase())) return; const totalLength = normalizedSentence.length; const continuationLength = continuationNormalized.length; if (totalLength <= continuationLength) return; const baseLength = totalLength - continuationLength; const fraction = baseLength / totalLength; if (fraction <= 0 || fraction >= 1) return; bestEstimate = { location: normalizedOptions.nextLocation!, sentenceIndex: index, fraction, }; }); pageTurnEstimateRef.current = bestEstimate; } else { pageTurnEstimateRef.current = null; } } else { pageTurnEstimateRef.current = null; } setIsProcessing(false); // Restore playback state if needed if (!shouldPause && wasPlaying) { setIsPlaying(true); } }) .catch(error => { console.warn('Error processing text:', error); setIsProcessing(false); toast.error('Failed to process text', { duration: 3000, }); }); }, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]); useEffect(() => { setTextRef.current = setText; }, [setText]); /** * Toggles the playback state between playing and paused */ const togglePlay = useCallback(() => { if (isPlaying) { abortAudio(); setIsPlaying(false); return; } // Ensure audio is unlocked while we're still in the click/tap handler. unlockPlaybackOnUserGesture(); setIsPlaying(true); }, [abortAudio, isPlaying, 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); } abortAudio(false); // Don't clear pending requests await advance(); }, [isPlaying, abortAudio, advance]); /** * Moves backward one sentence in the text */ const skipBackward = useCallback(async () => { // Only show processing state if we're currently playing if (isPlaying) { setIsProcessing(true); } abortAudio(false); // Don't clear pending requests await advance(true); }, [isPlaying, abortAudio, advance]); /** * 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]); /** * 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 = isKokoroModel(configTTSModel); const fallbackVoice = configVoice || availableVoices[0]; 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) { console.log(`Voice "${voice || '(empty)'}" not set. Using "${fallbackVoice}"`); setVoice(fallbackVoice); return; } if (!configVoice && !availableVoices.includes(voice)) { console.log(`Voice "${voice || '(empty)'}" not found in available voices. Using "${availableVoices[0]}"`); setVoice(availableVoices[0]); // Don't save to config - just use it temporarily until user explicitly selects one } } }, [availableVoices, voice, configVoice, configTTSModel]); /** * Generates and plays audio for the current sentence * * @param {string} sentence - The sentence to generate audio for * @returns {Promise} The generated audio buffer */ const getAudio = useCallback(async (sentence: string, preload = false): Promise => { const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); // Helper to ensure we have an alignment for a given // sentence/audio pair, even when the audio itself is // served from the local cache. const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => { if (!alignmentEnabledForCurrentDoc) return; const alignmentKey = buildCacheKey( sentence, voice, speed, configTTSProvider, ttsModel, ); if (sentenceAlignmentCacheRef.current.has(alignmentKey)) return; try { const audioBytes = Array.from(new Uint8Array(arrayBuffer)); const alignmentBody = { text: sentence, audio: audioBytes, }; void alignAudio(alignmentBody) .then(async (data) => { if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) { return; } const alignment = data.alignments[0] as TTSSentenceAlignment; sentenceAlignmentCacheRef.current.set(alignmentKey, alignment); const currentSentence = sentencesRef.current[currentIndexRef.current]; if (currentSentence === sentence) { setCurrentSentenceAlignment(alignment); setCurrentWordIndex(null); } }) .catch((err) => { console.warn('Alignment request failed:', err); }); } catch (err) { console.warn('Failed to start alignment request:', err); } }; const audioCacheKey = buildCacheKey( sentence, voice, speed, configTTSProvider, ttsModel, ); // Check if the audio is already cached const cachedAudio = audioCache.get(audioCacheKey); if (cachedAudio) { console.log('Using cached audio for sentence:', sentence.substring(0, 20)); // If we have audio but no alignment (e.g. after a // navigation or TTS reset), kick off a fresh alignment // request using the cached audio buffer. ensureAlignment(cachedAudio); return cachedAudio; } // If the user is already out of quota, avoid spamming requests. // Cached audio above is still allowed to play. if (isAtLimit) { if (!preload) { setIsPlaying(false); } return undefined; } try { console.log('Requesting audio for sentence:', sentence); // Create an AbortController for this request const controller = new AbortController(); activeAbortControllers.current.add(controller); const reqHeaders: TTSRequestHeaders = { 'Content-Type': 'application/json', 'x-openai-key': openApiKey || '', 'x-tts-provider': configTTSProvider, }; if (openApiBaseUrl) { reqHeaders['x-openai-base-url'] = openApiBaseUrl; } const reqBody: TTSRequestPayload = { text: sentence, voice, speed, format: 'mp3', model: ttsModel, instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined, }; // Allow one narrow client retry for transient browser->/api/tts transport failures. // HTTP failures are not retried client-side. const retryOptions: TTSRetryOptions = { maxRetries: 2, initialDelay: 300, maxDelay: 300, }; onTTSStart(); let arrayBuffer: TTSAudioBuffer; try { arrayBuffer = await withRetry( async () => { return await generateTTS(reqBody, reqHeaders, controller.signal); }, retryOptions ); } finally { onTTSComplete(); } // Remove the controller once the request is complete activeAbortControllers.current.delete(controller); // Cache the array buffer audioCache.set(audioCacheKey, arrayBuffer); // Fire-and-forget alignment request; do not block audio playback ensureAlignment(arrayBuffer); return arrayBuffer; } 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; })(); // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted:', sentence.substring(0, 20)); return; } // If a preload request fails, we should not flip the global playback state. // Otherwise the UI can lose the pause button while the current sentence // continues playing. if (!preload) { setIsPlaying(false); } // Handle daily quota exceeded (429 + Problem Details code) if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { // Avoid noisy toasts from background preloading; keep the user-facing error for active playback. if (!preload) { toast.error('Daily TTS limit reached.', { id: 'tts-limit-error', duration: 5000, }); } triggerRateLimit(); refreshRateLimit().catch(console.error); // Do NOT re-throw, just return undefined to stop playback gracefully return undefined; } // Avoid noisy toasts from background preloading. if (!preload) { toast.error('TTS failed. Skipped sentence and paused.', { id: 'tts-api-error', duration: 7000, }); } throw error; } }, [ voice, speed, ttsModel, ttsInstructions, audioCache, openApiKey, openApiBaseUrl, configTTSProvider, isEPUB, pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, epubWordHighlightEnabled, triggerRateLimit, refreshRateLimit, onTTSComplete, onTTSStart, isAtLimit ]); /** * 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} The URL of the processed audio */ const processSentence = useCallback(async (sentence: string, preload = false): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); // Check if there's a pending preload request for this sentence const pendingRequest = preloadRequests.current.get(sentence); if (pendingRequest) { console.log('Using pending preload request for:', sentence.substring(0, 20)); setIsProcessing(true); // Show processing state when using pending request // If this is not a preload request, remove it from the pending map if (!preload) { preloadRequests.current.delete(sentence); } return pendingRequest; } // Only set processing state if not preloading if (!preload) setIsProcessing(true); // Create the audio processing promise const processPromise = (async () => { try { const audioBuffer = await getAudio(sentence, preload); if (!audioBuffer) { // If quota or other handled error returns undefined, ensure we don't throw "No audio data" // Just return empty string to signal graceful failure/skip return ''; } // Convert to base64 data URI const bytes = new Uint8Array(audioBuffer); const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), ''); const base64String = btoa(binaryString); return `data:audio/mp3;base64,${base64String}`; } catch (error) { setIsProcessing(false); throw error; } })(); // If this is a preload request, store it in the map if (preload) { preloadRequests.current.set(sentence, processPromise); // Clean up the map entry once the promise resolves or rejects void processPromise .finally(() => { preloadRequests.current.delete(sentence); }) .catch(() => { // Prevent unhandled rejections from the cleanup-only chained promise. }); } return processPromise; }, [audioContext, getAudio]); /** * Plays the current sentence with Howl * * @param {string} sentence - The sentence to play */ const playSentenceWithHowl = useCallback(async (sentence: string, sentenceIndex: number) => { if (!sentence) { console.log('No sentence to play'); playbackInFlightRef.current = false; setIsProcessing(false); return; } const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = 1000; // 1 second const createHowl = async (retryCount = 0): Promise => { let playErrorAttempts = 0; // Get the processed audio data URI directly from processSentence const audioDataUri = await processSentence(sentence); if (!audioDataUri) { // Graceful exit for rate limit / abort / intentionally skipped sentence console.log('Skipping playback for sentence (no audio generated)'); return null; } // 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: [audioDataUri], 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) { 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) { setIsProcessing(false); startRateWatchdog(this); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'playing'; } }, onplayerror: function (this: Howl, soundId, error) { 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) { const actualError = error ?? soundId; console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount); console.log(`Retrying in ${delay}ms...`); // 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); if (retryHowl) { 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 (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 (isPlaying) { advance(); } }, onstop: function (this: Howl) { if (howlFinished) return; howlFinished = true; clearRateWatchdog(); playbackInFlightRef.current = false; setIsProcessing(false); this.unload(); } }); }; try { const howl = await createHowl(); 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, isAutoplayBlockedError, applyPlaybackRateToHowl, startRateWatchdog, clearRateWatchdog, ]); const playAudio = useCallback(async () => { const sentence = sentences[currentIndex]; const alignmentKey = buildCacheKey( sentence, voice, speed, configTTSProvider, ttsModel, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); if (cachedAlignment) { setCurrentSentenceAlignment(cachedAlignment); setCurrentWordIndex(null); } else { setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); } const howl = await playSentenceWithHowl(sentence, currentIndex); if (howl) { howl.play(); } }, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]); // 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 sequentially. * As soon as one preload finishes, the next one starts (up to lookahead window). */ const preloadNextAudio = useCallback(() => { const PRELOAD_LOOKAHEAD = 3; if (isAtLimit) return; const preloadFromOffset = (offset: number) => { if (offset > PRELOAD_LOOKAHEAD) return; const sentenceIndex = currentIndex + offset; const nextSentence = sentences[sentenceIndex]; if (!nextSentence) return; const nextKey = buildCacheKey( nextSentence, voice, speed, configTTSProvider, ttsModel, ); if (audioCache.has(nextKey)) { preloadFromOffset(offset + 1); return; } const pending = preloadRequests.current.get(nextSentence); if (pending) { void pending .finally(() => { preloadFromOffset(offset + 1); }) .catch(() => { // Prevent unhandled rejections from the chained preload progression. }); return; } void processSentence(nextSentence, true) .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; })(); // Ignore quota errors during preload. if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { console.error(`Error preloading sentence at offset ${offset}:`, error); } }) .finally(() => { preloadFromOffset(offset + 1); }); }; try { preloadFromOffset(1); } catch (error) { console.error('Error initiating preload:', error); } }, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]); /** * 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 abortAudio(); playbackInFlightRef.current = false; locationChangeHandlerRef.current = null; epubContinuationRef.current = null; continuationCarryRef.current.clear(); setIsPlaying(false); setCurrentIndex(0); setSentences([]); setCurrDocPage(1); setCurrDocPages(undefined); setIsProcessing(false); setIsEPUB(false); 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) => { abortAudio(); // Same autoplay-unlock issue as togglePlay when starting from a fresh load. unlockPlaybackOnUserGesture(); setCurrentIndex(index); setIsPlaying(true); }, [abortAudio, unlockPlaybackOnUserGesture]); /** * 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); 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]); /** * 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); 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]); /** * 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); 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]); /** * Provides the TTS context value to child components */ const value = useMemo(() => ({ isPlaying, isProcessing, currentSentence: sentences[currentIndex] || '', currentSentenceAlignment, currentWordIndex, currDocPage, currDocPageNumber, currDocPages, voice, availableVoices, togglePlay, skipForward, skipBackward, stop, pause, stopAndPlayFromIndex, setText, setCurrDocPages, setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, skipToLocation, registerLocationChangeHandler, registerVisualPageChangeHandler, setIsEPUB }), [ isPlaying, isProcessing, sentences, currentIndex, currDocPage, currDocPageNumber, currDocPages, voice, availableVoices, togglePlay, skipForward, skipBackward, stop, pause, stopAndPlayFromIndex, setText, setCurrDocPages, setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, skipToLocation, registerLocationChangeHandler, registerVisualPageChangeHandler, setIsEPUB, currentSentenceAlignment, currentWordIndex ]); // Use media session hook useMediaSession({ togglePlay, skipForward, skipBackward, }); // Load last location on mount for both EPUB and PDF. // 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) => { console.log('Setting last location:', lastLocation); if (isEPUB && locationChangeHandlerRef.current) { // For EPUB documents, use the location change handler locationChangeHandlerRef.current(lastLocation); return; } if (!isEPUB) { // For PDF documents, parse the location as "page:sentence" try { const [pageStr, sentenceIndexStr] = lastLocation.split(':'); const page = parseInt(pageStr, 10); const sentenceIndex = parseInt(sentenceIndexStr, 10); if (!isNaN(page) && !isNaN(sentenceIndex)) { console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`); // Skip to the page first, then the sentence index will be restored when setText is called setCurrDocPage(page); // Store the sentence index to be used when text is loaded setPendingRestoreIndex(sentenceIndex); } } catch (error) { console.warn('Error parsing PDF location:', error); } } }; const load = async () => { if (authEnabled) { 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); return; } } catch (error) { console.warn('Error loading remote progress:', error); } } try { const local = await getLastDocumentLocation(docId); if (!cancelled && local) { applyLocation(local); } } catch (error) { console.warn('Error loading local last location:', error); } }; load(); return () => { cancelled = true; }; }, [id, isEPUB, currentReaderType, authEnabled]); // Save current position periodically for PDFs useEffect(() => { if (id && !isEPUB && sentences.length > 0) { const location = `${currDocPageNumber}:${currentIndex}`; const timeoutId = setTimeout(() => { console.log(`Saving PDF position: ${location}`); setLastDocumentLocation(id as string, location).catch(error => { console.warn('Error saving PDF location:', error); }); if (authEnabled) { scheduleDocumentProgressSync({ documentId: id as string, readerType: currentReaderType, location, }); } }, 1000); // Debounce saves by 1 second return () => clearTimeout(timeoutId); } }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); /** * 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; }