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