feat: Remove explicit background state and implementing text prefetching for smoother page/section transitions.

This commit is contained in:
Richard R 2026-02-18 00:05:41 -07:00
parent 3ede092adc
commit 30ad62aa41
5 changed files with 60 additions and 53 deletions

View file

@ -82,7 +82,7 @@ interface EPUBContextType {
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
const EPUB_CONTINUATION_CHARS = 600;
const EPUB_CONTINUATION_CHARS = 5000;
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');

View file

@ -101,7 +101,6 @@ interface PDFContextType {
// Create the context
const PDFContext = createContext<PDFContextType | undefined>(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) {

View file

@ -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<number | null>(null);
const sentencesRef = useRef<string[]>([]);
const currentIndexRef = useRef(0);
const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { });
const prefetchedLocationTextRef = useRef<Map<string, string>>(new Map());
const pendingNextLocationRef = useRef<TTSLocation | undefined>(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,

View file

@ -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;
}

View file

@ -23,7 +23,6 @@ export interface TTSError {
export interface TTSPlaybackState {
isPlaying: boolean;
isProcessing: boolean;
isBackgrounded: boolean;
currentSentence: string;
currDocPage: TTSLocation;
currDocPageNumber: number;