diff --git a/src/hooks/useWakeLock.ts b/src/hooks/useWakeLock.ts new file mode 100644 index 0000000..28edb93 --- /dev/null +++ b/src/hooks/useWakeLock.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef } from 'react'; + +// Minimal typing so we don't depend on the DOM lib shipping WakeLock types. +interface WakeLockSentinelLike { release(): Promise } +interface WakeLockLike { request(type: 'screen'): Promise } +function getWakeLock(): WakeLockLike | undefined { + return (navigator as unknown as { wakeLock?: WakeLockLike }).wakeLock; +} + +/** + * Keep the screen awake during a long operation (e.g. model download / inference) + * so the phone doesn't auto-lock — which on mobile can discard the tab and wipe + * in-memory state. The lock is auto-released by the browser when the page is + * hidden, so we re-acquire it on return to the foreground. + */ +export function useWakeLock() { + const sentinelRef = useRef(null); + const wantedRef = useRef(false); + + const acquire = useCallback(async () => { + const wl = getWakeLock(); + if (!wl || sentinelRef.current) return; + try { sentinelRef.current = await wl.request('screen'); } catch { /* denied/unsupported */ } + }, []); + + const request = useCallback(async () => { wantedRef.current = true; await acquire(); }, [acquire]); + + const release = useCallback(() => { + wantedRef.current = false; + sentinelRef.current?.release().catch(() => {}); + sentinelRef.current = null; + }, []); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible' && wantedRef.current && !sentinelRef.current) void acquire(); + }; + document.addEventListener('visibilitychange', onVisible); + return () => { + document.removeEventListener('visibilitychange', onVisible); + sentinelRef.current?.release().catch(() => {}); + sentinelRef.current = null; + }; + }, [acquire]); + + return { request, release }; +} diff --git a/src/islands/media/VoiceToText.tsx b/src/islands/media/VoiceToText.tsx index 0e9ab34..cfb933c 100644 --- a/src/islands/media/VoiceToText.tsx +++ b/src/islands/media/VoiceToText.tsx @@ -7,8 +7,10 @@ import { ProgressBar } from '@/components/ui/ProgressBar'; import { CopyButton } from '@/components/ui/CopyButton'; import { downloadService } from '@/services/download'; import { useAudioRecorder } from '@/hooks/useAudioRecorder'; +import { useWakeLock } from '@/hooks/useWakeLock'; import { decodeToMono16k } from '@/tools/media/stt-audio.lib'; import { transcribeInWorker } from '@/tools/media/stt.client'; +import { saveRecording, loadRecording } from '@/tools/media/recording-store'; import { type SttModelId } from '@/tools/media/stt.engine'; import { segmentsToText, @@ -56,6 +58,8 @@ type Tab = 'text' | 'timestamped' | 'subtitles'; export default function VoiceToText() { const recorder = useAudioRecorder(); + const wakeLock = useWakeLock(); + const [restored, setRestored] = useState(false); const [audioBlob, setAudioBlob] = useState(null); const [audioUrl, setAudioUrl] = useState(''); const [model, setModel] = useState('onnx-community/whisper-tiny.en'); @@ -94,7 +98,7 @@ export default function VoiceToText() { return () => clearInterval(id); }, [transcribing]); - const setAudio = (blob: Blob) => { + const setAudio = (blob: Blob, persist = true) => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); const url = URL.createObjectURL(blob); urlRef.current = url; @@ -102,8 +106,18 @@ export default function VoiceToText() { setAudioBlob(blob); setSegments(null); setError(''); + if (persist) { setRestored(false); void saveRecording(blob); } }; + // Restore the last recording (survives a mobile tab discard / reload). + useEffect(() => { + let cancelled = false; + loadRecording().then(blob => { + if (!cancelled && blob) { setAudio(blob, false); setRestored(true); } + }); + return () => { cancelled = true; }; + }, []); + const onDrop = (files: File[]) => { const f = files.find(x => x.type.startsWith('audio/') || x.type.startsWith('video/')); if (f) setAudio(f); @@ -138,6 +152,7 @@ export default function VoiceToText() { setSegments(null); setTranscribing(true); setModelProgress(null); // only shows once real download progress fires (first load) + void wakeLock.request(); // keep the screen on so the phone doesn't lock + discard the tab try { const audio = await decodeToMono16k(audioBlob); const isMultilingual = MODELS.find(m => m.value === model)?.multilingual; @@ -156,6 +171,7 @@ export default function VoiceToText() { } finally { setTranscribing(false); setModelProgress(null); + wakeLock.release(); } }; @@ -200,7 +216,10 @@ export default function VoiceToText() { {recorder.error && {recorder.error.message}} {audioUrl && ( -