From 2ed37f78c7967b31a38a5872ba877af9c3014df7 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Apr 2026 22:35:54 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20announce=20chapter=20reliability=20?= =?UTF-8?q?=E2=80=94=20timeout=20fallback=20+=20eager=20chapters=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues causing announce to silently fail or permanently block navigation: 1. No hard timeout fallback on speechSynthesis.speak(): Chrome Android (and some desktop) silently drops utterances not triggered within a user-gesture window. If both onend and onerror fail to fire (a known browser bug), doNavigate() was never called and the chapter transition was permanently lost. Added an 8-second setTimeout fallback (safeNavigate) that forces navigation if the speech engine never resolves. safeNavigate is idempotent — guarded by a 'navigated' flag so it only fires once even if onend, onerror, and the timeout all fire. 2. audioStore.chapters only written inside startPlayback(): The onended handler reads audioStore.chapters to build the utterance text (Chapter N — Title). If auto-next navigated to this chapter and the user never manually pressed play (startPlayback was never called), chapters held whatever the previous AudioPlayer had written — potentially stale or empty on a book switch. Added a reactive $effect that keeps chapters in sync whenever the prop changes, same pattern as nextChapter. --- ui/src/lib/components/AudioPlayer.svelte | 6 ++++++ ui/src/routes/+layout.svelte | 21 +++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index e52fa8f..0060054 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -248,6 +248,12 @@ audioStore.nextChapter = nextChapter ?? null; }); + // Keep chapters list in store up to date so the layout's onended announce + // can find titles even if startPlayback() hasn't been called yet on this mount. + $effect(() => { + if (chapters.length > 0) audioStore.chapters = chapters; + }); + // Keep voices in store up to date whenever prop changes. $effect(() => { if (voices.length > 0) audioStore.voices = voices; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 0c88659..4a38159 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -406,8 +406,25 @@ const text = `Chapter ${targetChapter}${titlePart}`; window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); - utterance.onend = doNavigate; - utterance.onerror = doNavigate; + + // Guard: ensure doNavigate can only fire once even if both + // onend and the timeout fire, or onerror fires after onend. + let navigated = false; + const safeNavigate = () => { + if (navigated) return; + navigated = true; + clearTimeout(announceTimeout); + doNavigate(); + }; + + // Hard fallback: if speechSynthesis silently drops the utterance + // (common on Chrome Android due to gesture policy, or when the + // browser is busy fetching the next chapter's audio), navigate + // anyway after a generous 8-second window. + const announceTimeout = setTimeout(safeNavigate, 8000); + + utterance.onend = safeNavigate; + utterance.onerror = safeNavigate; window.speechSynthesis.speak(utterance); } else { doNavigate();