Add gapless and crossfade playlist transitions with a mode toggle, and a waveform seek bar for audio-only playback

This commit is contained in:
Jonathan Sykes
2026-09-19 16:12:33 +08:00
parent 71fc51fb50
commit ca07e1fdf1
5 changed files with 485 additions and 4 deletions

View File

@@ -115,6 +115,28 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
forever and Chrome killed the tab ("Target crashed" in Playwright, no JS forever and Chrome killed the tab ("Target crashed" in Playwright, no JS
error). Never cap with a loop whose exit depends on a deferred removal. error). Never cap with a loop whose exit depends on a deferred removal.
## Playlist transitions + waveform (`Transition` / `Wave` in app.js)
- `data.settings.transition` = `off` | `gapless` | `crossfade` (`crossfadeSec`,
default 6); the ↦/⇥/⤨ button cycles it with a 6-s toast. Applies only to
playlist/queue playback with a next song, not with loop-one or an A-B loop.
- A hidden **bridge `<audio>`** preloads the next song's audio ~25 s early
(offline copy, else `/api/streams`). Crossfade starts it `crossfadeSec` early
and ramps both volumes; gapless starts it when the song ends. Then the queue
advances normally with `Player._handoff` set: `_startPlayback()` seeks the
new track to the bridge's position, plays, and releases the bridge on
`playing` (failsafe 5 s / 20 s). afterLoad skips resume/A-marker seeks
during a handoff; `onTrackEnded` ignores `ended` while one runs.
- iOS ignores `element.volume`, so crossfade degrades to gapless there.
- Waveform: `GET /api/media/:id/peaks` = 400 RMS buckets (0..100, ^0.7 so
quiet verses stay visible) from ffmpeg over the server-cached copy, memoised
per file path (gen). Audio-only mode draws it on `#waveCanvas` BEHIND the
range input (made transparent, still the control), + hover time tooltip and
"⚡ Jump to the loudest part" (loudest ~5 s window). Not cached on the server
→ plain seek bar. Envelopes are kept in localStorage (`ytpPeaks`, 80 songs).
- Tested with real audio: a local server whose media_cache holds 20-s clips
(upsertMedia rows + `<id>.1.m4a/.mp4` files) — Playwright's Chromium plays
AAC; launch with `--autoplay-policy=no-user-gesture-required`.
## Phone remote (`server/remote.js`, `Remote` in app.js) ## Phone remote (`server/remote.js`, `Remote` in app.js)
- Desktop tab playing to a TV = **host**; phone = **remote**. Both are browser - Desktop tab playing to a TV = **host**; phone = **remote**. Both are browser
tabs of this app, so the server relays over `/ws/remote` (Bun `server.upgrade` tabs of this app, so the server relays over `/ws/remote` (Bun `server.upgrade`

View File

@@ -1527,6 +1527,7 @@ const Player = {
}, },
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true, nocache = false } = {}) { async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true, nocache = false } = {}) {
if (!this._handoff) Transition.cancel();
// When false (auto-advance / prev), skip resuming the saved timestamp and // When false (auto-advance / prev), skip resuming the saved timestamp and
// start from the beginning (or the A marker, if an A-B loop is set). // start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume; this._resumeOnLoad = resume;
@@ -1630,6 +1631,7 @@ const Player = {
afterLoad() { afterLoad() {
addToHistory(current.meta); addToHistory(current.meta);
Notes.load(current.meta); Notes.load(current.meta);
Wave.load(current.meta);
Remote.changed(); Remote.changed();
buildQualityMenu(); buildQualityMenu();
els.playerPane.classList.remove('empty'); els.playerPane.classList.remove('empty');
@@ -1671,8 +1673,9 @@ const Player = {
// a freshly selected track should start at the beginning, or at the A point // a freshly selected track should start at the beginning, or at the A point
// when an A-B loop is set for it. // when an A-B loop is set for it.
const id = current.meta.id; const id = current.meta.id;
if (playFullMode) { if (playFullMode || this._handoff) {
// Play-in-full ignores resume positions and A markers alike. // Play-in-full ignores resume positions and A markers alike; a
// crossfade/gapless handoff continues from the bridge's position.
} else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) { } else if (this._resumeOnLoad && rememberPosEnabled() && data.resumePositions[id] && data.resumePositions[id] > 1) {
const saved = data.resumePositions[id]; const saved = data.resumePositions[id];
const restore = () => { const restore = () => {
@@ -1784,7 +1787,7 @@ const Player = {
this.applyVolume(); this.applyVolume();
this.applySpeed(); this.applySpeed();
this.master.load(); this.master.load();
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); }; const onReadyLocal = () => { this.master.removeEventListener('canplay', onReadyLocal); this._startPlayback(); };
this.master.addEventListener('canplay', onReadyLocal); this.master.addEventListener('canplay', onReadyLocal);
// Cached/offline playback is just as susceptible to a silently-paused // Cached/offline playback is just as susceptible to a silently-paused
// background audio element as streamed playback (audio-only mode still // background audio element as streamed playback (audio-only mode still
@@ -1832,13 +1835,34 @@ const Player = {
if (this.secondary) this.secondary.load(); if (this.secondary) this.secondary.load();
const onReady = () => { const onReady = () => {
this.play();
this.master.removeEventListener('canplay', onReady); this.master.removeEventListener('canplay', onReady);
this._startPlayback();
}; };
this.master.addEventListener('canplay', onReady); this.master.addEventListener('canplay', onReady);
this.startDrift(); this.startDrift();
}, },
// Start the freshly attached track. A crossfade/gapless handoff (see
// Transition) has the next song already sounding on a bridge <audio>; pick
// up from the bridge's position and release it once we are really playing.
_handoff: null,
_startPlayback() {
const h = this._handoff;
this._handoff = null;
if (!h) { this.play(); return; }
try { const t = h.at(); if (t > 0.05) this.seek(t); } catch { /* bridge gone */ }
this.play();
let done = false;
const release = () => {
if (done) return;
done = true;
this.master.removeEventListener('playing', release);
h.release();
};
this.master.addEventListener('playing', release);
setTimeout(release, 5000); // never leave two songs playing
},
play() { play() {
this._wantsPlaying = true; this._wantsPlaying = true;
this.master.play().catch(() => {}); this.master.play().catch(() => {});
@@ -3790,6 +3814,352 @@ const Remote = (() => {
return { boot, changed }; return { boot, changed };
})(); })();
// ============================================================================
// Playlist transitions: normal · gapless · crossfade
// ============================================================================
// A hidden bridge <audio> preloads the NEXT song's audio ~25 s before the end.
// Gapless starts it the instant the current song ends; crossfade starts it
// `crossfadeSec` early and ramps the two volumes. Then the queue advances as
// usual and the main player, once loaded, picks up at the bridge's position
// (Player._startPlayback) and the bridge is released — so the switch never
// has the load-and-buffer silence of a normal advance.
// iOS ignores element.volume, so there crossfade degrades to gapless.
const TRANSITION_MODES = ['off', 'gapless', 'crossfade'];
const Transition = (() => {
const bridge = new Audio();
bridge.preload = 'auto';
bridge.setAttribute('playsinline', '');
const st = { forId: null, nextId: null, src: '', ready: false, phase: 'idle', ramp: null, gapTimer: null, blob: null, preparing: false };
const volumeWorks = (() => {
try { const a = new Audio(); a.volume = 0.5; return Math.abs(a.volume - 0.5) < 0.01; } catch { return false; }
})();
const mode = () => {
const m = data.settings.transition || 'off';
return m === 'crossfade' && !volumeWorks ? 'gapless' : m;
};
const fadeSec = () => Math.max(2, Math.min(12, Number(data.settings.crossfadeSec) || 6));
function nextIndex() {
if (queueIndex >= 0 && queueIndex < queue.length - 1) return queueIndex + 1;
if (!playFullMode && data.settings.repeatMode === 'all' && queue.length > 1) return 0;
return -1;
}
function eligible() {
if (mode() === 'off' || !current || !current.meta) return false;
if (!(queueSource.startsWith('playlist:') || queueSource === 'queue')) return false;
if (data.settings.loopOne && !playFullMode) return false;
if (abA !== null || abB !== null) return false;
if (Player.master.paused || Player._handoff) return false;
return nextIndex() >= 0;
}
function clearBridge() {
clearInterval(st.ramp);
clearTimeout(st.gapTimer);
st.ramp = null;
st.gapTimer = null;
try { bridge.pause(); } catch { /* nothing loaded */ }
bridge.removeAttribute('src');
try { bridge.load(); } catch { /* fine */ }
if (st.blob) { try { if (window.OPFS) window.OPFS.revokeUrl(st.blob); else URL.revokeObjectURL(st.blob); } catch { /* gone */ } st.blob = null; }
st.forId = st.nextId = null;
st.src = '';
st.ready = false;
st.preparing = false;
}
function cancel() {
if (st.phase === 'handoff') return; // the new track is taking over
if (st.phase === 'fading') Player.applyVolume();
st.phase = 'idle';
clearBridge();
}
// Audio for the next song: the offline copy if this device has one, else
// the same stream resolution the player uses (server copy or proxy).
async function audioUrlFor(v) {
if (v.custom || cachedIds.has(v.id)) {
if (WEB && window.OPFS) {
const u = await window.OPFS.getFileUrl(v.id);
if (u) { st.blob = u; return u; }
}
if (v.custom) return '';
}
const res = await API.getStreams(v.id, {});
if (!res || !res.ok || !res.data) return '';
const d = res.data;
if (d.audioUrl) return d.audioUrl;
const prog = (d.qualities || []).find((q) => q.hasAudio);
return prog ? prog.url : '';
}
async function prepare() {
const i = nextIndex();
const v = queue[i];
if (!v || st.preparing) return;
st.preparing = true;
st.forId = current.meta.id;
st.nextId = v.id;
try {
const url = await audioUrlFor(v);
if (!url || st.forId !== (current && current.meta && current.meta.id)) { st.preparing = false; return; }
st.src = url;
bridge.src = url;
bridge.volume = 0;
bridge.muted = false;
bridge.playbackRate = parseFloat(els.speed.value) || 1;
bridge.load();
bridge.addEventListener('canplay', () => { st.ready = true; }, { once: true });
} catch { /* stays not ready → a normal advance happens */ }
st.preparing = false;
}
function startBridge(volume) {
try { bridge.currentTime = 0; } catch { /* not seekable yet */ }
bridge.volume = volume;
return bridge.play().catch(() => false);
}
// Advance the queue with the bridge carrying the sound.
function handoff() {
clearInterval(st.ramp);
st.ramp = null;
st.phase = 'handoff';
const h = {
at: () => bridge.currentTime || 0,
release: () => {
if (Player._handoff === h) Player._handoff = null;
st.phase = 'idle';
// A short fade-out hides any overlap while the main element settles.
const v0 = bridge.volume;
let k = 0;
const t = setInterval(() => {
k++;
try { bridge.volume = Math.max(0, v0 * (1 - k / 4)); } catch { /* fine */ }
if (k >= 4) { clearInterval(t); clearBridge(); }
}, 40);
},
};
Player._handoff = h;
// Safety: if the next track never starts, don't leave the bridge playing.
setTimeout(() => { if (Player._handoff === h) h.release(); }, 20000);
Player.soundEl.volume = data.settings.volume ?? 1;
if (!advanceQueue()) { Player._handoff = null; st.phase = 'idle'; clearBridge(); }
}
function startCrossfade(remaining) {
st.phase = 'fading';
const target = data.settings.volume ?? 1;
const total = Math.max(0.5, remaining);
const t0 = performance.now();
startBridge(0);
st.ramp = setInterval(() => {
const p = Math.min(1, (performance.now() - t0) / 1000 / total);
try { bridge.volume = target * p; } catch { /* fine */ }
try { Player.soundEl.volume = target * (1 - p); } catch { /* fine */ }
if (p >= 1 || Player.master.ended) handoff();
}, 50);
}
function startGapless(remaining) {
st.phase = 'gap-armed';
const rate = Player.master.playbackRate || 1;
st.gapTimer = setTimeout(() => {
startBridge(data.settings.volume ?? 1);
Player.master.pause();
handoff();
}, Math.max(0, (remaining / rate) * 1000 - 40));
}
// Called on every timeupdate (~4×/s) from updateProgress.
function tick(cur, dur) {
if (st.phase === 'handoff') return;
if (!eligible() || !dur || !isFinite(dur)) {
if (st.phase === 'fading' || st.phase === 'gap-armed') cancel();
else if (st.forId && (!current || st.forId !== current.meta.id)) cancel();
return;
}
if (st.forId && st.forId !== current.meta.id) clearBridge();
const remaining = dur - cur;
const lead = mode() === 'crossfade' ? fadeSec() : 0;
if (!st.forId && remaining < lead + 25) prepare();
if (st.phase !== 'idle' || !st.ready) return;
if (mode() === 'crossfade' && remaining <= lead) startCrossfade(remaining);
else if (mode() === 'gapless' && remaining <= 0.6) startGapless(remaining);
}
function label(m = mode()) {
return m === 'crossfade' ? `Crossfade (${fadeSec()} s)` : m === 'gapless' ? 'Gapless' : 'Normal';
}
function renderButton() {
const btn = $('transitionBtn');
if (!btn) return;
const m = data.settings.transition || 'off';
btn.textContent = m === 'crossfade' ? '⤨' : m === 'gapless' ? '⇥' : '↦';
btn.classList.toggle('active', m !== 'off');
btn.title = `Between songs in a playlist: ${label(m === 'crossfade' && !volumeWorks ? 'gapless' : m)} — click to change`;
}
function cycle() {
const cur = data.settings.transition || 'off';
const next = TRANSITION_MODES[(TRANSITION_MODES.indexOf(cur) + 1) % TRANSITION_MODES.length];
data.settings.transition = next;
persist();
cancel();
renderButton();
const text = next === 'crossfade'
? (volumeWorks ? `⤨ Playback mode: Crossfade — songs in a playlist blend over ${fadeSec()} s` : '⤨ Crossfade needs volume control this device lacks — playing gapless instead')
: next === 'gapless' ? '⇥ Playback mode: Gapless — the next song starts the instant this one ends'
: '↦ Playback mode: Normal — a short pause while the next song loads';
toast(text, { duration: 6000 });
}
// Read-only snapshot for diagnostics and tests.
const state = () => ({ phase: st.phase, ready: st.ready, nextId: st.nextId, bridgeT: bridge.currentTime, bridgePaused: bridge.paused, bridgeVol: bridge.volume });
return { tick, cancel, cycle, renderButton, busy: () => st.phase === 'handoff' || st.phase === 'gap-armed', mode, label, state };
})();
// ============================================================================
// Waveform seek bar (audio-only playback)
// ============================================================================
// Loudness envelope from GET /api/media/:id/peaks (server-cached songs only),
// drawn as bars behind the (now transparent) range input, so tap/drag/keyboard
// seeking are the same control as before. Offline, envelopes seen earlier come
// from localStorage.
const Wave = (() => {
const wrap = document.querySelector('.seek-wrap');
const canvas = $('waveCanvas');
if (!wrap || !canvas) return { load() {}, draw() {}, refresh() {} };
const ctx = canvas.getContext('2d');
const CACHE_KEY = 'ytpPeaks';
const st = { id: null, peaks: null, seq: 0, hoverX: null, lastKey: '' };
const readCache = () => { try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; } };
function writeCache(id, peaks) {
try {
const c = readCache();
c[id] = { p: peaks.map((v) => String.fromCharCode(48 + Math.round(v * 0.74))).join(''), at: Date.now() };
const ids = Object.keys(c);
if (ids.length > 80) ids.sort((a, b) => c[a].at - c[b].at).slice(0, ids.length - 80).forEach((k) => delete c[k]);
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch { /* storage full */ }
}
const fromCache = (id) => {
const e = readCache()[id];
return e && e.p ? [...e.p].map((ch) => (ch.charCodeAt(0) - 48) / 0.74) : null;
};
const active = () => !!(st.peaks && data.settings.audioOnly && current && current.meta && current.meta.id === st.id);
async function load(meta) {
const seq = ++st.seq;
st.id = meta && meta.id;
st.peaks = null;
refresh();
if (!WEB || !st.id || meta.custom || !/^[A-Za-z0-9_-]{11}$/.test(st.id)) return;
const cached = fromCache(st.id);
if (cached) { st.peaks = cached; refresh(); }
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`/api/media/${encodeURIComponent(st.id)}/peaks`);
if (seq !== st.seq) return;
if (res.status === 503) { await new Promise((r) => setTimeout(r, 1500)); continue; }
const j = await res.json();
if (!j.ok || !Array.isArray(j.peaks)) return; // not cached on the server → plain seek bar
st.peaks = j.peaks;
writeCache(st.id, j.peaks);
refresh();
return;
} catch { return; } // offline — keep the cached envelope if any
}
}
function loudest() {
if (!st.peaks) return null;
// Loudest ~5 s window, not a single spike.
const n = st.peaks.length, w = Math.max(1, Math.round(n / 60));
let best = -1, at = 0;
for (let i = 0; i + w <= n; i++) {
let s = 0;
for (let k = i; k < i + w; k++) s += st.peaks[k];
if (s > best) { best = s; at = i; }
}
return at / n;
}
function refresh() {
const on = active();
wrap.classList.toggle('wave-on', on);
$('waveLoud').classList.toggle('hidden', !on);
st.lastKey = '';
if (on) draw();
}
function draw() {
if (!active()) return;
const dur = Player.master.duration || (current.meta && current.meta.duration) || 0;
const cur = Player.master.currentTime || 0;
const p = dur ? Math.min(1, cur / dur) : 0;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const key = `${w}x${h}|${Math.round(p * 1000)}|${st.hoverX}`;
if (key === st.lastKey) return;
st.lastKey = key;
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(document.documentElement);
const played = cs.getPropertyValue('--accent').trim() || '#ff4b32';
const rest = cs.getPropertyValue('--line').trim() || '#333';
const hover = cs.getPropertyValue('--text-dim').trim() || '#777';
const barW = 3, gap = 2;
const bars = Math.max(20, Math.floor(w / (barW + gap)));
const n = st.peaks.length;
for (let b = 0; b < bars; b++) {
const a = Math.floor((b / bars) * n), z = Math.max(a + 1, Math.floor(((b + 1) / bars) * n));
let v = 0;
for (let k = a; k < z; k++) v = Math.max(v, st.peaks[k]);
const bh = Math.max(2, (v / 100) * (h - 4));
const x = b * (barW + gap);
const frac = (b + 0.5) / bars;
ctx.fillStyle = frac <= p ? played : (st.hoverX !== null && x <= st.hoverX ? hover : rest);
ctx.fillRect(x, (h - bh) / 2, barW, bh);
}
// Playhead
ctx.fillStyle = cs.getPropertyValue('--text').trim() || '#fff';
ctx.fillRect(Math.round(p * w) - 1, 0, 2, h);
}
// Hover tooltip with the time under the pointer.
const tip = $('waveTip');
wrap.addEventListener('pointermove', (e) => {
if (!active()) return;
const r = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(r.width, e.clientX - r.left));
st.hoverX = x;
const dur = Player.master.duration || current.meta.duration || 0;
tip.textContent = fmtTime((x / r.width) * dur);
tip.style.left = `${x}px`;
tip.classList.remove('hidden');
draw();
});
wrap.addEventListener('pointerleave', () => { st.hoverX = null; tip.classList.add('hidden'); draw(); });
$('waveLoud').addEventListener('click', () => {
const f = loudest();
const dur = Player.master.duration || current.meta.duration || 0;
if (f !== null && dur) Player.seek(f * dur);
});
window.addEventListener('resize', () => { st.lastKey = ''; draw(); });
return { load, draw, refresh };
})();
function updateProgress() { function updateProgress() {
const cur = Player.master.currentTime || 0; const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0; const dur = Player.master.duration || current?.meta?.duration || 0;
@@ -3799,6 +4169,8 @@ function updateProgress() {
updateMiniBar(); updateMiniBar();
updateServiceProgress(cur, dur); updateServiceProgress(cur, dur);
Notes.tick(cur); Notes.tick(cur);
Transition.tick(cur, dur);
Wave.draw();
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only // A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and // when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list"). // we advance per the repeat settings (next track, or wrap when "repeat list").
@@ -4003,6 +4375,7 @@ function playNext() {
} }
// Fired when a track finishes on its own — honors single-video loop first. // Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() { function onTrackEnded() {
if (Transition.busy()) return; // crossfade/gapless already moved on
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; } if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
if (!advanceQueue()) { if (!advanceQueue()) {
if (playFullMode) { playFullMode = false; toast('Finished playing in full'); } if (playFullMode) { playFullMode = false; toast('Finished playing in full'); }
@@ -6136,6 +6509,7 @@ document.querySelectorAll('.chip').forEach((c) => {
else { startSleepTimer(next); toast(`Pausing in ${next} min`); } else { startSleepTimer(next); toast(`Pausing in ${next} min`); }
}); });
$('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); }); $('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });
$('transitionBtn').addEventListener('click', () => Transition.cycle());
// A-B markers // A-B markers
$('abABtn').addEventListener('click', setAbA); $('abABtn').addEventListener('click', setAbA);
@@ -6358,6 +6732,7 @@ document.querySelectorAll('.chip').forEach((c) => {
els.audioOnlyToggle.addEventListener('change', () => { els.audioOnlyToggle.addEventListener('change', () => {
data.settings.audioOnly = els.audioOnlyToggle.checked; data.settings.audioOnly = els.audioOnlyToggle.checked;
persist(); persist();
Wave.refresh();
if (!current) return; if (!current) return;
const t = Player.master.currentTime; const t = Player.master.currentTime;
const q = chooseQuality(); const q = chooseQuality();
@@ -7028,6 +7403,7 @@ async function boot() {
await checkPlaylistInbox(); await checkPlaylistInbox();
startPlaylistInboxWatch(); startPlaylistInboxWatch();
Remote.boot(); Remote.boot();
Transition.renderButton();
applyAppearance(); applyAppearance();
updateProfileStatus(); updateProfileStatus();
updateLoopRepeatButtons(); updateLoopRepeatButtons();

View File

@@ -141,6 +141,9 @@
<div class="seek-row"> <div class="seek-row">
<span id="curTime" class="time">0:00</span> <span id="curTime" class="time">0:00</span>
<div class="seek-wrap"> <div class="seek-wrap">
<!-- Audio-only: loudness waveform drawn behind the transparent range input -->
<canvas id="waveCanvas" class="wave-canvas" aria-hidden="true"></canvas>
<span id="waveTip" class="wave-tip hidden"></span>
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" /> <input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
<div id="seekMarkers" class="seek-markers" aria-hidden="true"></div> <div id="seekMarkers" class="seek-markers" aria-hidden="true"></div>
<div id="abIndicator" class="ab-indicator hidden"> <div id="abIndicator" class="ab-indicator hidden">
@@ -151,6 +154,7 @@
</div> </div>
<span id="durTime" class="time">0:00</span> <span id="durTime" class="time">0:00</span>
</div> </div>
<button id="waveLoud" class="wave-loud hidden" type="button">⚡ Jump to the loudest part</button>
<div class="btn-row"> <div class="btn-row">
<button id="prevBtn" class="ctrl" title="Previous">⏮</button> <button id="prevBtn" class="ctrl" title="Previous">⏮</button>
<button id="playBtn" class="ctrl play" title="Play/Pause">▶</button> <button id="playBtn" class="ctrl play" title="Play/Pause">▶</button>
@@ -188,6 +192,7 @@
<button id="shuffleBtn" class="ctrl" title="Shuffle upcoming tracks (S)">🔀</button> <button id="shuffleBtn" class="ctrl" title="Shuffle upcoming tracks (S)">🔀</button>
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button> <button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button> <button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
<button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button>
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button> <button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
</div> </div>
</div> </div>

View File

@@ -3778,3 +3778,27 @@ body.remote-open { overflow: hidden; }
font-size: clamp(22px, 4vw, 40px); font-weight: 700; color: var(--text-2); font-size: clamp(22px, 4vw, 40px); font-weight: 700; color: var(--text-2);
} }
.sl-empty small { font-family: var(--ui); font-size: 16px; font-weight: 500; color: var(--text-dim); } .sl-empty small { font-family: var(--ui); font-size: 16px; font-weight: 500; color: var(--text-dim); }
/* ============================================================================
* Waveform seek bar (Wave in app.js) — audio-only playback
* ========================================================================== */
.wave-canvas { display: none; }
.seek-wrap.wave-on .wave-canvas { display: block; width: 100%; height: 46px; }
.seek-wrap.wave-on .seek {
position: absolute; left: 0; right: 0; top: 0;
width: 100%; height: 46px; margin: 0;
opacity: 0; cursor: pointer;
}
.seek-wrap.wave-on .seek-markers { top: -6px; }
.wave-tip {
position: absolute; top: -26px; transform: translateX(-50%);
padding: 3px 7px; border-radius: 6px;
background: var(--bg-3); border: 1px solid var(--line); color: var(--text);
font-family: var(--mono); font-size: 11px; pointer-events: none; white-space: nowrap;
}
.wave-loud {
display: block; margin: 6px auto 0; padding: 4px 10px;
background: transparent; border: 1px solid var(--line); border-radius: 999px;
color: var(--text-2); font-size: 12px; cursor: pointer;
}
.wave-loud:hover { color: var(--text); border-color: var(--accent); }

View File

@@ -12,6 +12,7 @@
* GET /api/play?v=<id>&f=<fmt> same-origin playback proxy (Range-aware) → media bytes * GET /api/play?v=<id>&f=<fmt> same-origin playback proxy (Range-aware) → media bytes
* GET /api/download/:videoId server-cached copy (fetched by a background job) → binary * GET /api/download/:videoId server-cached copy (fetched by a background job) → binary
* GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar) * GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar)
* GET /api/media/:id/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
* GET /api/media/:id/status { status, size, height, optimized, error } * GET /api/media/:id/status { status, size, height, optimized, error }
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again * POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
* GET /api/media/stats cache totals, budget, free disk, queue * GET /api/media/stats cache totals, budget, free disk, queue
@@ -1066,6 +1067,59 @@ app.get('/api/media/:id', async (c) => {
}); });
// GET /api/media/:id/status // GET /api/media/:id/status
// GET /api/media/:id/peaks — loudness envelope of a server-cached copy for
// the waveform seek bar: PEAKS_N RMS buckets scaled 0..100. Computed once per
// file with ffmpeg (mono, 2 kHz is plenty for an envelope) and kept in memory;
// the file path carries the gen, so a replaced copy is recomputed.
const PEAKS_N = 400;
const PEAKS_RATE = 2000;
const peaksCache = new Map();
let peaksRunning = 0;
function computePeaks(path) {
return new Promise((resolve, reject) => {
const child = spawn(FFMPEG, ['-v', 'error', '-i', path, '-vn', '-ac', '1', '-ar', String(PEAKS_RATE), '-f', 's16le', '-'],
{ stdio: ['ignore', 'pipe', 'pipe'] });
const chunks = [];
let err = '';
child.stdout.on('data', (d) => chunks.push(d));
child.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) return reject(new Error(err.trim() || `ffmpeg exited ${code}`));
const buf = Buffer.concat(chunks);
const n = Math.floor(buf.length / 2);
if (!n) return reject(new Error('no audio'));
const per = Math.max(1, Math.ceil(n / PEAKS_N));
const rms = [];
for (let b = 0; b * per < n; b++) {
let sum = 0, cnt = 0;
for (let i = b * per; i < Math.min(n, (b + 1) * per); i++) { const v = buf.readInt16LE(i * 2); sum += v * v; cnt++; }
rms.push(Math.sqrt(sum / Math.max(1, cnt)));
}
const max = Math.max(...rms) || 1;
// ^0.7 lifts quiet passages so verses stay visible next to a loud bridge.
resolve({ duration: n / PEAKS_RATE, peaks: rms.map((v) => Math.round(Math.pow(v / max, 0.7) * 100)) });
});
});
}
app.get('/api/media/:id/peaks', async (c) => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
let hit = peaksCache.get(path);
if (!hit) {
if (peaksRunning >= 2) return c.json({ ok: false, error: 'busy — retry shortly' }, 503);
peaksRunning++;
try { hit = await computePeaks(path); } catch (err) { return c.json({ ok: false, error: err.message }, 500); }
finally { peaksRunning--; }
peaksCache.set(path, hit);
if (peaksCache.size > 300) peaksCache.delete(peaksCache.keys().next().value);
}
return c.json({ ok: true, ...hit }, 200, { 'Cache-Control': 'public, max-age=3600' });
});
app.get('/api/media/:id/status', async (c) => { app.get('/api/media/:id/status', async (c) => {
try { try {
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' }); return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });