diff --git a/src/player.js b/src/player.js index 92ac03d..192043e 100644 --- a/src/player.js +++ b/src/player.js @@ -82,77 +82,77 @@ 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 + const gen = ++_playGen; S._trackChanging = true; - // Keep Samsung notification alive — set playing BEFORE any metadata swap + // Hold notification open with 'playing' state BEFORE touching metadata or src if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; - // Update player bar DOM but NOT MediaSession metadata yet — setting metadata while the - // audio element is about to enter HAVE_NOTHING (src change) causes Chrome to briefly - // signal "stopped" internally, which makes Samsung close and re-open the lock screen - // notification. We defer updateMediaSession() until after play() actually resolves. + // DOM update only — MediaSession metadata deferred until audio actually starts. + // Changing metadata during the HAVE_NOTHING window (after src swap) causes Chrome to + // signal "stopped" to Android, making Samsung close and re-open the lock screen card. updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); + const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); _clearPreload(); S.audio.src = src; const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; - // Called once audio is confirmed playing — safe moment to swap MediaSession metadata - // and kick off next-track preload (artwork already warm via the 2s timer below). + // Called once per transition when audio is confirmed playing const _applySession = () => { if (_playGen !== gen) return; updateMediaSession(s); + setTimeout(_preloadNext, 2000); }; - // Wait for canplay (audio engine event — not throttled by Android background timers). - // _trackChanging stays TRUE throughout so spurious pause events during buffering can't - // reach Samsung and dismiss the lock screen notification. - function _waitCanPlay() { - const guard = setTimeout(() => { - // canplay didn't fire within 10s (Doze likely stalled the network). - // Last-ditch: try play() anyway — the element may have buffered enough already. - // If it fails, _onForeground() will retry when the user opens the app. + // ── canplay-first recovery (armed BEFORE play() call) ────────────────────── + // canplay fires from the audio engine — not throttled by Android Doze or JS + // timer limits — so this is the only reliable wakeup on a locked screen. + // We arm it now so it's ready whether play() hangs, fails, or never resolves. + let _guardTimer = null; + let _canplayFn = null; + + function _armCanplay() { + if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); + clearTimeout(_guardTimer); + + _guardTimer = setTimeout(() => { + // canplay didn't fire in 10 s — Doze likely stalled the network. + // Try play() anyway; _onForeground() will retry if the user opens the app. if (_playGen !== gen) return; S._trackChanging = false; S.audio.play().then(_applySession).catch(() => {}); }, 10000); - S.audio.addEventListener('canplay', () => { - clearTimeout(guard); - if (_playGen !== gen) return; // newer playCurrent() took over — do nothing - if (!S.audio.paused) { _applySession(); return; } // already playing + + _canplayFn = () => { + clearTimeout(_guardTimer); + _canplayFn = null; + if (_playGen !== gen) return; + if (!S.audio.paused) { _applySession(); return; } _setAudioSession(); S.audio.play().then(_applySession).catch(_resetFlag); - }, { once: true }); + }; + S.audio.addEventListener('canplay', _canplayFn, { once: true }); } - const _initGuard = setTimeout(() => { - // play() neither resolved nor rejected in 10s — hardware stall, try once more - if (_playGen !== gen) return; - S._trackChanging = false; - S.audio.play().then(_applySession).catch(() => {}); - }, 10000); - + _armCanplay(); // arm before play() so we never miss the canplay event _setAudioSession(); S.audio.play() .then(() => { - clearTimeout(_initGuard); + // Immediate success — cancel canplay guard, apply session now + clearTimeout(_guardTimer); + if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn); _applySession(); - // Pre-warm next track artwork 2s into playback — early enough for lock screen - // notification to find it in cache on the next skip - setTimeout(_preloadNext, 2000); }) .catch(err => { - clearTimeout(_initGuard); - if (_playGen !== gen) return; // superseded — let the newer call handle everything - if (err?.name === 'AbortError') { - // 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(); - } - _waitCanPlay(); + if (_playGen !== gen) return; + // Any failure (AbortError, NotAllowedError, etc.): reset the element so + // the audio engine starts fresh and canplay fires reliably. + // _trackChanging stays true so load()'s pause event stays suppressed. + S.audio.src = src; + S.audio.load(); + _armCanplay(); // re-arm: canplay may have already fired once before the error }); } @@ -613,6 +613,8 @@ export function _restoreLastPlayed() { // set src so the play button works immediately without needing to re-select the song S.audio.src = streamUrl(s.id); S.audio.load(); - updateMediaSession(s); + // Do NOT call updateMediaSession() here — setting stale metadata before the user + // starts playing means playCurrent()'s _applySession() later looks like a "new session" + // to Samsung (old song → new song metadata while playing → notification resets). } catch {} }