fix: generation counter prevents stale _reset() from clobbering active transition

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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-04 00:38:55 +02:00
1 parent b941672a77
commit ec13124d99
1 file changed
+14 -11
+14 -11
View File
@@ -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();
});
}