Add gapless and crossfade playlist transitions with a mode toggle, and a waveform seek bar for audio-only playback
This commit is contained in:
22
CLAUDE.md
22
CLAUDE.md
@@ -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`
|
||||
|
||||
384
frontend/app.js
384
frontend/app.js
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user