Fix summary overlay scrolling and paragraph formatting on mobile
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Make overlay scroll within the viewport instead of being clipped (overflow-y-auto on backdrop, items-start alignment)
- Split summary text into paragraphs in the zoom overlay (newline-split with sentence-boundary fallback)
- Add Summary heading in overlay for context
- Remove old merge-poller JS; simplify generateAudio to use data.url directly (Kokoro backend rewrite cleanup)
This commit is contained in:
Admin
2026-03-01 23:37:05 +05:00
parent 13a3eacca6
commit db59d55f42
2 changed files with 390 additions and 601 deletions

View File

@@ -1429,12 +1429,13 @@ const bookTmpl = `
<!-- Summary zoom overlay -->
<div id="summary-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out p-6"
class="fixed inset-0 z-50 hidden items-start justify-center bg-black/80 cursor-zoom-out p-4 pt-8 overflow-y-auto"
onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');">
<div class="max-w-xl w-full bg-zinc-900 border border-zinc-700 rounded-2xl p-6 shadow-2xl cursor-default" onclick="event.stopPropagation()">
<p id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed whitespace-pre-wrap"></p>
<h2 class="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Summary</h2>
<div id="summary-zoom-text" class="text-zinc-200 text-base leading-relaxed space-y-3"></div>
<button onclick="document.getElementById('summary-zoom-overlay').classList.add('hidden');document.getElementById('summary-zoom-overlay').classList.remove('flex');"
class="mt-4 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
class="mt-5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors">Close</button>
</div>
</div>
@@ -1475,7 +1476,7 @@ const bookTmpl = `
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3 cursor-zoom-in hover:text-zinc-300 transition-colors"
onclick="(function(t){var o=document.getElementById('summary-zoom-overlay');document.getElementById('summary-zoom-text').textContent=t;o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
onclick="(function(t){var o=document.getElementById('summary-zoom-overlay');var c=document.getElementById('summary-zoom-text');c.innerHTML='';var paras=t.split(/\n+/).filter(function(s){return s.trim().length>0;});if(paras.length<=1){paras=t.split(/(?<=[.!?])\s{2,}|(?<=[.!?][\"'\u201d])\s+/).filter(function(s){return s.trim().length>0;});}paras.forEach(function(p){var el=document.createElement('p');el.textContent=p.trim();c.appendChild(el);});o.classList.remove('hidden');o.classList.add('flex');})(this.dataset.full)"
data-full="{{.Meta.Summary}}">{{.Meta.Summary}}</p>
{{end}}
</div>
@@ -1862,14 +1863,13 @@ const chapterTmpl = `
</a>
{{end}}
<!-- TTS play/pause -->
<!-- TTS play/pause (icon-only) -->
<button id="tts-btn"
type="button"
onclick="ttsToggle()"
aria-label="Listen"
class="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-transparent text-amber-500 hover:text-amber-400 text-[0.8125rem] font-medium border-none cursor-pointer transition-colors">
class="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors">
<span id="tts-icon" aria-hidden="true">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Settings -->
@@ -1982,55 +1982,89 @@ const chapterTmpl = `
</nav>
</main>
<!-- ─── Audio queue bar (sticky bottom) ──────────────────────────────────── -->
<aside id="audio-queue-bar"
aria-label="Audio queue"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-xs text-zinc-400">
<!-- ─── Mini audio player (slides up from bottom when audio starts) ────────── -->
<aside id="mini-player"
aria-label="Audio player"
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 border-t border-zinc-800 text-zinc-100 translate-y-full transition-transform duration-300 ease-out">
<!-- Toggle handle -->
<button id="queue-toggle"
type="button"
onclick="window.toggleQueuePanel()"
aria-expanded="false"
class="w-full flex items-center gap-2 px-4 py-1.5 bg-transparent border-none cursor-pointer text-zinc-400 text-[0.7rem] text-left">
<span class="flex-1 flex items-center gap-2">
<span id="queue-toggle-icon" aria-hidden="true" class="text-[0.65rem]">&#9650;</span>
<span class="font-semibold tracking-widest uppercase">Audio Queue</span>
<span id="queue-now-badge" class="queue-badge queue-badge-idle">idle</span>
</span>
<!-- Mini scrubber -->
<span class="flex-[2] flex items-center gap-2 px-2" aria-hidden="true">
<span id="queue-time-cur" class="min-w-[2.5rem] text-right tabular-nums">0:00</span>
<span class="flex-1 relative h-[3px] bg-zinc-700 rounded-sm overflow-hidden">
<span id="queue-scrubber-fill" class="absolute left-0 top-0 h-full w-0 bg-amber-500 transition-[width] duration-300 linear"></span>
</span>
<span id="queue-time-tot" class="min-w-[2.5rem] tabular-nums">0:00</span>
</span>
</button>
<!-- Collapsed bar: play/pause · title · time · expand -->
<div id="player-bar"
class="max-w-2xl mx-auto flex items-center gap-3 px-4 h-14">
<!-- Play / Pause button -->
<button id="player-play-btn"
type="button"
onclick="ttsToggle()"
aria-label="Play/Pause"
class="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-base border-none cursor-pointer transition-colors">
<span id="player-play-icon" aria-hidden="true">&#9654;</span>
</button>
<!-- Chapter title + inline mini progress -->
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<span id="player-title" class="text-[0.8rem] font-medium text-zinc-200 truncate"></span>
<!-- Tap-to-seek progress track -->
<div id="player-seek-track"
role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
class="relative w-full h-[4px] bg-zinc-700 rounded-full cursor-pointer">
<div id="player-seek-fill" class="absolute left-0 top-0 h-full bg-amber-500 rounded-full pointer-events-none" style="width:0%"></div>
</div>
</div>
<!-- Time display -->
<div class="flex-shrink-0 flex flex-col items-end text-[0.7rem] tabular-nums text-zinc-400">
<span id="player-time-cur">0:00</span>
<span id="player-time-tot" class="text-zinc-600">0:00</span>
</div>
<!-- Expand toggle -->
<button id="player-expand-btn"
type="button"
onclick="window.togglePlayerPanel()"
aria-label="Expand player"
aria-expanded="false"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg bg-transparent border-none cursor-pointer text-zinc-500 hover:text-zinc-200 transition-colors text-[0.75rem]">
<span id="player-expand-icon" aria-hidden="true">&#9650;</span>
</button>
</div>
<!-- Expanded panel -->
<div id="queue-panel-body" hidden class="border-t border-zinc-800">
<div id="player-panel" hidden
class="max-w-2xl mx-auto border-t border-zinc-800 px-4 py-3 flex flex-col gap-3">
<!-- Now playing -->
<div class="queue-row" id="queue-row-current">
<span class="queue-row-label">Now</span>
<span class="queue-row-title" id="queue-cur-title">—</span>
<span class="queue-badge" id="queue-cur-badge">idle</span>
<!-- Full seekable scrub bar -->
<div class="flex items-center gap-3">
<span id="player-full-cur" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem] text-right">0:00</span>
<div id="player-full-track"
role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
class="flex-1 relative h-[6px] bg-zinc-700 rounded-full cursor-pointer">
<div id="player-full-fill" class="absolute left-0 top-0 h-full bg-amber-500 rounded-full pointer-events-none" style="width:0%"></div>
<div id="player-full-thumb" class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-amber-400 rounded-full shadow pointer-events-none" style="left:0%"></div>
</div>
<span id="player-full-tot" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem]">0:00</span>
</div>
<!-- Up next -->
<div class="queue-row" id="queue-row-next" hidden>
<span class="queue-row-label">Next</span>
<span class="queue-row-title" id="queue-next-title">—</span>
<span class="queue-badge" id="queue-next-badge">—</span>
</div>
<!-- Controls row -->
<div class="flex items-center justify-between">
<!-- State badge -->
<span id="player-state-badge" class="queue-badge queue-badge-idle">idle</span>
<!-- Debug row -->
<div class="queue-row queue-row-debug" id="queue-row-debug">
<span class="queue-row-label">dbg</span>
<span id="queue-debug-text" class="font-mono break-all"></span>
</div>
<!-- Stop button -->
<button id="player-stop-btn"
type="button"
onclick="playerStop()"
aria-label="Stop"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-[0.8rem] border-none cursor-pointer transition-colors">
&#9632; Stop
</button>
<!-- Next chapter prefetch status -->
<div id="player-next-row" hidden class="flex items-center gap-2">
<span class="text-[0.7rem] text-zinc-600 uppercase tracking-wide font-semibold">Next</span>
<span id="player-next-title" class="text-[0.75rem] text-zinc-400 truncate max-w-[8rem]">—</span>
<span id="player-next-badge" class="queue-badge queue-badge-idle">—</span>
</div>
</div>
</div>
</aside>
@@ -2042,9 +2076,6 @@ const chapterTmpl = `
padding-left: 0.5rem;
transition: background 0.15s;
}
@media (hover: hover) {
#chapter-article p:hover { background: rgba(251,191,36,0.07); }
}
#chapter-article p.tts-active {
background: rgba(251,191,36,0.13);
border-left: 2px solid #f59e0b;
@@ -2052,11 +2083,11 @@ const chapterTmpl = `
}
/* Prevent sticky nav from obscuring anchor targets */
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 4rem; }
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 5rem; }
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
#chapter-article p + p { margin-top: 0; }
/* Audio queue badges */
/* Audio state badges */
.queue-badge {
display: inline-block;
padding: 0.1em 0.5em;
@@ -2074,31 +2105,18 @@ const chapterTmpl = `
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
.queue-badge-error { background: #450a0a; color: #f87171; }
/* Queue rows */
.queue-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid #18181b;
/* Mini-player slide-up */
#mini-player { will-change: transform; }
#mini-player.player-visible { transform: translateY(0); }
/* Seek track hit-area padding for easier touch */
#player-seek-track, #player-full-track {
padding-top: 6px;
padding-bottom: 6px;
margin-top: -6px;
margin-bottom: -6px;
box-sizing: content-box;
}
.queue-row-label {
width: 2.5rem;
flex-shrink: 0;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #52525b;
}
.queue-row-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #d4d4d8;
}
.queue-row-debug { background: #0c0c0e; color: #52525b; font-size: 0.65rem; }
</style>
<script>
@@ -2108,7 +2126,7 @@ const chapterTmpl = `
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
// ── reading progress ─────────────────────────────────────────────────────────
// ── reading progress ─────────────────────────────────────────────────────────
(function saveProgress() {
try {
var p = JSON.parse(localStorage.getItem('reading_progress') || '{}');
@@ -2117,10 +2135,10 @@ const chapterTmpl = `
} catch(_) {}
})();
// ── DOM refs ─────────────────────────────────────────────────────────────────
var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn');
var icon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
var navBtn = document.getElementById('tts-btn'); // nav play/pause circle
var navIcon = document.getElementById('tts-icon');
var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice');
@@ -2129,31 +2147,38 @@ const chapterTmpl = `
var autoplayChk = document.getElementById('tts-autoplay');
var article = document.getElementById('chapter-article');
// ── audio queue bar DOM refs ───────────────────────────────────────────────────
var queuePanelBody = document.getElementById('queue-panel-body');
var queueToggleBtn = document.getElementById('queue-toggle');
var queueToggleIcon = document.getElementById('queue-toggle-icon');
var queueNowBadge = document.getElementById('queue-now-badge');
var queueTimeCur = document.getElementById('queue-time-cur');
var queueTimeTot = document.getElementById('queue-time-tot');
var queueFill = document.getElementById('queue-scrubber-fill');
var queueRowNext = document.getElementById('queue-row-next');
var queueCurTitle = document.getElementById('queue-cur-title');
var queueCurBadge = document.getElementById('queue-cur-badge');
var queueNextTitle = document.getElementById('queue-next-title');
var queueNextBadge = document.getElementById('queue-next-badge');
var queueDebugText = document.getElementById('queue-debug-text');
// mini-player
var miniPlayer = document.getElementById('mini-player');
var playerPlayBtn = document.getElementById('player-play-btn');
var playerPlayIcon = document.getElementById('player-play-icon');
var playerTitle = document.getElementById('player-title');
var playerSeekTrack = document.getElementById('player-seek-track');
var playerSeekFill = document.getElementById('player-seek-fill');
var playerTimeCur = document.getElementById('player-time-cur');
var playerTimeTot = document.getElementById('player-time-tot');
var playerExpandBtn = document.getElementById('player-expand-btn');
var playerExpandIcon= document.getElementById('player-expand-icon');
var playerPanel = document.getElementById('player-panel');
var playerFullTrack = document.getElementById('player-full-track');
var playerFullFill = document.getElementById('player-full-fill');
var playerFullThumb = document.getElementById('player-full-thumb');
var playerFullCur = document.getElementById('player-full-cur');
var playerFullTot = document.getElementById('player-full-tot');
var playerStateBadge= document.getElementById('player-state-badge');
var playerNextRow = document.getElementById('player-next-row');
var playerNextTitle = document.getElementById('player-next-title');
var playerNextBadge = document.getElementById('player-next-badge');
// ── audio queue helpers ────────────────────────────────────────────────────────
// ── badge helper ─────────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
'queue-badge-playing','queue-badge-paused','queue-badge-error'];
function setBadge(el, state) {
BADGE_CLASSES.forEach(function (c) { el.classList.remove(c); });
el.classList.add('queue-badge-' + state);
el.textContent = state;
}
// ── time formatter ────────────────────────────────────────────────────────────
function fmtTime(secs) {
if (!isFinite(secs) || secs < 0) return '0:00';
var m = Math.floor(secs / 60);
@@ -2161,44 +2186,69 @@ const chapterTmpl = `
return m + ':' + (s < 10 ? '0' : '') + s;
}
function queueUpdateScrubber() {
// ── mini-player show/hide ─────────────────────────────────────────────────────
function showPlayer() { miniPlayer.classList.add('player-visible'); }
function hidePlayer() { miniPlayer.classList.remove('player-visible'); }
// ── scrubber update ───────────────────────────────────────────────────────────
function updateScrubber() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
queueTimeCur.textContent = fmtTime(cur);
queueTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
queueFill.style.width = pct.toFixed(1) + '%';
var pctStr = pct.toFixed(1) + '%';
// collapsed bar
playerTimeCur.textContent = fmtTime(cur);
playerTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
playerSeekFill.style.width = pctStr;
// expanded bar
playerFullCur.textContent = fmtTime(cur);
playerFullTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
playerFullFill.style.width = pctStr;
playerFullThumb.style.left = pctStr;
// aria
playerSeekTrack.setAttribute('aria-valuenow', Math.round(pct));
playerFullTrack.setAttribute('aria-valuenow', Math.round(pct));
}
function queueSetCurrent(state, debugMsg) {
setBadge(queueNowBadge, state);
setBadge(queueCurBadge, state);
queueCurTitle.textContent = 'Ch. ' + CHAPTER_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
queueUpdateScrubber();
}
function queueSetNext(state, debugMsg) {
if (!NEXT_N) { queueRowNext.style.display = 'none'; return; }
queueRowNext.style.display = '';
setBadge(queueNextBadge, state);
queueNextTitle.textContent = 'Ch. ' + NEXT_N;
if (debugMsg !== undefined) {
queueDebugText.textContent = debugMsg;
}
}
// Toggle expand/collapse.
window.toggleQueuePanel = function () {
var open = queuePanelBody.style.display !== 'none';
queuePanelBody.style.display = open ? 'none' : '';
queueToggleIcon.innerHTML = open ? '&#9650;' : '&#9660;';
queueToggleBtn.setAttribute('aria-expanded', String(!open));
// ── toggle expanded panel ─────────────────────────────────────────────────────
window.togglePlayerPanel = function () {
var open = !playerPanel.hidden;
playerPanel.hidden = open;
playerExpandIcon.innerHTML = open ? '&#9650;' : '&#9660;';
playerExpandBtn.setAttribute('aria-expanded', String(!open));
};
// ── panel toggles ─────────────────────────────────────────────────────────────
// ── seek on track click/tap ───────────────────────────────────────────────────
function seekFromEvent(track, e) {
var rect = track.getBoundingClientRect();
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
var ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
}
}
function attachSeek(track) {
var dragging = false;
track.addEventListener('mousedown', function (e) { dragging = true; seekFromEvent(track, e); e.preventDefault(); });
track.addEventListener('touchstart', function (e) { dragging = true; seekFromEvent(track, e); }, { passive: true });
document.addEventListener('mousemove', function (e) { if (dragging) seekFromEvent(track, e); });
document.addEventListener('touchmove', function (e) { if (dragging) seekFromEvent(track, e.touches ? e : e); }, { passive: true });
document.addEventListener('mouseup', function () { dragging = false; });
document.addEventListener('touchend', function () { dragging = false; });
track.addEventListener('click', function (e) { seekFromEvent(track, e); });
}
attachSeek(playerSeekTrack);
attachSeek(playerFullTrack);
// ── next-chapter prefetch display ────────────────────────────────────────────
function setNextState(state) {
if (!NEXT_N) { playerNextRow.hidden = true; return; }
playerNextRow.hidden = false;
playerNextTitle.textContent = 'Ch.\u00a0' + NEXT_N;
setBadge(playerNextBadge, state);
}
// ── panel toggles (chapter list / settings) ───────────────────────────────────
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel = document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
@@ -2237,15 +2287,15 @@ const chapterTmpl = `
}
});
// ── status strip ─────────────────────────────────────────────────────────────
// ── status strip ─────────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
// ── localStorage settings ─────────────────────────────────────────────────────
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_SPEED = 'tts_speed';
var LS_VOICE = 'tts_voice';
var LS_AUTONEXT = 'tts_autonext';
(function loadSettings() {
@@ -2266,26 +2316,20 @@ const chapterTmpl = `
speedSlider.addEventListener('input', function () {
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
localStorage.setItem(LS_SPEED, speedSlider.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
prefetchFired = false;
});
voiceSel.addEventListener('change', function () {
localStorage.setItem(LS_VOICE, voiceSel.value);
prefetchFired = false; // voice/speed changed — invalidate prefetch
prefetchFired = false;
});
autoplayChk.addEventListener('change', function () {
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
});
// ── paragraph indexing ───────────────────────────────────────────────────────
// Clicking a paragraph seeks to the proportional position in the audio file.
// ── paragraph indexing (highlight only, no click-to-seek) ───────────────────
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
var activePara = null;
paras.forEach(function (p, i) {
p.dataset.paraIdx = i;
p.addEventListener('click', function () { seekToPara(i); });
});
function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
@@ -2295,104 +2339,74 @@ const chapterTmpl = `
}
}
// Seek to the proportional position in the loaded audio corresponding to para idx.
function seekToPara(idx) {
if (!audio.src || !audio.duration || !isFinite(audio.duration)) {
// Audio not loaded yet — generate it first, then seek once ready.
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
doSeek(idx);
});
audio.load();
});
return;
}
doSeek(idx);
}
function doSeek(idx) {
if (!audio.duration || !isFinite(audio.duration)) return;
var ratio = paras.length > 1 ? idx / (paras.length - 1) : 0;
audio.currentTime = ratio * audio.duration;
highlightPara(idx);
if (audio.paused) audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
// ── UI state helpers ──────────────────────────────────────────────────────────
function setPlayIcon(icon) {
navIcon.innerHTML = icon;
playerPlayIcon.innerHTML = icon;
}
function setGenerating() {
icon.textContent = '\u231B';
label.textContent = 'Generating\u2026';
setStatus('Generating audio on server\u2026');
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
setPlayIcon('\u231B');
navBtn.disabled = true;
navBtn.style.opacity = '0.6';
playerPlayBtn.disabled = true;
voiceSel.disabled = true;
speedSlider.disabled = true;
queueSetCurrent('generating', 'POST /ui/audio/' + SLUG + '/' + CHAPTER_N);
setBadge(playerStateBadge, 'generating');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
setStatus('Generating audio\u2026');
showPlayer();
}
function setPlaying() {
icon.innerHTML = '&#9646;&#9646;';
label.textContent = 'Pause';
setStatus('Playing');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9646;&#9646;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('playing', 'audio.src set, playing ch.' + CHAPTER_N);
setBadge(playerStateBadge, 'playing');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
showPlayer();
}
function setPaused() {
icon.innerHTML = '&#9654;';
label.textContent = 'Resume';
setStatus('Paused');
queueSetCurrent('paused');
setPlayIcon('&#9654;');
setBadge(playerStateBadge, 'paused');
setStatus('');
}
function setStopped() {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9654;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('idle', 'stopped');
queueSetNext('idle');
queueUpdateScrubber();
setBadge(playerStateBadge, 'idle');
playerTitle.textContent = '—';
setStatus('');
updateScrubber();
hidePlayer();
}
function setError(msg) {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
setStatus('Error: ' + msg);
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
setPlayIcon('&#9654;');
navBtn.disabled = false;
navBtn.style.opacity = '1';
playerPlayBtn.disabled = false;
voiceSel.disabled = false;
speedSlider.disabled = false;
queueSetCurrent('error', 'err: ' + msg);
setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
showPlayer();
}
// ── server-side audio generation ─────────────────────────────────────────────
// POST /ui/audio/{slug}/{n} — returns {url, parts, merged}.
// When merged=false, url is part-0; polling /ui/audio/{slug}/{n}/status
// detects when the full merged file is ready and swaps audio.src seamlessly.
// cb(url) is called with the initial (part-0 or merged) URL on success.
var currentAudioCtrl = null; // AbortController for the active generateAudio fetch
var mergePoller = null; // setInterval id for polling merged status
function clearMergePoller() {
if (mergePoller !== null) { clearInterval(mergePoller); mergePoller = null; }
}
var currentAudioCtrl = null;
function generateAudio(chapterN, cb) {
// Cancel any previous in-flight generation and polling.
if (currentAudioCtrl) { currentAudioCtrl.abort(); }
clearMergePoller();
var ctrl = new AbortController();
currentAudioCtrl = ctrl;
@@ -2412,75 +2426,42 @@ const chapterTmpl = `
.then(function (data) {
if (!data || !data.url) throw new Error('no url in response');
cb(data.url);
// If the server is still generating remaining parts, start polling.
if (!data.merged && data.parts > 1) {
var statusURL = '/ui/audio/' + SLUG + '/' + chapterN + '/status'
+ '?voice=' + encodeURIComponent(voiceSel.value)
+ '&speed=' + parseFloat(speedSlider.value);
mergePoller = setInterval(function () {
fetch(statusURL)
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (s) {
if (s.merged) {
clearMergePoller();
// Seamlessly swap to the full merged file.
var ratio = (audio.duration && isFinite(audio.duration))
? audio.currentTime / audio.duration : 0;
audio.addEventListener('loadedmetadata', function onMeta() {
audio.removeEventListener('loadedmetadata', onMeta);
if (audio.duration && isFinite(audio.duration)) {
audio.currentTime = ratio * audio.duration;
}
}, { once: true });
audio.src = s.url;
audio.load();
}
})
.catch(function () { /* ignore transient poll errors */ });
}, 3000);
}
})
.catch(function (e) {
if (e.name === 'AbortError') return; // silently cancelled
if (e.name === 'AbortError') return;
setError(e.message);
});
}
// ── next-chapter prefetch ─────────────────────────────────────────────────────
// At 80 % playback we silently POST to generate the next chapter's audio so
// it is cached by the time the current chapter ends.
var prefetchFired = false; // ensure we only fire once per chapter
// ── 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) {
prefetchFired = true;
queueSetNext('generating', 'POST /ui/audio/' + SLUG + '/' + NEXT_N + ' (prefetch)');
// Fire-and-forget: idempotent on server.
setNextState('generating');
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 () { queueSetNext('ready', 'prefetch complete: ch.' + NEXT_N + ' cached'); })
.catch(function (e) { queueSetNext('error', 'prefetch failed: ' + e); });
.then(function () { setNextState('ready'); })
.catch(function () { setNextState('error'); });
}
});
// ── stop / cleanup ────────────────────────────────────────────────────────────
function stop() {
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
clearMergePoller();
audio.pause();
audio.src = '';
prefetchFired = false;
setStopped();
}
// Exposed for the Stop button in the expanded panel.
window.playerStop = stop;
// ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () {
@@ -2494,9 +2475,8 @@ const chapterTmpl = `
audio.addEventListener('play', setPlaying);
audio.addEventListener('error', function () { setError('audio error'); });
// Sync paragraph highlight and scrubber while playing (via timeupdate).
audio.addEventListener('timeupdate', function () {
queueUpdateScrubber();
updateScrubber();
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
var idx = Math.min(
Math.floor((audio.currentTime / audio.duration) * paras.length),
@@ -2533,12 +2513,10 @@ const chapterTmpl = `
generateAudio(CHAPTER_N, function (url) {
audio.src = url;
audio.load();
// canplay event triggers play(); setPlaying() re-enables controls.
});
}
window.ttsToggle = function () {
// Already have audio loaded — just play/pause.
if (audio.src) {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
@@ -2559,51 +2537,34 @@ const chapterTmpl = `
setTimeout(startAudio, 100);
}
// ── initialise queue bar on page load ────────────────────────────────────────
queueSetCurrent('idle', 'ready — ch.' + CHAPTER_N);
if (NEXT_N) { queueSetNext('idle', 'waiting — ch.' + NEXT_N); }
// ── init next-chapter display ────────────────────────────────────────────────
if (NEXT_N) { setNextState('idle'); }
// ── double-tap left/right to navigate chapters ───────────────────────────────
// A double-tap on the left third of the screen goes to the previous chapter;
// a double-tap on the right third goes to the next chapter.
// Taps on interactive elements (buttons, links, inputs, selects) are ignored
// so normal UI interactions are never swallowed.
(function initDoubleTap() {
var lastTap = 0;
var lastSide = ''; // 'left' | 'right'
var THRESHOLD = 300; // ms between taps
var lastSide = '';
var THRESHOLD = 300;
var INTERACTIVE = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'];
function navigate(url) {
window.location.href = url;
}
function navigate(url) { window.location.href = url; }
document.addEventListener('touchend', function (e) {
// Ignore taps that land on interactive elements
var el = e.target;
while (el && el !== document.body) {
if (INTERACTIVE.indexOf(el.tagName) !== -1) return;
el = el.parentElement;
}
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
var touch = e.changedTouches[0];
var side = touch.clientX < window.innerWidth / 2 ? 'left' : 'right';
var now = Date.now();
var gap = now - lastTap;
if (gap < THRESHOLD && side === lastSide) {
// Double-tap detected
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);
}
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);
} else {
lastTap = now;
lastSide = side;
lastTap = now; lastSide = side;
}
}, { passive: true });
}());