diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 77de036..54530d9 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -82,7 +82,7 @@ interface EPUBContextType { const EPUBContext = createContext(undefined); -const EPUB_CONTINUATION_CHARS = 600; +const EPUB_CONTINUATION_CHARS = 5000; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index eff105c..0df17af 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -101,7 +101,6 @@ interface PDFContextType { // Create the context const PDFContext = createContext(undefined); -const CONTINUATION_PREVIEW_CHARS = 600; const EMPTY_TEXT_RETRY_DELAY_MS = 120; const EMPTY_TEXT_MAX_RETRIES = 6; @@ -283,7 +282,7 @@ export function PDFProvider({ children }: { children: ReactNode }) { setTTSText(text, { location: currDocPageNumber, nextLocation: nextPageNumber, - nextText: nextText?.slice(0, CONTINUATION_PREVIEW_CHARS), + nextText: nextText, }); } } catch (error) { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 3e0e425..23e0a47 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -36,7 +36,6 @@ 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 { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client/api/audiobooks'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/shared/nlp'; import { isKokoroModel } from '@/lib/shared/kokoro'; @@ -388,6 +387,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement 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); @@ -575,6 +577,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement */ 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) { @@ -584,6 +587,25 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // 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; } @@ -593,8 +615,27 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // 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(currDocPageNumber + (nextIndex >= sentences.length ? 1 : -1)); + skipToLocation(targetLocation); return; } @@ -692,6 +733,17 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement 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; @@ -780,6 +832,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }); }, [isPlaying, handleBlankSection, abortAudio, splitTextToTtsBlocksLocal, pendingRestoreIndex, isEPUB, smartSentenceSplitting]); + useEffect(() => { + setTextRef.current = setText; + }, [setText]); + /** * Toggles the playback state between playing and paused */ @@ -1408,13 +1464,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]); - // Place useBackgroundState after playAudio is defined - const isBackgrounded = useBackgroundState({ - activeHowl, - isPlaying, - playAudio, - }); - // Track the current word index during playback using Howler's seek position useEffect(() => { if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) { @@ -1510,7 +1559,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (isProcessing) return; // Don't proceed if processing audio if (!sentences[currentIndex]) return; // Don't proceed if no sentence to play if (activeHowl) return; // Don't proceed if audio is already playing - if (isBackgrounded) return; // Don't proceed if backgrounded // Start playing current sentence playAudio(); @@ -1530,7 +1578,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currentIndex, sentences, activeHowl, - isBackgrounded, playAudio, preloadNextAudio, abortAudio @@ -1674,7 +1721,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const value = useMemo(() => ({ isPlaying, isProcessing, - isBackgrounded, currentSentence: sentences[currentIndex] || '', currentSentenceAlignment, currentWordIndex, @@ -1700,7 +1746,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement }), [ isPlaying, isProcessing, - isBackgrounded, sentences, currentIndex, currDocPage, diff --git a/src/hooks/audio/useBackgroundState.ts b/src/hooks/audio/useBackgroundState.ts deleted file mode 100644 index 12c608b..0000000 --- a/src/hooks/audio/useBackgroundState.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Howl } from 'howler'; - -interface UseBackgroundStateProps { - activeHowl: Howl | null; - isPlaying: boolean; - playAudio: () => void; -} - -export function useBackgroundState({ activeHowl, isPlaying, playAudio }: UseBackgroundStateProps) { - const [isBackgrounded, setIsBackgrounded] = useState(false); - - useEffect(() => { - const handleVisibilityChange = () => { - setIsBackgrounded(document.hidden); - if (document.hidden) { - // When backgrounded, pause audio but maintain isPlaying state - if (activeHowl) { - activeHowl.pause(); - } - } else if (isPlaying) { - // When returning to foreground, resume from current position - if (activeHowl) { - activeHowl.play(); - } - } - }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [isPlaying, activeHowl, playAudio]); - - return isBackgrounded; -} \ No newline at end of file diff --git a/src/types/tts.ts b/src/types/tts.ts index 5a944a4..089bdc5 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -23,7 +23,6 @@ export interface TTSError { export interface TTSPlaybackState { isPlaying: boolean; isProcessing: boolean; - isBackgrounded: boolean; currentSentence: string; currDocPage: TTSLocation; currDocPageNumber: number;