perf(web): instant skips via gapless handoff to a pre-buffered element

Reintroduce the two-element handoff, this time as a skip-speed optimization
(not for the OS tile). A second <audio> element pre-buffers the next track;
on a forward skip playCurrent hands playback over to it instead of reloading
the source, so playback starts immediately instead of ~1s. Covers are
pre-warmed in parallel so the new artwork is ready too.

Listeners are bound to both elements once (handlers guard with el !== S.audio).
Safety: handoff only when the standby is buffered (readyState >= 2); otherwise
falls back to the unchanged source-swap path, and a failed handoff play()
recovers with a fresh source load. Web-only; the native bridge path is untouched.

Note: this does not change the OS media-tile flicker (a separate, MSE-only fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-06-27 11:17:16 +02:00
1 parent be73f717ac
commit 46e252b494
3 files changed
+94 -49

No files matched your search

+46 -35
View File
@@ -122,6 +122,10 @@ window.addEventListener('load', () => {
// Wire up audio elements from DOM
S.audio = document.getElementById('audio-element');
const _audioB = document.getElementById('audio-element-b');
// Two fixed audio elements for instant skips — S.audio always points at the active one,
// the other pre-buffers the next track so a skip can hand off to it without reloading.
S._audioEls = _audioB ? [S.audio, _audioB] : [S.audio];
S.previewAudio = document.getElementById('preview-audio');
const saved = localStorage.getItem('ns_cfg');
@@ -134,44 +138,51 @@ window.addEventListener('load', () => {
if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); }
document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); });
S.audio.addEventListener('timeupdate', updateProgress);
S.audio.addEventListener('ended', onEnded);
S.audio.addEventListener('play', () => {
S._trackChanging = false;
absInterrupt();
if (S.previewCurrent) stopPreview(false);
setPlayIcon(true);
document.getElementById('player-art').classList.add('spinning');
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
});
S.audio.addEventListener('pause', () => {
setPlayIcon(false);
document.getElementById('player-art').classList.remove('spinning');
// Suppress during track change — src swap fires pause before the new track starts
if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
});
// Listeners are bound to BOTH audio elements so a skip can hand off to the pre-buffered
// one with no rebinding. Handlers that only apply to the playing element guard with
// `el !== S.audio` (the idle standby element must not affect UI/recovery).
let _errRetries = 0, _errTimer = null;
function bindAudioEl(el) {
el.addEventListener('timeupdate', updateProgress);
el.addEventListener('ended', onEnded);
el.addEventListener('play', () => {
S._trackChanging = false;
absInterrupt();
if (S.previewCurrent) stopPreview(false);
setPlayIcon(true);
document.getElementById('player-art').classList.add('spinning');
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
});
el.addEventListener('pause', () => {
if (el !== S.audio) return; // ignore the idle standby element during a handoff
setPlayIcon(false);
document.getElementById('player-art').classList.remove('spinning');
// Suppress during track change — src swap fires pause before the new track starts
if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
});
// stalled = browser buffer ran out briefly; let the browser retry naturally.
el.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
el.addEventListener('error', () => {
if (el !== S.audio) return; // standby/preload errors must not disrupt active playback
// Skip if playCurrent() is already handling recovery (_trackChanging = true means
// we're mid-transition or waiting for canplay — don't conflict with that)
if(!S.queue[S.qIdx] || S._trackChanging) return;
_errRetries++;
const delay = Math.min(1000 * Math.pow(2, _errRetries - 1), 8000); // 1s, 2s, 4s, 8s
clearTimeout(_errTimer);
_errTimer = setTimeout(() => {
if(!S.queue[S.qIdx] || S._trackChanging) return;
S._trackChanging = true;
S.audio.load();
S.audio.play().catch(() => { S._trackChanging = false; });
}, delay);
});
}
S._audioEls.forEach(bindAudioEl);
S.previewAudio.addEventListener('timeupdate', updatePreviewProgress);
S.previewAudio.addEventListener('ended', stopPreview);
// stalled = browser buffer ran out briefly; let the browser retry naturally.
// The watchdog below catches genuine long freezes.
let _errRetries = 0, _errTimer = null;
S.audio.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
S.audio.addEventListener('error', () => {
// Skip if playCurrent() is already handling recovery (_trackChanging = true means
// we're mid-transition or waiting for canplay — don't conflict with that)
if(!S.queue[S.qIdx] || S._trackChanging) return;
_errRetries++;
const delay = Math.min(1000 * Math.pow(2, _errRetries - 1), 8000); // 1s, 2s, 4s, 8s
clearTimeout(_errTimer);
_errTimer = setTimeout(() => {
if(!S.queue[S.qIdx] || S._trackChanging) return;
S._trackChanging = true;
S.audio.load();
S.audio.play().catch(() => { S._trackChanging = false; });
}, delay);
});
const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10);
setVolume(isNaN(_savedVol) ? 55 : _savedVol);