Custom seek bar, bigger nav buttons, Media Session API for background playback
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:55:01 +05:00
parent 5cfb411530
commit 090d155c19

View File

@@ -1908,7 +1908,24 @@ const chapterTmpl = `
<!-- ─── 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">
class="fixed bottom-0 left-0 right-0 z-60 bg-zinc-950 text-zinc-100">
<!-- Seek bar sits flush on top edge, full width -->
<div id="seek-track"
class="relative w-full h-1 bg-zinc-800 cursor-pointer touch-none"
style="display:none">
<div id="seek-fill" class="absolute left-0 top-0 h-full bg-amber-500 pointer-events-none" style="width:0%"></div>
<div id="seek-thumb" class="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-amber-400 shadow pointer-events-none -translate-x-1/2" style="left:0%"></div>
</div>
<!-- Time row (only when playing) -->
<div id="seek-times" style="display:none"
class="max-w-2xl mx-auto px-4 pt-1 flex items-center justify-between">
<span id="seek-cur" class="text-[0.65rem] tabular-nums text-zinc-500">0:00</span>
<span id="seek-tot" class="text-[0.65rem] tabular-nums text-zinc-600">0:00</span>
</div>
<div class="border-t border-zinc-800"></div>
<!-- Settings panel (opens upward) -->
<div id="settings-panel"
@@ -1940,7 +1957,7 @@ const chapterTmpl = `
<div class="max-w-2xl mx-auto px-3 py-2 flex flex-col gap-0">
<!-- Single control row -->
<div class="flex items-center gap-1.5 h-11">
<div class="flex items-center gap-1.5 h-12">
<!-- Prev chapter -->
{{if .PrevN}}
@@ -1951,11 +1968,11 @@ const chapterTmpl = `
hx-swap="innerHTML"
title="Previous chapter"
aria-label="Previous chapter"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-sm transition-colors no-underline">
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
&#8592;
</a>
{{else}}
<span class="flex-shrink-0 w-8"></span>
<span class="flex-shrink-0 w-10"></span>
{{end}}
<!-- Play/Pause -->
@@ -1963,7 +1980,7 @@ const chapterTmpl = `
type="button"
onclick="ttsToggle()"
aria-label="Play/Pause"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-sm border-none cursor-pointer transition-colors">
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-base border-none cursor-pointer transition-colors">
<span id="player-play-icon" aria-hidden="true">&#9654;</span>
</button>
@@ -1973,7 +1990,7 @@ const chapterTmpl = `
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
</div>
<!-- Next prefetch badge (shown at 80%) -->
<!-- Next prefetch badge -->
<div id="player-next-row" hidden class="flex-shrink-0 flex items-center gap-1">
<span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
</div>
@@ -1984,7 +2001,7 @@ const chapterTmpl = `
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 transition-colors">
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl bg-transparent border-none cursor-pointer text-zinc-500 hover:text-amber-400 transition-colors">
&#9881;
</button>
@@ -1997,24 +2014,20 @@ const chapterTmpl = `
hx-swap="innerHTML"
title="Next chapter"
aria-label="Next chapter"
class="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-sm transition-colors no-underline">
class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-xl text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 text-base transition-colors no-underline">
&#8594;
</a>
{{else}}
<span class="flex-shrink-0 w-8"></span>
<span class="flex-shrink-0 w-10"></span>
{{end}}
</div>
<!-- Native audio seek bar (hidden until audio loads) -->
<audio id="tts-audio-native"
controls
preload="none"
class="w-full h-8"
style="display:none; color-scheme:dark; accent-color:#f59e0b;">
</audio>
</div>
<!-- Hidden native audio (no controls — we drive it entirely from JS) -->
<audio id="tts-audio-native" preload="none" style="display:none"></audio>
</aside>
<style>
@@ -2054,15 +2067,13 @@ const chapterTmpl = `
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
.queue-badge-error { background: #450a0a; color: #f87171; }
/* Native audio — dark theme, hide redundant play button */
#tts-audio-native {
color-scheme: dark;
accent-color: #f59e0b;
/* Seek bar — expanded touch hit-area without changing visual height */
#seek-track {
padding: 6px 0;
margin: -6px 0;
box-sizing: content-box;
}
/* Chrome/Safari: hide the play button inside the native controls */
#tts-audio-native::-webkit-media-controls-play-button { display: none !important; }
#tts-audio-native::-webkit-media-controls-panel { background: #18181b; border-radius: 0.5rem; }
/* Tighten bottom padding now that audio bar can appear */
/* Safe-area inset for notched phones */
#mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }
</style>
@@ -2112,6 +2123,12 @@ const chapterTmpl = `
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel= document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
var seekTrack = document.getElementById('seek-track');
var seekFill = document.getElementById('seek-fill');
var seekThumb = document.getElementById('seek-thumb');
var seekCur = document.getElementById('seek-cur');
var seekTot = document.getElementById('seek-tot');
var seekTimes = document.getElementById('seek-times');
// ── badge helper ─────────────────────────────────────────────────────────────
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
@@ -2122,6 +2139,48 @@ const chapterTmpl = `
el.textContent = state;
}
// ── time formatter ────────────────────────────────────────────────────────────
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '0:00';
var m = Math.floor(s / 60);
var sec = Math.floor(s % 60);
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
// ── seek bar ──────────────────────────────────────────────────────────────────
function showSeek() { seekTrack.style.display = ''; seekTimes.style.display = ''; }
function hideSeek() { seekTrack.style.display = 'none'; seekTimes.style.display = 'none'; }
function updateSeek() {
var cur = audio.currentTime || 0;
var tot = audio.duration;
var pct = (isFinite(tot) && tot > 0) ? Math.min(100, (cur / tot) * 100) : 0;
var ps = pct.toFixed(2) + '%';
seekFill.style.width = ps;
seekThumb.style.left = ps;
seekCur.textContent = fmtTime(cur);
seekTot.textContent = isFinite(tot) ? fmtTime(tot) : '0:00';
}
function seekFromEvent(e) {
var rect = seekTrack.getBoundingClientRect();
var clientX = (e.touches ? e.touches[0] : 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;
updateSeek();
}
}
(function attachSeek() {
var dragging = false;
seekTrack.addEventListener('mousedown', function(e){ dragging=true; seekFromEvent(e); e.preventDefault(); });
seekTrack.addEventListener('touchstart', function(e){ dragging=true; seekFromEvent(e); }, {passive:true});
document.addEventListener('mousemove', function(e){ if(dragging) seekFromEvent(e); });
document.addEventListener('touchmove', function(e){ if(dragging) seekFromEvent(e); }, {passive:true});
document.addEventListener('mouseup', function(){ dragging=false; });
document.addEventListener('touchend', function(){ dragging=false; });
})();
// ── status strip ─────────────────────────────────────────────────────────────
function setStatus(text) {
statusEl.textContent = text;
@@ -2229,7 +2288,7 @@ const chapterTmpl = `
setBadge(playerStateBadge, 'generating');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
setStatus('Generating audio\u2026');
audio.style.display = 'none';
hideSeek();
}
function setPlaying() {
if (stale()) return;
@@ -2240,14 +2299,16 @@ const chapterTmpl = `
setBadge(playerStateBadge, 'playing');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
audio.style.display = '';
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
}
function setPaused() {
if (stale()) return;
setPlayIcon('&#9654;');
setBadge(playerStateBadge, 'paused');
setStatus('');
audio.style.display = '';
showSeek();
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
}
function setStopped() {
highlightPara(-1);
@@ -2258,7 +2319,7 @@ const chapterTmpl = `
setBadge(playerStateBadge, 'idle');
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
setStatus('');
audio.style.display = 'none';
hideSeek();
}
function setError(msg) {
if (stale()) return;
@@ -2270,7 +2331,7 @@ const chapterTmpl = `
setBadge(playerStateBadge, 'error');
playerTitle.textContent = 'Error';
setStatus('Error: ' + msg);
audio.style.display = 'none';
hideSeek();
}
// ── next-chapter prefetch display ────────────────────────────────────────────
@@ -2318,8 +2379,8 @@ const chapterTmpl = `
// ── audio event handlers (named so they can be removed on next swap) ──────────
function onTimeUpdate() {
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
updateSeek();
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) {
// still do paragraph highlight
} else if (audio.currentTime / audio.duration >= 0.8) {
prefetchFired = true;
setNextState('generating');
@@ -2393,6 +2454,38 @@ const chapterTmpl = `
setStopped();
}
// ── Media Session API (lock-screen controls + background playback) ────────────
function registerMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Ch.\u00a0' + CHAPTER_N,
artist: SLUG.replace(/-/g, ' '),
album: 'libnovel'
});
navigator.mediaSession.setActionHandler('play', function() {
audio.play().then(setPlaying).catch(function(){});
});
navigator.mediaSession.setActionHandler('pause', function() {
audio.pause(); setPaused();
});
navigator.mediaSession.setActionHandler('stop', function() { stop(); });
if (NEXT_N) {
navigator.mediaSession.setActionHandler('nexttrack', function() {
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
window.location.href = '/books/' + SLUG + '/chapters/' + NEXT_N;
});
}
if (PREV_N) {
navigator.mediaSession.setActionHandler('previoustrack', function() {
window.location.href = '/books/' + SLUG + '/chapters/' + PREV_N;
});
}
// seekto handler so lock-screen scrubbing works
navigator.mediaSession.setActionHandler('seekto', function(d) {
if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); }
});
}
// ── main entry point ─────────────────────────────────────────────────────────
function startAudio() {
setGenerating();
@@ -2401,6 +2494,7 @@ const chapterTmpl = `
if (stale()) return;
audio.src = url;
audio.load();
registerMediaSession();
});
}