From 0c7a845952e6301a70c881a54ea47651856f40f3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 16 Apr 2026 15:47:39 -0600 Subject: [PATCH] feat(tts): add pauseActiveHowl helper and improve playback pause/resume handling Introduce pauseActiveHowl to centralize audio pause logic, ensuring consistent cleanup and media session state updates. Update pause and togglePlay to use the new helper, enabling accurate resume of paused audio and improved error handling. Enhance Howl onpause event to synchronize playback state and clear timeouts. --- src/contexts/TTSContext.tsx | 72 ++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index da731e3..e771de2 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -413,6 +413,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const sentenceAlignmentCacheRef = useRef>(new Map()); const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState(); const [currentWordIndex, setCurrentWordIndex] = useState(null); + const isPlayingRef = useRef(false); const sentencesRef = useRef([]); const currentIndexRef = useRef(0); const setTextRef = useRef<(text: string, options?: boolean | SetTextOptions) => void>(() => { }); @@ -530,6 +531,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement currentIndexRef.current = currentIndex; }, [currentIndex]); + useEffect(() => { + isPlayingRef.current = isPlaying; + }, [isPlaying]); + /** * Processes text into sentences using the shared NLP utility * @@ -572,14 +577,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); }, [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 * Used for external control of playback state */ const pause = useCallback(() => { - abortAudio(); + pauseActiveHowl(); setIsPlaying(false); - }, [abortAudio]); + }, [pauseActiveHowl]); /** * Navigates to a specific location in the document @@ -908,15 +939,31 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement */ const togglePlay = useCallback(() => { if (isPlaying) { - abortAudio(); + pauseActiveHowl(); setIsPlaying(false); return; } // Ensure audio is unlocked while we're still in the click/tap handler. 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); - }, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]); + }, [activeHowl, applyPlaybackRateToHowl, isPlaying, pauseActiveHowl, unlockPlaybackOnUserGesture]); /** @@ -1348,6 +1395,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement 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) { const actualError = error ?? soundId; console.warn('Howl playback error:', actualError); @@ -1551,6 +1611,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const howl = await playSentenceWithHowl(sentence, currentIndex); if (howl) { + if (!isPlayingRef.current) { + playbackInFlightRef.current = false; + return; + } howl.play(); } }, [sentences, currentIndex, playSentenceWithHowl, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]);