Fix listener stacking across HTMX swaps and wire auto-next chapter playback
This commit is contained in:
@@ -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>`
|
||||
|
||||
Reference in New Issue
Block a user