diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1644c77..893ca1f 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -36,6 +36,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; +import { useWakeLock } from '@/hooks/audio/useWakeLock'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { isKokoroModel } from '@/utils/voice'; @@ -1144,6 +1145,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement playAudio, }); + // Prevent screen from sleeping during audio playback (mobile Safari) + useWakeLock(isPlaying); + // Track the current word index during playback using Howler's seek position useEffect(() => { if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) { diff --git a/src/hooks/audio/useWakeLock.ts b/src/hooks/audio/useWakeLock.ts new file mode 100644 index 0000000..61fca31 --- /dev/null +++ b/src/hooks/audio/useWakeLock.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useCallback } from 'react'; + +/** + * Requests a screen wake lock to prevent the display from sleeping + * during audio playback. Automatically re-acquires the lock when the + * page regains visibility (Safari releases it on visibility change). + */ +export function useWakeLock(isPlaying: boolean) { + const wakeLockRef = useRef(null); + + const requestWakeLock = useCallback(async () => { + if (!('wakeLock' in navigator)) return; + + try { + wakeLockRef.current = await navigator.wakeLock.request('screen'); + wakeLockRef.current.addEventListener('release', () => { + wakeLockRef.current = null; + }); + } catch (err) { + // Wake lock request can fail (e.g. low battery mode) + console.warn('Wake lock request failed:', err); + } + }, []); + + const releaseWakeLock = useCallback(async () => { + if (wakeLockRef.current) { + try { + await wakeLockRef.current.release(); + } catch { + // Ignore release errors + } + wakeLockRef.current = null; + } + }, []); + + // Acquire/release based on playback state + useEffect(() => { + if (isPlaying) { + requestWakeLock(); + } else { + releaseWakeLock(); + } + + return () => { + releaseWakeLock(); + }; + }, [isPlaying, requestWakeLock, releaseWakeLock]); + + // Re-acquire wake lock when page becomes visible again + // (Safari releases wake locks when the page is hidden) + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && isPlaying) { + requestWakeLock(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isPlaying, requestWakeLock]); +}