Fix listener stacking across HTMX swaps and wire auto-next chapter playback
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
Admin
2026-03-02 09:35:39 +05:00
parent decf22f258
commit e4ab8664e9

View File

@@ -2081,8 +2081,19 @@ const chapterTmpl = `
} catch(_) {} } catch(_) {}
})(); })();
// ── Teardown previous chapter's script instance ───────────────────────────────
// Each HTMX swap re-runs this IIFE on the same persistent audio element.
// We use a global generation counter so stale closures become no-ops immediately.
window.__ttsGen = (window.__ttsGen || 0) + 1;
var myGen = window.__ttsGen;
function stale() { return window.__ttsGen !== myGen; }
// Remove the previous chapter's htmx:beforeSwap listener before adding our own.
if (window.__ttsBeforeSwap) {
document.body.removeEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
}
// ── DOM refs ───────────────────────────────────────────────────────────────── // ── DOM refs ─────────────────────────────────────────────────────────────────
// The hidden audio element is used as a proxy; the native one is shown after src loads.
var audio = document.getElementById('tts-audio-native'); var audio = document.getElementById('tts-audio-native');
var statusEl = document.getElementById('tts-status'); var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar'); var statusBar = document.getElementById('tts-status-bar');
@@ -2138,7 +2149,12 @@ const chapterTmpl = `
closeChapterList(); closeChapterList();
settingsPanel.style.display = open ? 'none' : 'block'; settingsPanel.style.display = open ? 'none' : 'block';
}; };
document.addEventListener('click', function (e) { // Re-register the document click-outside handler each swap by replacing via a named ref.
if (window.__ttsClickOutside) {
document.removeEventListener('click', window.__ttsClickOutside);
}
window.__ttsClickOutside = function (e) {
if (stale()) { document.removeEventListener('click', window.__ttsClickOutside); return; }
var sb = document.getElementById('settings-btn'); var sb = document.getElementById('settings-btn');
if (settingsPanel.style.display !== 'none' && if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) { !settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) {
@@ -2149,7 +2165,8 @@ const chapterTmpl = `
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) { e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList(); closeChapterList();
} }
}); };
document.addEventListener('click', window.__ttsClickOutside);
// ── localStorage settings ───────────────────────────────────────────────────── // ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed'; var LS_SPEED = 'tts_speed';
@@ -2172,20 +2189,23 @@ const chapterTmpl = `
})(); })();
speedSlider.addEventListener('input', function () { speedSlider.addEventListener('input', function () {
if (stale()) return;
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7'; speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value); localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false; prefetchFired = false;
}); });
voiceSel.addEventListener('change', function () { voiceSel.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_VOICE, voiceSel.value); localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false; prefetchFired = false;
}); });
autoplayChk.addEventListener('change', function () { autoplayChk.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
}); });
// ── paragraph indexing (highlight only) ────────────────────────────────────── // ── paragraph indexing (highlight only) ──────────────────────────────────────
var paras = Array.prototype.slice.call(article.querySelectorAll('p')); var paras = Array.prototype.slice.call((article || {querySelectorAll: function(){return[];}}).querySelectorAll('p'));
var activePara = null; var activePara = null;
function highlightPara(idx) { function highlightPara(idx) {
@@ -2211,6 +2231,7 @@ const chapterTmpl = `
audio.style.display = 'none'; audio.style.display = 'none';
} }
function setPlaying() { function setPlaying() {
if (stale()) return;
setPlayIcon('▮▮'); setPlayIcon('▮▮');
playerPlayBtn.disabled = false; playerPlayBtn.disabled = false;
voiceSel.disabled = false; voiceSel.disabled = false;
@@ -2221,6 +2242,7 @@ const chapterTmpl = `
audio.style.display = ''; audio.style.display = '';
} }
function setPaused() { function setPaused() {
if (stale()) return;
setPlayIcon('▶'); setPlayIcon('▶');
setBadge(playerStateBadge, 'paused'); setBadge(playerStateBadge, 'paused');
setStatus(''); setStatus('');
@@ -2238,6 +2260,7 @@ const chapterTmpl = `
audio.style.display = 'none'; audio.style.display = 'none';
} }
function setError(msg) { function setError(msg) {
if (stale()) return;
highlightPara(-1); highlightPara(-1);
setPlayIcon('▶'); setPlayIcon('▶');
playerPlayBtn.disabled = false; playerPlayBtn.disabled = false;
@@ -2278,11 +2301,12 @@ const chapterTmpl = `
return res.json(); return res.json();
}) })
.then(function (data) { .then(function (data) {
if (stale()) return;
if (!data || !data.url) throw new Error('no url in response'); if (!data || !data.url) throw new Error('no url in response');
cb(data.url); cb(data.url);
}) })
.catch(function (e) { .catch(function (e) {
if (e.name === 'AbortError') return; if (e.name === 'AbortError' || stale()) return;
setError(e.message); setError(e.message);
}); });
} }
@@ -2290,9 +2314,12 @@ const chapterTmpl = `
// ── next-chapter prefetch at 80% ───────────────────────────────────────────── // ── next-chapter prefetch at 80% ─────────────────────────────────────────────
var prefetchFired = false; var prefetchFired = false;
audio.addEventListener('timeupdate', function () { // ── audio event handlers (named so they can be removed on next swap) ──────────
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return; function onTimeUpdate() {
if (audio.currentTime / audio.duration >= 0.8) { if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) {
// still do paragraph highlight
} else if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true; prefetchFired = true;
setNextState('generating'); setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, { fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
@@ -2301,42 +2328,31 @@ const chapterTmpl = `
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) }) body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
}) })
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); }) .then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function () { setNextState('ready'); }) .then(function () { if (!stale()) setNextState('ready'); })
.catch(function () { setNextState('error'); }); .catch(function () { if (!stale()) setNextState('error'); });
} }
// paragraph highlight // paragraph highlight
if (paras.length === 0) return; if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
var idx = Math.min( var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length), Math.floor((audio.currentTime / audio.duration) * paras.length),
paras.length - 1 paras.length - 1
); );
if (!activePara || activePara !== paras[idx]) highlightPara(idx); if (!activePara || activePara !== paras[idx]) highlightPara(idx);
}); }
// ── audio events ────────────────────────────────────────────────────────────── function onCanPlay() {
audio.addEventListener('canplay', function () { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; }
if (audio.paused) { if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); }); audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
} }
});
audio.addEventListener('waiting', function () { setStatus('Buffering\u2026'); });
audio.addEventListener('playing', setPlaying);
audio.addEventListener('pause', function () { if (!audio.ended) setPaused(); });
audio.addEventListener('play', setPlaying);
audio.addEventListener('error', function () { setError('audio error'); });
// ── auto-next ────────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
htmx.ajax('GET', nextURL + '?autoplay=1', {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
} }
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
function onPlaying() { if (!stale()) setPlaying(); }
function onPause() { if (!stale() && !audio.ended) setPaused(); }
function onAudioError() { if (!stale()) setError('audio error'); }
audio.addEventListener('ended', function () { function onEnded() {
if (stale()) { audio.removeEventListener('ended', onEnded); return; }
audio.src = ''; audio.src = '';
prefetchFired = false; prefetchFired = false;
if (autoplayChk.checked && NEXT_N) { if (autoplayChk.checked && NEXT_N) {
@@ -2344,7 +2360,28 @@ const chapterTmpl = `
} else { } else {
setStopped(); setStopped();
} }
}); }
audio.addEventListener('canplay', onCanPlay);
audio.addEventListener('waiting', onWaiting);
audio.addEventListener('playing', onPlaying);
audio.addEventListener('pause', onPause);
audio.addEventListener('play', onPlaying);
audio.addEventListener('error', onAudioError);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('ended', onEnded);
// ── auto-next ────────────────────────────────────────────────────────────────
function goNextChapter() {
if (!NEXT_N) return;
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
htmx.ajax('GET', nextURL, {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
}
// ── stop / cleanup ──────────────────────────────────────────────────────────── // ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() { function stop() {
@@ -2360,6 +2397,7 @@ const chapterTmpl = `
setGenerating(); setGenerating();
highlightPara(0); highlightPara(0);
generateAudio(CHAPTER_N, function (url) { generateAudio(CHAPTER_N, function (url) {
if (stale()) return;
audio.src = url; audio.src = url;
audio.load(); audio.load();
}); });
@@ -2377,11 +2415,22 @@ const chapterTmpl = `
startAudio(); startAudio();
}; };
// Stop cleanly when HTMX navigates away. // ── cleanup on next HTMX swap ────────────────────────────────────────────────
document.body.addEventListener('htmx:beforeSwap', stop); window.__ttsBeforeSwap = function () {
// Invalidate this generation so all stale() checks short-circuit.
window.__ttsGen++;
stop();
};
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
// ── auto-start on ?autoplay=1 ───────────────────────────────────────────────── // ── auto-start: localStorage signal (from auto-next) or ?autoplay=1 ──────────
if (new URLSearchParams(window.location.search).get('autoplay') === '1') { var _autostart = false;
try { _autostart = localStorage.getItem('tts_autostart') === '1'; } catch(_) {}
if (_autostart) {
try { localStorage.removeItem('tts_autostart'); } catch(_) {}
autoplayChk.checked = true;
setTimeout(startAudio, 100);
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true; autoplayChk.checked = true;
setTimeout(startAudio, 100); setTimeout(startAudio, 100);
} }
@@ -2390,15 +2439,17 @@ const chapterTmpl = `
if (NEXT_N) { setNextState('idle'); } if (NEXT_N) { setNextState('idle'); }
// ── double-tap left/right to navigate chapters ─────────────────────────────── // ── double-tap left/right to navigate chapters ───────────────────────────────
if (window.__ttsDoubleTap) {
document.removeEventListener('touchend', window.__ttsDoubleTap);
}
(function initDoubleTap() { (function initDoubleTap() {
var lastTap = 0; var lastTap = 0;
var lastSide = ''; var lastSide = '';
var THRESHOLD = 300; var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL']; var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
function navigate(url) { window.location.href = url; } window.__ttsDoubleTap = function (e) {
if (stale()) { document.removeEventListener('touchend', window.__ttsDoubleTap); return; }
document.addEventListener('touchend', function (e) {
var el = e.target; var el = e.target;
while (el && el !== document.body) { while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return; if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
@@ -2410,12 +2461,13 @@ const chapterTmpl = `
var gap = now - lastTap; var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) { if (gap < THRESHOLD && side === lastSide) {
lastTap = 0; lastSide = ''; lastTap = 0; lastSide = '';
if (side === 'right' && NEXT_N) navigate('/books/' + SLUG + '/chapters/' + NEXT_N); if (side === 'right' && NEXT_N) window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
else if (side === 'left' && PREV_N) navigate('/books/' + SLUG + '/chapters/' + PREV_N); else if (side === 'left' && PREV_N) window.location.href = '/books/' + SLUG + '/chapters/' + PREV_N;
} else { } else {
lastTap = now; lastSide = side; lastTap = now; lastSide = side;
} }
}, { passive: true }); };
document.addEventListener('touchend', window.__ttsDoubleTap, { passive: true });
}()); }());
}()); }());
</script>` </script>`