diff --git a/index.html b/index.html index 6d192ec..e824827 100644 --- a/index.html +++ b/index.html @@ -742,6 +742,7 @@
+ diff --git a/src/main.js b/src/main.js index 9c84d7a..8dd66f8 100644 --- a/src/main.js +++ b/src/main.js @@ -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); diff --git a/src/player/player.js b/src/player/player.js index 8e59d07..04550ba 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -276,10 +276,19 @@ function _setAudioSession() { try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} } -// ─── PRELOAD — shadow Audio element actually buffers the next track ────────── -let _preUrl = ''; -let _preId = ''; -let _preAudio = null; +// ─── PRELOAD — the standby audio element buffers the next track ────────────── +// Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to +// it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the +// 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() { if(S.repeat === 'one') return S.qIdx; @@ -294,13 +303,12 @@ function _preloadNext() { if(ni < 0) return; const next = S.queue[ni]; if(!next || _preId === next.id) return; + const standby = _standbyEl(); + if(!standby) return; _preId = next.id; _preUrl = streamUrl(next.id); - // Real Audio preload — actually buffers the stream so canplay fires instantly - if(_preAudio) { _preAudio.src = ''; _preAudio = null; } - _preAudio = new Audio(); - _preAudio.preload = 'auto'; - _preAudio.src = _preUrl; + standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly + try { standby.load(); } catch {} if(next.coverArt) { [256, 512, 1024].forEach(size => { const img = new Image(); @@ -311,7 +319,8 @@ function _preloadNext() { function _clearPreload() { _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) ────────────────────────────────────── @@ -397,10 +406,6 @@ export function playCurrent() { renderQueue(); _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 _applySession = () => { @@ -409,6 +414,34 @@ export function playCurrent() { 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 _canplayFn = null;