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();