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:
1 parent
be73f717ac
commit
46e252b494
3 files changed
+94
-49
No files matched your search
@@ -742,6 +742,7 @@
|
|||||||
|
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
<audio id="audio-element" preload="auto" playsinline crossorigin="anonymous"></audio>
|
<audio id="audio-element" preload="auto" playsinline crossorigin="anonymous"></audio>
|
||||||
|
<audio id="audio-element-b" preload="auto" playsinline crossorigin="anonymous"></audio>
|
||||||
<audio id="preview-audio"></audio>
|
<audio id="preview-audio"></audio>
|
||||||
<audio id="abs-audio"></audio>
|
<audio id="abs-audio"></audio>
|
||||||
|
|
||||||
|
|||||||
+46
-35
@@ -122,6 +122,10 @@ window.addEventListener('load', () => {
|
|||||||
|
|
||||||
// Wire up audio elements from DOM
|
// Wire up audio elements from DOM
|
||||||
S.audio = document.getElementById('audio-element');
|
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');
|
S.previewAudio = document.getElementById('preview-audio');
|
||||||
|
|
||||||
const saved = localStorage.getItem('ns_cfg');
|
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(); }
|
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(); });
|
document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); });
|
||||||
S.audio.addEventListener('timeupdate', updateProgress);
|
// Listeners are bound to BOTH audio elements so a skip can hand off to the pre-buffered
|
||||||
S.audio.addEventListener('ended', onEnded);
|
// one with no rebinding. Handlers that only apply to the playing element guard with
|
||||||
S.audio.addEventListener('play', () => {
|
// `el !== S.audio` (the idle standby element must not affect UI/recovery).
|
||||||
S._trackChanging = false;
|
let _errRetries = 0, _errTimer = null;
|
||||||
absInterrupt();
|
function bindAudioEl(el) {
|
||||||
if (S.previewCurrent) stopPreview(false);
|
el.addEventListener('timeupdate', updateProgress);
|
||||||
setPlayIcon(true);
|
el.addEventListener('ended', onEnded);
|
||||||
document.getElementById('player-art').classList.add('spinning');
|
el.addEventListener('play', () => {
|
||||||
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
|
S._trackChanging = false;
|
||||||
});
|
absInterrupt();
|
||||||
S.audio.addEventListener('pause', () => {
|
if (S.previewCurrent) stopPreview(false);
|
||||||
setPlayIcon(false);
|
setPlayIcon(true);
|
||||||
document.getElementById('player-art').classList.remove('spinning');
|
document.getElementById('player-art').classList.add('spinning');
|
||||||
// Suppress during track change — src swap fires pause before the new track starts
|
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
|
||||||
if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
|
});
|
||||||
});
|
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('timeupdate', updatePreviewProgress);
|
||||||
S.previewAudio.addEventListener('ended', stopPreview);
|
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);
|
const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10);
|
||||||
setVolume(isNaN(_savedVol) ? 55 : _savedVol);
|
setVolume(isNaN(_savedVol) ? 55 : _savedVol);
|
||||||
|
|
||||||
|
|||||||
+47
-14
@@ -276,10 +276,19 @@ function _setAudioSession() {
|
|||||||
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
|
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PRELOAD — shadow Audio element actually buffers the next track ──────────
|
// ─── PRELOAD — the standby audio element buffers the next track ──────────────
|
||||||
let _preUrl = '';
|
// Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to
|
||||||
let _preId = '';
|
// it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the
|
||||||
let _preAudio = null;
|
// new artwork is ready too. Falls back to a source-swap when the next track isn't preloaded.
|
||||||
|
let _preUrl = '';
|
||||||
|
let _preId = '';
|
||||||
|
|
||||||
|
/** The audio element that is NOT currently active (or null if there's only one). */
|
||||||
|
function _standbyEl() {
|
||||||
|
const els = S._audioEls;
|
||||||
|
if (!els || els.length < 2) return null;
|
||||||
|
return els.find(e => e !== S.audio) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function _nextIdx() {
|
function _nextIdx() {
|
||||||
if(S.repeat === 'one') return S.qIdx;
|
if(S.repeat === 'one') return S.qIdx;
|
||||||
@@ -294,13 +303,12 @@ function _preloadNext() {
|
|||||||
if(ni < 0) return;
|
if(ni < 0) return;
|
||||||
const next = S.queue[ni];
|
const next = S.queue[ni];
|
||||||
if(!next || _preId === next.id) return;
|
if(!next || _preId === next.id) return;
|
||||||
|
const standby = _standbyEl();
|
||||||
|
if(!standby) return;
|
||||||
_preId = next.id;
|
_preId = next.id;
|
||||||
_preUrl = streamUrl(next.id);
|
_preUrl = streamUrl(next.id);
|
||||||
// Real Audio preload — actually buffers the stream so canplay fires instantly
|
standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly
|
||||||
if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
|
try { standby.load(); } catch {}
|
||||||
_preAudio = new Audio();
|
|
||||||
_preAudio.preload = 'auto';
|
|
||||||
_preAudio.src = _preUrl;
|
|
||||||
if(next.coverArt) {
|
if(next.coverArt) {
|
||||||
[256, 512, 1024].forEach(size => {
|
[256, 512, 1024].forEach(size => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -311,7 +319,8 @@ function _preloadNext() {
|
|||||||
|
|
||||||
function _clearPreload() {
|
function _clearPreload() {
|
||||||
_preId = ''; _preUrl = '';
|
_preId = ''; _preUrl = '';
|
||||||
if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
|
const standby = _standbyEl();
|
||||||
|
if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
|
// ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
|
||||||
@@ -397,10 +406,6 @@ export function playCurrent() {
|
|||||||
renderQueue();
|
renderQueue();
|
||||||
_scheduleScrobble(s.id, s.duration);
|
_scheduleScrobble(s.id, s.duration);
|
||||||
|
|
||||||
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
|
|
||||||
_clearPreload();
|
|
||||||
S.audio.src = src;
|
|
||||||
|
|
||||||
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
||||||
|
|
||||||
const _applySession = () => {
|
const _applySession = () => {
|
||||||
@@ -409,6 +414,34 @@ export function playCurrent() {
|
|||||||
setTimeout(_preloadNext, 2000);
|
setTimeout(_preloadNext, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Instant skip: if the standby element already buffered THIS track, hand playback over
|
||||||
|
// to it (no source reload → no ~1s wait). The new title/cover were already set above. ──
|
||||||
|
const standby = _standbyEl();
|
||||||
|
if (standby && _preId === s.id && standby.readyState >= 2) {
|
||||||
|
const old = S.audio;
|
||||||
|
standby.currentTime = 0;
|
||||||
|
standby.volume = old.volume; standby.muted = old.muted; standby.playbackRate = old.playbackRate || 1;
|
||||||
|
S.audio = standby; // the active element is now the preloaded one
|
||||||
|
_preId = ''; _preUrl = '';
|
||||||
|
try { old.pause(); } catch {} // 'pause' is ignored for the now-inactive element
|
||||||
|
_setAudioSession();
|
||||||
|
S.audio.play()
|
||||||
|
.then(() => { S._trackChanging = false; _applySession(); })
|
||||||
|
.catch(() => {
|
||||||
|
// Handoff playback failed — recover with a fresh source load on this element.
|
||||||
|
if (_playGen !== gen) return;
|
||||||
|
S.audio.src = streamUrl(s.id);
|
||||||
|
try { S.audio.load(); } catch {}
|
||||||
|
S.audio.play().then(() => { S._trackChanging = false; _applySession(); }).catch(_resetFlag);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback: classic source swap on the current element (unchanged) ──
|
||||||
|
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
|
||||||
|
_clearPreload();
|
||||||
|
S.audio.src = src;
|
||||||
|
|
||||||
let _guardTimer = null;
|
let _guardTimer = null;
|
||||||
let _canplayFn = null;
|
let _canplayFn = null;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user