Add gapless and crossfade playlist transitions with a mode toggle, and a waveform seek bar for audio-only playback
This commit is contained in:
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();
|
||||
|
||||
Reference in New Issue
Block a user