Replace custom audio player with native HTML audio element and compact layout
This commit is contained in:
@@ -1842,20 +1842,6 @@ const chapterTmpl = `
|
||||
☰
|
||||
</a>
|
||||
|
||||
<!-- Prev chapter -->
|
||||
{{if .PrevN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
class="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
|
||||
← Prev
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- Chapter title button — opens chapter list drawer -->
|
||||
<button id="chapter-list-btn"
|
||||
type="button"
|
||||
@@ -1868,20 +1854,6 @@ const chapterTmpl = `
|
||||
<span class="flex-shrink-0 text-[0.625rem] text-zinc-600" aria-hidden="true">▼</span>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{{if .NextN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
class="flex-shrink-0 px-2.5 py-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 text-[0.8125rem] transition-colors no-underline">
|
||||
Next →
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TTS status strip -->
|
||||
@@ -1922,9 +1894,6 @@ const chapterTmpl = `
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hidden audio element -->
|
||||
<audio id="tts-audio" aria-hidden="true" hidden></audio>
|
||||
|
||||
<!-- ─── Chapter content ───────────────────────────────────────────────────── -->
|
||||
<main class="max-w-2xl mx-auto px-4 py-10">
|
||||
|
||||
@@ -1934,31 +1903,9 @@ const chapterTmpl = `
|
||||
{{.HTML}}
|
||||
</article>
|
||||
|
||||
<nav aria-label="Chapter pagination" class="flex justify-between mt-12 pt-6 border-t border-zinc-800">
|
||||
{{if .PrevN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm px-4 py-2 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">
|
||||
← Previous chapter
|
||||
</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
{{if .NextN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm px-4 py-2 rounded-lg text-zinc-400 hover:text-zinc-200 transition-colors">
|
||||
Next chapter →
|
||||
</a>
|
||||
{{else}}<span></span>{{end}}
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<!-- ─── Mini audio player (always visible at bottom) ────────────────────── -->
|
||||
<!-- ─── Compact audio player (fixed bottom) ─────────────────────────────── -->
|
||||
<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">
|
||||
@@ -1990,88 +1937,87 @@ const chapterTmpl = `
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div class="max-w-2xl mx-auto px-3 py-2 flex flex-col gap-1.5">
|
||||
|
||||
<!-- 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>
|
||||
<!-- Row 1: chapter nav + title + status + settings -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Chapter title -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span id="player-title" class="text-[0.8rem] font-medium text-zinc-200 truncate">—</span>
|
||||
</div>
|
||||
<!-- Prev chapter -->
|
||||
{{if .PrevN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
class="flex-shrink-0 px-2 py-1 rounded-md text-zinc-400 hover:text-zinc-100 text-xs transition-colors no-underline bg-zinc-800 hover:bg-zinc-700">
|
||||
← Prev
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="flex-shrink-0 w-[3.5rem]"></span>
|
||||
{{end}}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Settings button -->
|
||||
<button id="settings-btn"
|
||||
type="button"
|
||||
onclick="toggleSettings()"
|
||||
aria-label="Reader settings"
|
||||
aria-haspopup="dialog"
|
||||
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-amber-400 text-base transition-colors">
|
||||
⚙
|
||||
</button>
|
||||
|
||||
<!-- 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="player-panel" hidden
|
||||
class="max-w-2xl mx-auto border-t border-zinc-800 px-4 py-3 flex flex-col gap-3">
|
||||
|
||||
<!-- 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>
|
||||
<!-- Title + state badge -->
|
||||
<div class="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span id="player-title" class="text-[0.78rem] font-medium text-zinc-300 truncate">Ch. {{.ChapterN}}</span>
|
||||
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
|
||||
</div>
|
||||
<span id="player-full-tot" class="text-[0.7rem] tabular-nums text-zinc-400 min-w-[2.5rem]">0:00</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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Next chapter prefetch info -->
|
||||
<div id="player-next-row" hidden class="flex-shrink-0 flex items-center gap-1.5">
|
||||
<span class="text-[0.65rem] text-zinc-600 uppercase tracking-wide font-semibold">Next</span>
|
||||
<span id="player-next-badge" class="queue-badge queue-badge-idle">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<button id="settings-btn"
|
||||
type="button"
|
||||
onclick="toggleSettings()"
|
||||
aria-label="Reader settings"
|
||||
aria-haspopup="dialog"
|
||||
class="flex-shrink-0 flex items-center justify-center w-7 h-7 rounded-md bg-transparent border-none cursor-pointer text-zinc-500 hover:text-amber-400 text-sm transition-colors">
|
||||
⚙
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{{if .NextN}}
|
||||
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
class="flex-shrink-0 px-2 py-1 rounded-md text-zinc-400 hover:text-zinc-100 text-xs transition-colors no-underline bg-zinc-800 hover:bg-zinc-700">
|
||||
Next →
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="flex-shrink-0 w-[3.5rem]"></span>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Row 2: play button + native audio -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Play/Pause button (triggers generation on first press) -->
|
||||
<button id="player-play-btn"
|
||||
type="button"
|
||||
onclick="ttsToggle()"
|
||||
aria-label="Play/Pause"
|
||||
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-amber-500 hover:bg-amber-400 text-zinc-950 text-sm border-none cursor-pointer transition-colors">
|
||||
<span id="player-play-icon" aria-hidden="true">▶</span>
|
||||
</button>
|
||||
|
||||
<!-- Native audio element (visible, controls shown after src is set) -->
|
||||
<audio id="tts-audio-native"
|
||||
controls
|
||||
preload="none"
|
||||
class="flex-1 h-8 min-w-0 rounded-lg [&::-webkit-media-controls-panel]:bg-zinc-800 [&::-webkit-media-controls-current-time-display]:text-zinc-300 [&::-webkit-media-controls-time-remaining-display]:text-zinc-500"
|
||||
style="display:none; color-scheme:dark;">
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -2090,7 +2036,7 @@ const chapterTmpl = `
|
||||
}
|
||||
|
||||
/* Prevent sticky nav from obscuring anchor targets */
|
||||
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 5rem; }
|
||||
#main-content { scroll-padding-top: 3.5rem; padding-bottom: 7rem; }
|
||||
#chapter-article { font-size: 1.0625rem; line-height: 1.8; }
|
||||
#chapter-article p + p { margin-top: 0; }
|
||||
|
||||
@@ -2112,15 +2058,10 @@ const chapterTmpl = `
|
||||
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
||||
.queue-badge-error { background: #450a0a; color: #f87171; }
|
||||
|
||||
/* Mini-player is always visible on chapter pages */
|
||||
|
||||
/* Seek track hit-area padding for easier touch */
|
||||
#player-full-track {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
margin-top: -6px;
|
||||
margin-bottom: -6px;
|
||||
box-sizing: content-box;
|
||||
/* Native audio element dark theme */
|
||||
#tts-audio-native {
|
||||
color-scheme: dark;
|
||||
accent-color: #f59e0b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2141,34 +2082,24 @@ const chapterTmpl = `
|
||||
})();
|
||||
|
||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
var audio = document.getElementById('tts-audio');
|
||||
var statusEl = document.getElementById('tts-status');
|
||||
var statusBar = document.getElementById('tts-status-bar');
|
||||
var voiceSel = document.getElementById('tts-voice');
|
||||
var speedSlider = document.getElementById('tts-speed');
|
||||
var speedLabel = document.getElementById('tts-speed-label');
|
||||
var autoplayChk = document.getElementById('tts-autoplay');
|
||||
var article = document.getElementById('chapter-article');
|
||||
|
||||
// mini-player
|
||||
var miniPlayer = document.getElementById('mini-player');
|
||||
// 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');
|
||||
var voiceSel = document.getElementById('tts-voice');
|
||||
var speedSlider = document.getElementById('tts-speed');
|
||||
var speedLabel = document.getElementById('tts-speed-label');
|
||||
var autoplayChk = document.getElementById('tts-autoplay');
|
||||
var article = document.getElementById('chapter-article');
|
||||
var playerPlayBtn = document.getElementById('player-play-btn');
|
||||
var playerPlayIcon = document.getElementById('player-play-icon');
|
||||
var playerTitle = document.getElementById('player-title');
|
||||
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');
|
||||
var settingsPanel = document.getElementById('settings-panel');
|
||||
var chapterListPanel= document.getElementById('chapter-list-panel');
|
||||
var chapterListBtn = document.getElementById('chapter-list-btn');
|
||||
|
||||
// ── badge helper ─────────────────────────────────────────────────────────────
|
||||
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
|
||||
@@ -2179,78 +2110,13 @@ const chapterTmpl = `
|
||||
el.textContent = state;
|
||||
}
|
||||
|
||||
// ── time formatter ────────────────────────────────────────────────────────────
|
||||
function fmtTime(secs) {
|
||||
if (!isFinite(secs) || secs < 0) return '0:00';
|
||||
var m = Math.floor(secs / 60);
|
||||
var s = Math.floor(secs % 60);
|
||||
return m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
// ── mini-player show/hide ─────────────────────────────────────────────────────
|
||||
function showPlayer() { /* always visible */ }
|
||||
function hidePlayer() { /* always visible */ }
|
||||
|
||||
// ── scrubber update ───────────────────────────────────────────────────────────
|
||||
function updateScrubber() {
|
||||
var cur = audio.currentTime || 0;
|
||||
var tot = audio.duration;
|
||||
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
|
||||
var pctStr = pct.toFixed(1) + '%';
|
||||
// collapsed bar time
|
||||
playerTimeCur.textContent = fmtTime(cur);
|
||||
playerTimeTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
|
||||
// expanded bar
|
||||
playerFullCur.textContent = fmtTime(cur);
|
||||
playerFullTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
|
||||
playerFullFill.style.width = pctStr;
|
||||
playerFullThumb.style.left = pctStr;
|
||||
// aria
|
||||
playerFullTrack.setAttribute('aria-valuenow', Math.round(pct));
|
||||
}
|
||||
|
||||
// ── toggle expanded panel ─────────────────────────────────────────────────────
|
||||
window.togglePlayerPanel = function () {
|
||||
var open = !playerPanel.hidden;
|
||||
playerPanel.hidden = open;
|
||||
playerExpandIcon.innerHTML = open ? '▲' : '▼';
|
||||
playerExpandBtn.setAttribute('aria-expanded', String(!open));
|
||||
};
|
||||
|
||||
// ── 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(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);
|
||||
// ── status strip ─────────────────────────────────────────────────────────────
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
statusBar.style.display = text ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
|
||||
window.closeChapterList = function () {
|
||||
chapterListPanel.style.display = 'none';
|
||||
chapterListBtn.querySelector('span:last-child').innerHTML = '▼';
|
||||
@@ -2285,12 +2151,6 @@ const chapterTmpl = `
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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';
|
||||
@@ -2324,7 +2184,7 @@ const chapterTmpl = `
|
||||
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// ── paragraph indexing (highlight only, no click-to-seek) ───────────────────
|
||||
// ── paragraph indexing (highlight only) ──────────────────────────────────────
|
||||
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
|
||||
var activePara = null;
|
||||
|
||||
@@ -2338,9 +2198,7 @@ const chapterTmpl = `
|
||||
}
|
||||
|
||||
// ── UI state helpers ──────────────────────────────────────────────────────────
|
||||
function setPlayIcon(icon) {
|
||||
playerPlayIcon.innerHTML = icon;
|
||||
}
|
||||
function setPlayIcon(icon) { playerPlayIcon.innerHTML = icon; }
|
||||
|
||||
function setGenerating() {
|
||||
setPlayIcon('\u231B');
|
||||
@@ -2350,6 +2208,7 @@ const chapterTmpl = `
|
||||
setBadge(playerStateBadge, 'generating');
|
||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
|
||||
setStatus('Generating audio\u2026');
|
||||
audio.style.display = 'none';
|
||||
}
|
||||
function setPlaying() {
|
||||
setPlayIcon('▮▮');
|
||||
@@ -2359,11 +2218,13 @@ const chapterTmpl = `
|
||||
setBadge(playerStateBadge, 'playing');
|
||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||
setStatus('');
|
||||
audio.style.display = '';
|
||||
}
|
||||
function setPaused() {
|
||||
setPlayIcon('▶');
|
||||
setBadge(playerStateBadge, 'paused');
|
||||
setStatus('');
|
||||
audio.style.display = '';
|
||||
}
|
||||
function setStopped() {
|
||||
highlightPara(-1);
|
||||
@@ -2372,9 +2233,9 @@ const chapterTmpl = `
|
||||
voiceSel.disabled = false;
|
||||
speedSlider.disabled = false;
|
||||
setBadge(playerStateBadge, 'idle');
|
||||
playerTitle.textContent = '—';
|
||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||
setStatus('');
|
||||
updateScrubber();
|
||||
audio.style.display = 'none';
|
||||
}
|
||||
function setError(msg) {
|
||||
highlightPara(-1);
|
||||
@@ -2385,6 +2246,14 @@ const chapterTmpl = `
|
||||
setBadge(playerStateBadge, 'error');
|
||||
playerTitle.textContent = 'Error';
|
||||
setStatus('Error: ' + msg);
|
||||
audio.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── next-chapter prefetch display ────────────────────────────────────────────
|
||||
function setNextState(state) {
|
||||
if (!NEXT_N) { playerNextRow.hidden = true; return; }
|
||||
playerNextRow.hidden = false;
|
||||
setBadge(playerNextBadge, state);
|
||||
}
|
||||
|
||||
// ── server-side audio generation ─────────────────────────────────────────────
|
||||
@@ -2435,19 +2304,15 @@ const chapterTmpl = `
|
||||
.then(function () { setNextState('ready'); })
|
||||
.catch(function () { setNextState('error'); });
|
||||
}
|
||||
// paragraph highlight
|
||||
if (paras.length === 0) return;
|
||||
var idx = Math.min(
|
||||
Math.floor((audio.currentTime / audio.duration) * paras.length),
|
||||
paras.length - 1
|
||||
);
|
||||
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
|
||||
});
|
||||
|
||||
// ── stop / cleanup ────────────────────────────────────────────────────────────
|
||||
function stop() {
|
||||
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
|
||||
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 () {
|
||||
if (audio.paused) {
|
||||
@@ -2460,15 +2325,16 @@ const chapterTmpl = `
|
||||
audio.addEventListener('play', setPlaying);
|
||||
audio.addEventListener('error', function () { setError('audio error'); });
|
||||
|
||||
audio.addEventListener('timeupdate', function () {
|
||||
updateScrubber();
|
||||
if (!audio.duration || !isFinite(audio.duration) || paras.length === 0) return;
|
||||
var idx = Math.min(
|
||||
Math.floor((audio.currentTime / audio.duration) * paras.length),
|
||||
paras.length - 1
|
||||
);
|
||||
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
|
||||
});
|
||||
// ── 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
|
||||
});
|
||||
}
|
||||
|
||||
audio.addEventListener('ended', function () {
|
||||
audio.src = '';
|
||||
@@ -2480,15 +2346,13 @@ const chapterTmpl = `
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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
|
||||
});
|
||||
// ── stop / cleanup ────────────────────────────────────────────────────────────
|
||||
function stop() {
|
||||
if (currentAudioCtrl) { currentAudioCtrl.abort(); currentAudioCtrl = null; }
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
prefetchFired = false;
|
||||
setStopped();
|
||||
}
|
||||
|
||||
// ── main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user