From d16eef7ae005d17dee37529f35a2802704126a8c Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 18:35:45 +0700 Subject: [PATCH] feat(voice-to-text): keep screen awake + persist recording across reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: while the model downloads, the phone screen locks, the browser discards the backgrounded tab, and on return the recording is gone (a general mobile SPA state-loss pattern). - useWakeLock: hold a screen Wake Lock during transcription/download so the phone doesn't auto-lock (re-acquired on返回 to foreground). Prevents the discard. - recording-store (IndexedDB): persist the last recording and restore it on load, so even if the tab IS discarded, the recording comes back ('Restored your last recording.'). Blobs can't live in localStorage, hence IndexedDB. 545 tests · lint clean · build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useWakeLock.ts | 47 ++++++++++++++++++++++++ src/islands/media/VoiceToText.tsx | 23 ++++++++++-- src/tools/media/recording-store.ts | 58 ++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useWakeLock.ts create mode 100644 src/tools/media/recording-store.ts 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 && ( -