diff --git a/web/templates/entry_view.html b/web/templates/entry_view.html index 05a4cf6..3386e7e 100644 --- a/web/templates/entry_view.html +++ b/web/templates/entry_view.html @@ -285,47 +285,38 @@ const ttsBtn = document.getElementById('ttsBtn'); if (!ttsBtn) return; - const TTS_URL = "{{ tts_url|default:'' }}"; // staff-only when present - let ttsAudio = null; // holds current playback (Audio or shim) + const TTS_URL = "{{ tts_url|default:'' }}"; // empty for non-staff → browser TTS - function stopPlayback(){ - try { - if (window.speechSynthesis) speechSynthesis.cancel(); - } catch(e){} - try { - if (ttsAudio && typeof ttsAudio.pause === 'function') { - ttsAudio.pause(); - ttsAudio.currentTime = 0; - } - if (ttsAudio && typeof ttsAudio.stop === 'function') { - ttsAudio.stop(); - } - } catch(e){} - ttsAudio = null; + // -------- persistent audio + helpers -------- + let audioEl = null; + let currentURL = null; // objectURL for OpenAI response + let playing = false; + let fetchCtrl = null; + + function ensureAudio(){ + if (!audioEl) { + audioEl = new Audio(); + audioEl.setAttribute('playsinline',''); // iOS + audioEl.preload = 'auto'; + audioEl.addEventListener('ended', () => { playing = false; cleanupURL(); }); + audioEl.addEventListener('pause', () => { /* keep playing=false only if really paused */ }); + } + return audioEl; } - async function playOpenAITTS() { - const r = await fetch(TTS_URL, { credentials: 'same-origin', cache: 'no-store' }); - if (!r.ok) { - const msg = await r.text().catch(()=> String(r.status)); - throw new Error(`HTTP ${r.status}: ${msg.slice(0,200)}`); - } - const ct = (r.headers.get('content-type') || '').toLowerCase(); - if (!ct.startsWith('audio/')) { - const preview = await r.text().catch(()=> '(non-audio response)'); - throw new Error(`Unexpected content-type "${ct}". Preview: ${preview.slice(0,200)}`); - } - const blob = await r.blob(); - const url = URL.createObjectURL(blob); - - const audio = new Audio(url); - audio.preload = 'auto'; - audio.setAttribute('playsinline',''); - audio.onended = () => { URL.revokeObjectURL(url); ttsAudio = null; }; - await audio.play(); - ttsAudio = audio; + function cleanupURL(){ + if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; } } + function stopAll(){ + try { if (fetchCtrl) fetchCtrl.abort(); } catch(_){} + fetchCtrl = null; + if (audioEl) { audioEl.pause(); audioEl.currentTime = 0; } + cleanupURL(); + playing = false; + } + + // -------- text builders -------- function buildCombinedText(){ const ill = (document.getElementById('illustration-text')?.innerText || '').trim(); const app = (document.getElementById('application-text')?.innerText || '').trim(); @@ -338,43 +329,83 @@ if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) { throw new Error('Browser TTS not supported.'); } - speechSynthesis.cancel(); + window.speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(text); u.rate = 1.0; u.pitch = 1.0; u.volume = 1.0; - u.onend = () => { ttsAudio = null; }; speechSynthesis.speak(u); - - // Create a tiny shim so our toggle can "stop" it - ttsAudio = { - pause: () => speechSynthesis.cancel(), - currentTime: 0 - }; + playing = true; } - ttsBtn.addEventListener('click', async () => { - try { - // Toggle: if already playing, stop instead - if (ttsAudio) { - stopPlayback(); - showToast("Playback stopped"); - return; - } + async function playOpenAITTS(){ + stopAll(); + fetchCtrl = new AbortController(); + const r = await fetch(TTS_URL, { credentials:'same-origin', cache:'no-store', signal: fetchCtrl.signal }); + if (!r.ok) { + const preview = await r.text().catch(()=> String(r.status)); + throw new Error(`HTTP ${r.status}: ${preview.slice(0,200)}`); + } + const ct = (r.headers.get('content-type') || '').toLowerCase(); + if (!ct.startsWith('audio/')) { + const preview = await r.text().catch(()=> '(non-audio response)'); + throw new Error(`Unexpected content-type "${ct}". Preview: ${preview.slice(0,200)}`); + } + + const blob = await r.blob(); + currentURL = URL.createObjectURL(blob); + + const a = ensureAudio(); + a.src = currentURL; + a.currentTime = 0; + a.muted = false; + + try { + await a.play(); // <- may throw NotAllowedError on some iOS/Safari cases + playing = true; + } catch (err) { + // Autoplay/permission style block (Safari/iOS or Chrome heuristics) + if (err && (err.name === 'NotAllowedError' || + /not allowed|denied permission/i.test(err.message))) { + showToast("Audio was blocked. Make sure the phone isn't on Silent and tap the speaker again."); + } else { + throw err; + } + } + } + + // -------- button: toggle behavior -------- + ttsBtn.addEventListener('click', async () => { + // Toggle OFF if something is already playing (either engine) + if (playing) { + try { + stopAll(); + if ('speechSynthesis' in window) window.speechSynthesis.cancel(); + } finally { + playing = false; + showToast("Stopped."); + } + return; + } + + try { ttsBtn.disabled = true; if (TTS_URL) { await playOpenAITTS(); - showToast("Using OpenAI TTS"); + if (playing) showToast("Playing with OpenAI TTS"); } else { speakBrowserTTS(); - showToast("Using Browser TTS"); + showToast("Playing with Browser TTS"); } } catch (err) { - alert('TTS error: ' + (err && err.message ? err.message : String(err))); + showToast("TTS error: " + (err?.message || String(err))); } finally { ttsBtn.disabled = false; } }); + + // Safety: stop audio when navigating away + window.addEventListener('pagehide', stopAll); })();