From ec13124d99ee47fa2572c0a00181648e39c229b6 Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 4 May 2026 00:38:55 +0200 Subject: [PATCH] fix: generation counter prevents stale _reset() from clobbering active transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When skipping rapidly, old playCurrent() closures had _reset() callbacks that could fire _trackChanging=false while a newer transition was still in progress — breaking both notification guard and background recovery. Each playCurrent() now captures a generation number (++_playGen). _reset() is a no-op if _playGen has advanced (newer call took over). canplay and play().catch handlers also check the generation before acting. This makes all stale callbacks safe regardless of how quickly the user skips. Co-Authored-By: Claude Sonnet 4.6 --- src/player.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/player.js b/src/player.js index beac6d4..19e3374 100644 --- a/src/player.js +++ b/src/player.js @@ -58,6 +58,8 @@ function _clearPreload() { } // ─── PLAYBACK ─── +let _playGen = 0; // incremented on every playCurrent() — stale callbacks check this + export function playQueue(songs, startIdx) { S.queue = songs.map(s => ({...s})); S.qIdx = startIdx; @@ -70,6 +72,7 @@ export function playQueue(songs, startIdx) { export function playCurrent() { if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; const s = S.queue[S.qIdx]; + const gen = ++_playGen; // capture generation — any callback that sees gen !== _playGen is stale S._trackChanging = true; // Keep Samsung notification alive — set playing BEFORE metadata swap if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; @@ -80,16 +83,19 @@ export function playCurrent() { _clearPreload(); S.audio.src = src; - const _reset = () => { S._trackChanging = false; }; + // Only resets _trackChanging if no newer playCurrent() has started since. + // Prevents rapid skips from letting a stale reset() clobber the active transition. + const _reset = () => { if (_playGen === gen) S._trackChanging = false; }; - // After play() fails: wait for canplay (audio engine, not throttled by Android timers). - // _trackChanging stays TRUE throughout so spurious pause events from Android revoking - // audio focus on locked screen can't slip through and dismiss the Samsung cover screen. + // Wait for canplay (audio engine — not throttled by Android background timers). + // _trackChanging stays TRUE so Android audio-focus-loss pause events can't reach + // Samsung and dismiss the lock screen notification during recovery. function _waitCanPlay() { const guard = setTimeout(_reset, 10000); // 10s Kima-style failsafe S.audio.addEventListener('canplay', () => { clearTimeout(guard); - if (S.queue[S.qIdx]?.id !== s.id || !S.audio.paused) { _reset(); return; } + if (_playGen !== gen) return; // newer playCurrent() took over — do nothing + if (!S.audio.paused) return; // already playing — play event will clear flag try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} S.audio.play().catch(_reset); }, { once: true }); @@ -101,16 +107,13 @@ export function playCurrent() { .then(() => clearTimeout(_initGuard)) .catch(err => { clearTimeout(_initGuard); - if (S.queue[S.qIdx]?.id !== s.id) { _reset(); return; } + if (_playGen !== gen) return; // superseded — let the newer call handle everything if (err?.name === 'AbortError') { - // Audio element in bad state (OS interruption / load race) — reload so the - // element settles; _trackChanging stays true so load()'s pause is suppressed + // Audio element in bad state — reload so it settles; _trackChanging stays true + // so load()'s pause event is still suppressed S.audio.src = src; S.audio.load(); } - // For ALL errors: keep _trackChanging=true until canplay fires (or 10s timeout). - // This prevents Android audio-focus-loss pause events from reaching Samsung - // and dismissing the lock screen notification during the recovery window. _waitCanPlay(); }); }