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) ─────────────────────────────── -->
|
<!-- ─── Compact audio player (fixed bottom) ─────────────────────────────── -->
|
||||||
<aside id="mini-player"
|
<aside id="mini-player"
|
||||||
aria-label="Audio 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) -->
|
<!-- Settings panel (opens upward) -->
|
||||||
<div id="settings-panel"
|
<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">
|
<div class="max-w-2xl mx-auto px-3 py-2 flex flex-col gap-0">
|
||||||
|
|
||||||
<!-- Single control row -->
|
<!-- 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 -->
|
<!-- Prev chapter -->
|
||||||
{{if .PrevN}}
|
{{if .PrevN}}
|
||||||
@@ -1951,11 +1968,11 @@ const chapterTmpl = `
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
title="Previous chapter"
|
title="Previous chapter"
|
||||||
aria-label="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>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="flex-shrink-0 w-8"></span>
|
<span class="flex-shrink-0 w-10"></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Play/Pause -->
|
<!-- Play/Pause -->
|
||||||
@@ -1963,7 +1980,7 @@ const chapterTmpl = `
|
|||||||
type="button"
|
type="button"
|
||||||
onclick="ttsToggle()"
|
onclick="ttsToggle()"
|
||||||
aria-label="Play/Pause"
|
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>
|
<span id="player-play-icon" aria-hidden="true">▶</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1973,7 +1990,7 @@ const chapterTmpl = `
|
|||||||
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
|
<span id="player-state-badge" class="queue-badge queue-badge-idle flex-shrink-0">idle</span>
|
||||||
</div>
|
</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">
|
<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>
|
<span id="player-next-badge" class="queue-badge queue-badge-idle">next</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1984,7 +2001,7 @@ const chapterTmpl = `
|
|||||||
onclick="toggleSettings()"
|
onclick="toggleSettings()"
|
||||||
aria-label="Reader settings"
|
aria-label="Reader settings"
|
||||||
aria-haspopup="dialog"
|
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>
|
</button>
|
||||||
|
|
||||||
@@ -1997,24 +2014,20 @@ const chapterTmpl = `
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
title="Next chapter"
|
title="Next chapter"
|
||||||
aria-label="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>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="flex-shrink-0 w-8"></span>
|
<span class="flex-shrink-0 w-10"></span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden native audio (no controls — we drive it entirely from JS) -->
|
||||||
|
<audio id="tts-audio-native" preload="none" style="display:none"></audio>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -2054,15 +2067,13 @@ const chapterTmpl = `
|
|||||||
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
.queue-badge-paused { background: #1c1917; color: #d6d3d1; }
|
||||||
.queue-badge-error { background: #450a0a; color: #f87171; }
|
.queue-badge-error { background: #450a0a; color: #f87171; }
|
||||||
|
|
||||||
/* Native audio — dark theme, hide redundant play button */
|
/* Seek bar — expanded touch hit-area without changing visual height */
|
||||||
#tts-audio-native {
|
#seek-track {
|
||||||
color-scheme: dark;
|
padding: 6px 0;
|
||||||
accent-color: #f59e0b;
|
margin: -6px 0;
|
||||||
|
box-sizing: content-box;
|
||||||
}
|
}
|
||||||
/* Chrome/Safari: hide the play button inside the native controls */
|
/* Safe-area inset for notched phones */
|
||||||
#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 */
|
|
||||||
#mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }
|
#mini-player { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -2112,6 +2123,12 @@ const chapterTmpl = `
|
|||||||
var settingsPanel = document.getElementById('settings-panel');
|
var settingsPanel = document.getElementById('settings-panel');
|
||||||
var chapterListPanel= document.getElementById('chapter-list-panel');
|
var chapterListPanel= document.getElementById('chapter-list-panel');
|
||||||
var chapterListBtn = document.getElementById('chapter-list-btn');
|
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 ─────────────────────────────────────────────────────────────
|
// ── badge helper ─────────────────────────────────────────────────────────────
|
||||||
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
|
var BADGE_CLASSES = ['queue-badge-idle','queue-badge-generating','queue-badge-ready',
|
||||||
@@ -2122,6 +2139,48 @@ const chapterTmpl = `
|
|||||||
el.textContent = state;
|
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 ─────────────────────────────────────────────────────────────
|
// ── status strip ─────────────────────────────────────────────────────────────
|
||||||
function setStatus(text) {
|
function setStatus(text) {
|
||||||
statusEl.textContent = text;
|
statusEl.textContent = text;
|
||||||
@@ -2229,7 +2288,7 @@ const chapterTmpl = `
|
|||||||
setBadge(playerStateBadge, 'generating');
|
setBadge(playerStateBadge, 'generating');
|
||||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
|
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N + '\u00a0— generating\u2026';
|
||||||
setStatus('Generating audio\u2026');
|
setStatus('Generating audio\u2026');
|
||||||
audio.style.display = 'none';
|
hideSeek();
|
||||||
}
|
}
|
||||||
function setPlaying() {
|
function setPlaying() {
|
||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
@@ -2240,14 +2299,16 @@ const chapterTmpl = `
|
|||||||
setBadge(playerStateBadge, 'playing');
|
setBadge(playerStateBadge, 'playing');
|
||||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||||
setStatus('');
|
setStatus('');
|
||||||
audio.style.display = '';
|
showSeek();
|
||||||
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
|
||||||
}
|
}
|
||||||
function setPaused() {
|
function setPaused() {
|
||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
setPlayIcon('▶');
|
setPlayIcon('▶');
|
||||||
setBadge(playerStateBadge, 'paused');
|
setBadge(playerStateBadge, 'paused');
|
||||||
setStatus('');
|
setStatus('');
|
||||||
audio.style.display = '';
|
showSeek();
|
||||||
|
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
|
||||||
}
|
}
|
||||||
function setStopped() {
|
function setStopped() {
|
||||||
highlightPara(-1);
|
highlightPara(-1);
|
||||||
@@ -2258,7 +2319,7 @@ const chapterTmpl = `
|
|||||||
setBadge(playerStateBadge, 'idle');
|
setBadge(playerStateBadge, 'idle');
|
||||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||||
setStatus('');
|
setStatus('');
|
||||||
audio.style.display = 'none';
|
hideSeek();
|
||||||
}
|
}
|
||||||
function setError(msg) {
|
function setError(msg) {
|
||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
@@ -2270,7 +2331,7 @@ const chapterTmpl = `
|
|||||||
setBadge(playerStateBadge, 'error');
|
setBadge(playerStateBadge, 'error');
|
||||||
playerTitle.textContent = 'Error';
|
playerTitle.textContent = 'Error';
|
||||||
setStatus('Error: ' + msg);
|
setStatus('Error: ' + msg);
|
||||||
audio.style.display = 'none';
|
hideSeek();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── next-chapter prefetch display ────────────────────────────────────────────
|
// ── next-chapter prefetch display ────────────────────────────────────────────
|
||||||
@@ -2318,8 +2379,8 @@ const chapterTmpl = `
|
|||||||
// ── audio event handlers (named so they can be removed on next swap) ──────────
|
// ── audio event handlers (named so they can be removed on next swap) ──────────
|
||||||
function onTimeUpdate() {
|
function onTimeUpdate() {
|
||||||
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
|
if (stale()) { audio.removeEventListener('timeupdate', onTimeUpdate); return; }
|
||||||
|
updateSeek();
|
||||||
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) {
|
if (!NEXT_N || prefetchFired || !audio.duration || !isFinite(audio.duration)) {
|
||||||
// still do paragraph highlight
|
|
||||||
} else if (audio.currentTime / audio.duration >= 0.8) {
|
} else if (audio.currentTime / audio.duration >= 0.8) {
|
||||||
prefetchFired = true;
|
prefetchFired = true;
|
||||||
setNextState('generating');
|
setNextState('generating');
|
||||||
@@ -2393,6 +2454,38 @@ const chapterTmpl = `
|
|||||||
setStopped();
|
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 ─────────────────────────────────────────────────────────
|
// ── main entry point ─────────────────────────────────────────────────────────
|
||||||
function startAudio() {
|
function startAudio() {
|
||||||
setGenerating();
|
setGenerating();
|
||||||
@@ -2401,6 +2494,7 @@ const chapterTmpl = `
|
|||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
audio.src = url;
|
audio.src = url;
|
||||||
audio.load();
|
audio.load();
|
||||||
|
registerMediaSession();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user