diff --git a/scraper/internal/server/ui.go b/scraper/internal/server/ui.go index 09e00e4..97a62b9 100644 --- a/scraper/internal/server/ui.go +++ b/scraper/internal/server/ui.go @@ -1908,7 +1908,24 @@ const chapterTmpl = ` @@ -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(); }); }