Fix summary overlay scrolling and paragraph formatting on mobile
- 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:
@@ -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">▶</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]">▲</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">▶</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">▲</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">
|
||||
■ 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 ? '▲' : '▼';
|
||||
queueToggleBtn.setAttribute('aria-expanded', String(!open));
|
||||
// ── toggle expanded panel ─────────────────────────────────────────────────────
|
||||
window.togglePlayerPanel = function () {
|
||||
var open = !playerPanel.hidden;
|
||||
playerPanel.hidden = open;
|
||||
playerExpandIcon.innerHTML = open ? '▲' : '▼';
|
||||
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 = '▮▮';
|
||||
label.textContent = 'Pause';
|
||||
setStatus('Playing');
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
btn.style.cursor = 'pointer';
|
||||
setPlayIcon('▮▮');
|
||||
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 = '▶';
|
||||
label.textContent = 'Resume';
|
||||
setStatus('Paused');
|
||||
queueSetCurrent('paused');
|
||||
setPlayIcon('▶');
|
||||
setBadge(playerStateBadge, 'paused');
|
||||
setStatus('');
|
||||
}
|
||||
function setStopped() {
|
||||
highlightPara(-1);
|
||||
icon.innerHTML = '▶';
|
||||
label.textContent = 'Listen';
|
||||
setStatus('');
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
btn.style.cursor = 'pointer';
|
||||
setPlayIcon('▶');
|
||||
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 = '▶';
|
||||
label.textContent = 'Listen';
|
||||
setStatus('Error: ' + msg);
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '1';
|
||||
btn.style.cursor = 'pointer';
|
||||
setPlayIcon('▶');
|
||||
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 });
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user