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
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)
- 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`

View File

@@ -1527,6 +1527,7 @@ const Player = {
},
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
// start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume;
@@ -1630,6 +1631,7 @@ const Player = {
afterLoad() {
addToHistory(current.meta);
Notes.load(current.meta);
Wave.load(current.meta);
Remote.changed();
buildQualityMenu();
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
// when an A-B loop is set for it.
const id = current.meta.id;
if (playFullMode) {
// Play-in-full ignores resume positions and A markers alike.
if (playFullMode || this._handoff) {
// 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) {
const saved = data.resumePositions[id];
const restore = () => {
@@ -1784,7 +1787,7 @@ const Player = {
this.applyVolume();
this.applySpeed();
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);
// Cached/offline playback is just as susceptible to a silently-paused
// background audio element as streamed playback (audio-only mode still
@@ -1832,13 +1835,34 @@ const Player = {
if (this.secondary) this.secondary.load();
const onReady = () => {
this.play();
this.master.removeEventListener('canplay', onReady);
this._startPlayback();
};
this.master.addEventListener('canplay', onReady);
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() {
this._wantsPlaying = true;
this.master.play().catch(() => {});
@@ -3790,6 +3814,352 @@ const Remote = (() => {
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() {
const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0;
@@ -3799,6 +4169,8 @@ function updateProgress() {
updateMiniBar();
updateServiceProgress(cur, dur);
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
// 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").
@@ -4003,6 +4375,7 @@ function playNext() {
}
// Fired when a track finishes on its own — honors single-video loop first.
function onTrackEnded() {
if (Transition.busy()) return; // crossfade/gapless already moved on
if (!playFullMode && data.settings.loopOne) { Player.seek(0); Player.play(); return; }
if (!advanceQueue()) {
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`); }
});
$('sleepCancelBtn').addEventListener('click', () => { cancelSleepTimer(); toast('Sleep timer cancelled'); });
$('transitionBtn').addEventListener('click', () => Transition.cycle());
// A-B markers
$('abABtn').addEventListener('click', setAbA);
@@ -6358,6 +6732,7 @@ document.querySelectorAll('.chip').forEach((c) => {
els.audioOnlyToggle.addEventListener('change', () => {
data.settings.audioOnly = els.audioOnlyToggle.checked;
persist();
Wave.refresh();
if (!current) return;
const t = Player.master.currentTime;
const q = chooseQuality();
@@ -7028,6 +7403,7 @@ async function boot() {
await checkPlaylistInbox();
startPlaylistInboxWatch();
Remote.boot();
Transition.renderButton();
applyAppearance();
updateProfileStatus();
updateLoopRepeatButtons();

View File

@@ -141,6 +141,9 @@
<div class="seek-row">
<span id="curTime" class="time">0:00</span>
<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" />
<div id="seekMarkers" class="seek-markers" aria-hidden="true"></div>
<div id="abIndicator" class="ab-indicator hidden">
@@ -151,6 +154,7 @@
</div>
<span id="durTime" class="time">0:00</span>
</div>
<button id="waveLoud" class="wave-loud hidden" type="button">⚡ Jump to the loudest part</button>
<div class="btn-row">
<button id="prevBtn" class="ctrl" title="Previous">⏮</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="loopBtn" class="ctrl" title="Loop current video (L)">🔂</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>
</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);
}
.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/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/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
* GET /api/media/:id/status { status, size, height, optimized, error }
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
* 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/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) => {
try {
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });