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(_) {}
})();
// ── 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 ─────────────────────────────────────────────────────────────────
// 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 statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
@@ -2138,7 +2149,12 @@ const chapterTmpl = `
closeChapterList();
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');
if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) && e.target !== sb && !sb.contains(e.target)) {
@@ -2149,7 +2165,8 @@ const chapterTmpl = `
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
});
};
document.addEventListener('click', window.__ttsClickOutside);
// ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed';
@@ -2172,20 +2189,23 @@ const chapterTmpl = `
})();
speedSlider.addEventListener('input', function () {
if (stale()) return;
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false;
});
voiceSel.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false;
});
autoplayChk.addEventListener('change', function () {
if (stale()) return;
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
});
// ── 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;
function highlightPara(idx) {
@@ -2211,6 +2231,7 @@ const chapterTmpl = `
audio.style.display = 'none';
}
function setPlaying() {
if (stale()) return;
setPlayIcon('▮▮');
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
@@ -2221,6 +2242,7 @@ const chapterTmpl = `
audio.style.display = '';
}
function setPaused() {
if (stale()) return;
setPlayIcon('▶');
setBadge(playerStateBadge, 'paused');
setStatus('');
@@ -2238,6 +2260,7 @@ const chapterTmpl = `
audio.style.display = 'none';
}
function setError(msg) {
if (stale()) return;
highlightPara(-1);
setPlayIcon('▶');
playerPlayBtn.disabled = false;
@@ -2278,11 +2301,12 @@ const chapterTmpl = `
return res.json();
})
.then(function (data) {
if (stale()) return;
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
})
.catch(function (e) {
if (e.name === 'AbortError') return;
if (e.name === 'AbortError' || stale()) return;
setError(e.message);
});
}
@@ -2290,9 +2314,12 @@ const chapterTmpl = `
// ── next-chapter prefetch at 80% ─────────────────────────────────────────────
var prefetchFired = false;
audio.addEventListener('timeupdate', function () {
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) return;
if (audio.currentTime / audio.duration >= 0.8) {
// ── audio event handlers (named so they can be removed on next swap) ──────────
function onTimeUpdate() {
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;
setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
@@ -2301,42 +2328,31 @@ const chapterTmpl = `
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
})
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
.then(function () { setNextState('ready'); })
.catch(function () { setNextState('error'); });
.then(function () { if (!stale()) setNextState('ready'); })
.catch(function () { if (!stale()) setNextState('error'); });
}
// paragraph highlight
if (paras.length === 0) return;
if (paras.length === 0 || !audio.duration || !isFinite(audio.duration)) return;
var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length),
paras.length - 1
);
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
});
}
// ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () {
function onCanPlay() {
if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; }
if (audio.paused) {
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 = '';
prefetchFired = false;
if (autoplayChk.checked && NEXT_N) {
@@ -2344,7 +2360,28 @@ const chapterTmpl = `
} else {
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 ────────────────────────────────────────────────────────────
function stop() {
@@ -2360,6 +2397,7 @@ const chapterTmpl = `
setGenerating();
highlightPara(0);
generateAudio(CHAPTER_N, function (url) {
if (stale()) return;
audio.src = url;
audio.load();
});
@@ -2377,11 +2415,22 @@ const chapterTmpl = `
startAudio();
};
// Stop cleanly when HTMX navigates away.
document.body.addEventListener('htmx:beforeSwap', stop);
// ── cleanup on next HTMX swap ────────────────────────────────────────────────
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 ─────────────────────────────────────────────────
if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
// ── auto-start: localStorage signal (from auto-next) or ?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;
setTimeout(startAudio, 100);
}
@@ -2390,15 +2439,17 @@ const chapterTmpl = `
if (NEXT_N) { setNextState('idle'); }
// ── double-tap left/right to navigate chapters ───────────────────────────────
if (window.__ttsDoubleTap) {
document.removeEventListener('touchend', window.__ttsDoubleTap);
}
(function initDoubleTap() {
var lastTap = 0;
var lastSide = '';
var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
function navigate(url) { window.location.href = url; }
document.addEventListener('touchend', function (e) {
window.__ttsDoubleTap = function (e) {
if (stale()) { document.removeEventListener('touchend', window.__ttsDoubleTap); return; }
var el = e.target;
while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
@@ -2410,12 +2461,13 @@ const chapterTmpl = `
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) {
lastTap = 0; lastSide = '';
if (side === 'right' && NEXT_N) navigate('/books/' + SLUG + '/chapters/' + NEXT_N);
else if (side === 'left' && PREV_N) navigate('/books/' + SLUG + '/chapters/' + PREV_N);
if (side === 'right' && NEXT_N) window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
else if (side === 'left' && PREV_N) window.location.href = '/books/' + SLUG + '/chapters/' + PREV_N;
} else {
lastTap = now; lastSide = side;
}
}, { passive: true });
};
document.addEventListener('touchend', window.__ttsDoubleTap, { passive: true });
}());
}());
</script>`