feat(TTS): implement pause functionality with state preservation

This commit is contained in:
Richard R 2026-04-16 11:52:15 -06:00
parent 893f74f038
commit a8d2e416ca

View file

@ -413,6 +413,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map()); const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>(); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null); const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
const isPlayingRef = useRef(false);
const sentencesRef = useRef<string[]>([]); const sentencesRef = useRef<string[]>([]);
const currentIndexRef = useRef(0); const currentIndexRef = useRef(0);
const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { }); const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { });
@ -530,6 +531,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
currentIndexRef.current = currentIndex; currentIndexRef.current = currentIndex;
}, [currentIndex]); }, [currentIndex]);
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
/** /**
* Processes text into sentences using the shared NLP utility * Processes text into sentences using the shared NLP utility
* *
@ -572,14 +577,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentWordIndex(null); setCurrentWordIndex(null);
}, [activeHowl, clearRateWatchdog]); }, [activeHowl, clearRateWatchdog]);
/**
* 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]);
/** /**
* Pauses the current audio playback * Pauses the current audio playback
* Used for external control of playback state * Used for external control of playback state
*/ */
const pause = useCallback(() => { const pause = useCallback(() => {
abortAudio(); pauseActiveHowl();
setIsPlaying(false); setIsPlaying(false);
}, [abortAudio]); }, [pauseActiveHowl]);
/** /**
* Navigates to a specific location in the document * Navigates to a specific location in the document
@ -908,15 +939,31 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/ */
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {
if (isPlaying) { if (isPlaying) {
abortAudio(); pauseActiveHowl();
setIsPlaying(false); setIsPlaying(false);
return; return;
} }
// Ensure audio is unlocked while we're still in the click/tap handler. // Ensure audio is unlocked while we're still in the click/tap handler.
unlockPlaybackOnUserGesture(); unlockPlaybackOnUserGesture();
// 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); setIsPlaying(true);
}, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]); }, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, unlockPlaybackOnUserGesture]);
/** /**
@ -1348,6 +1395,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
navigator.mediaSession.playbackState = 'playing'; navigator.mediaSession.playbackState = 'playing';
} }
}, },
onpause: function () {
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) { onplayerror: function (this: Howl, soundId, error) {
const actualError = error ?? soundId; const actualError = error ?? soundId;
console.warn('Howl playback error:', actualError); console.warn('Howl playback error:', actualError);
@ -1551,6 +1611,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const howl = await playSentenceWithHowl(sentence, currentIndex); const howl = await playSentenceWithHowl(sentence, currentIndex);
if (howl) { if (howl) {
if (!isPlayingRef.current) {
playbackInFlightRef.current = false;
return;
}
howl.play(); howl.play();
} }
}, [sentences, currentIndex, playSentenceWithHowl, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]); }, [sentences, currentIndex, playSentenceWithHowl, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]);