From 6ce5eec98bc185583e6f3c2e5c83b9f29b4b7933 Mon Sep 17 00:00:00 2001 From: William Chong Date: Fri, 27 Mar 2026 15:23:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20lock=20screen=20pause=20no?= =?UTF-8?q?t=20stopping=20TTS=20playback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove custom auto-resume logic that could not distinguish lock screen pause (user intent) from OS audio interruption, causing pause from media controls to be immediately undone. expo-audio handles interruption recovery natively via AVAudioSession (iOS) and AudioFocus (Android). --- services/audio-bridge.native.ts | 55 ++++++--------------------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/services/audio-bridge.native.ts b/services/audio-bridge.native.ts index 9fa62b2..b084303 100644 --- a/services/audio-bridge.native.ts +++ b/services/audio-bridge.native.ts @@ -48,16 +48,12 @@ let loadPromise: Promise = Promise.resolve(); let notifyWebView: SendToWebView | null = null; let lastSentState = ''; -// Auto-resume & stuck detection state -const MAX_AUTO_RESUME_RETRIES = 3; +// Stuck detection state // Longer than web player's 5s because iOS uses blocking=1 URLs where the // server generates the full TTS audio before responding. const STUCK_TIMEOUT_MS = 15000; let active = false; -let userPaused = false; let audible = false; -let autoResumeRetries = 0; -let autoResumeTimer: ReturnType | null = null; let stuckTimer: ReturnType | null = null; let stuckRetried = false; let errored = false; @@ -142,13 +138,6 @@ function getOrCreatePlayers(): AudioPlayer { return getActivePlayer()!; } -function clearAutoResumeTimer(): void { - if (autoResumeTimer) { - clearTimeout(autoResumeTimer); - autoResumeTimer = null; - } -} - function clearStuckTimer(): void { if (stuckTimer) { clearTimeout(stuckTimer); @@ -160,8 +149,6 @@ function resetRecoveryState(): void { audible = false; errored = false; stuckRetried = false; - autoResumeRetries = 0; - clearAutoResumeTimer(); } function armStuckTimer(): void { @@ -284,7 +271,6 @@ async function doLoad(msg: LoadMessage): Promise { currentRate = msg.rate; lastFinishTime = 0; active = true; - userPaused = false; clearStuckTimer(); resetIdle(); @@ -297,28 +283,20 @@ async function doLoad(msg: LoadMessage): Promise { export function handlePause(): void { active = false; - userPaused = true; audible = false; - clearAutoResumeTimer(); clearStuckTimer(); getActivePlayer()?.pause(); } export function handleResume(): void { - clearAutoResumeTimer(); - autoResumeRetries = 0; active = true; - userPaused = false; errored = false; stuckRetried = false; - // No armStuckTimer() — on resume the source is already buffered. - // OS-interruption stalls are handled by the auto-resume retry loop. getActivePlayer()?.play(); } export function handleStop(): void { active = false; - userPaused = false; resetRecoveryState(); clearStuckTimer(); // Skip replace(null) — iOS expo-audio cannot cast null to AudioSource. @@ -337,7 +315,6 @@ export function handleSkipTo(index: number): void { const player = getActivePlayer(); if (!player || index < 0 || index >= queue.length) return; active = true; - userPaused = false; lastFinishTime = 0; const lastIndex = currentIndex; @@ -381,7 +358,6 @@ export function registerEventListeners(sendToWebView: SendToWebView) { if (status.playbackState === 'failed') { errored = true; clearStuckTimer(); - clearAutoResumeTimer(); notifyWebView?.({ type: 'error', message: 'Playback failed' }); return; } @@ -400,32 +376,20 @@ export function registerEventListeners(sendToWebView: SendToWebView) { notifyWebView?.({ type: 'playbackState', state }); } - // Audio reached playing state — clear stuck timer, reset resume retries + // NOTE: Do NOT add auto-resume logic here (e.g. detecting unexpected pauses + // and calling play() after a timeout). expo-audio already handles OS audio + // interruption recovery natively — iOS via AVAudioSession.interruptionNotification + // with shouldResume, Android via AUDIOFOCUS_GAIN. A custom auto-resume cannot + // distinguish lock screen pause (user intent) from OS interruption, causing + // lock screen pause to be ineffective and the queue to keep advancing. + + // Audio reached playing state — clear stuck timer if (state === 'playing') { audible = true; errored = false; - autoResumeRetries = 0; clearStuckTimer(); } - // Detect unexpected pause (OS interruption: phone call, Siri, other app). - // The `audible` guard ensures this only fires on the playing→paused transition, - // not on every subsequent paused status tick. - if (state === 'paused' && audible && !userPaused && active && !errored - && !autoResumeTimer && !status.didJustFinish) { - audible = false; - if (autoResumeRetries < MAX_AUTO_RESUME_RETRIES) { - autoResumeRetries += 1; - autoResumeTimer = setTimeout(() => { - autoResumeTimer = null; - if (active && !audible && !userPaused && !errored) { - audible = true; // re-arm so next paused status can trigger another retry - getActivePlayer()?.play(); - } - }, 1000); - } - } - // Trigger preload once playback starts if (state === 'playing' && preload.readyIndex < 0 && preload.loadingIndex < 0) { preloadNext(); @@ -455,7 +419,6 @@ export function registerEventListeners(sendToWebView: SendToWebView) { clearStuckTimer(); resetIdle(); active = false; - userPaused = false; resetRecoveryState(); notifyWebView = null; };