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

@@ -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' });