Custom seek bar, bigger nav buttons, Media Session API for background playback
This commit is contained in:
@@ -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">
|
||||
←
|
||||
</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">▶</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">
|
||||
⚙
|
||||
</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">
|
||||
→
|
||||
</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('▶');
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user