Replace custom audio player with native HTML audio element and compact layout
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
Admin
2026-03-02 09:24:40 +05:00
parent 5db21a821a
commit decf22f258

View File

@@ -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">
&#8592; 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">&#9660;</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 &#8594;
</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">&#9654;</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">
&#8592; 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">
&#9881;
</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">&#9650;</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.&#x00A0;{{.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">
&#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>
<!-- 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">
&#9881;
</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 &#8594;
</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">&#9654;</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 ? '&#9650;' : '&#9660;';
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 = '&#9660;';
@@ -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('&#9646;&#9646;');
@@ -2359,11 +2218,13 @@ const chapterTmpl = `
setBadge(playerStateBadge, 'playing');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
audio.style.display = '';
}
function setPaused() {
setPlayIcon('&#9654;');
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 ─────────────────────────────────────────────────────────