fix: arm canplay before play(), reset on any error, remove stale MediaSession on restore

- playCurrent(): arm canplay listener BEFORE calling play() so it's never missed
  when play() hangs (common on locked Android — promise neither resolves nor rejects)
- playCurrent(): on ANY play() failure reset audio element (src + load()) so the
  audio engine starts fresh and canplay fires reliably; previously only AbortError
  triggered the reset — NotAllowedError (audio focus lost on lock screen) did not
- playCurrent(): single _armCanplay() helper replaces two separate guard/listener
  setups; re-arms after failure in case canplay already fired before the error
- _restoreLastPlayed(): remove updateMediaSession() call — pre-setting stale song
  metadata caused Samsung to treat the first actual playCurrent() as a new session
  (old song metadata → new song metadata while playing = notification close/reopen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-04 18:55:10 +02:00
1 parent d355129134
commit 1ede8727a2
1 file changed
+42 -40
+42 -40
View File
@@ -82,77 +82,77 @@ export function playQueue(songs, startIdx) {
export function playCurrent() { export function playCurrent() {
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
const s = S.queue[S.qIdx]; 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; 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'; if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
// Update player bar DOM but NOT MediaSession metadata yet — setting metadata while the // DOM update only — MediaSession metadata deferred until audio actually starts.
// audio element is about to enter HAVE_NOTHING (src change) causes Chrome to briefly // Changing metadata during the HAVE_NOTHING window (after src swap) causes Chrome to
// signal "stopped" internally, which makes Samsung close and re-open the lock screen // signal "stopped" to Android, making Samsung close and re-open the lock screen card.
// notification. We defer updateMediaSession() until after play() actually resolves.
updatePlayerUI(s, true); updatePlayerUI(s, true);
onNpSongChange(s.id); onNpSongChange(s.id);
renderQueue(); renderQueue();
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload(); _clearPreload();
S.audio.src = src; S.audio.src = src;
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
// Called once audio is confirmed playing — safe moment to swap MediaSession metadata // Called once per transition when audio is confirmed playing
// and kick off next-track preload (artwork already warm via the 2s timer below).
const _applySession = () => { const _applySession = () => {
if (_playGen !== gen) return; if (_playGen !== gen) return;
updateMediaSession(s); updateMediaSession(s);
setTimeout(_preloadNext, 2000);
}; };
// Wait for canplay (audio engine event — not throttled by Android background timers). // ── canplay-first recovery (armed BEFORE play() call) ──────────────────────
// _trackChanging stays TRUE throughout so spurious pause events during buffering can't // canplay fires from the audio engine — not throttled by Android Doze or JS
// reach Samsung and dismiss the lock screen notification. // timer limits — so this is the only reliable wakeup on a locked screen.
function _waitCanPlay() { // We arm it now so it's ready whether play() hangs, fails, or never resolves.
const guard = setTimeout(() => { let _guardTimer = null;
// canplay didn't fire within 10s (Doze likely stalled the network). let _canplayFn = null;
// Last-ditch: try play() anyway — the element may have buffered enough already.
// If it fails, _onForeground() will retry when the user opens the app. 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; if (_playGen !== gen) return;
S._trackChanging = false; S._trackChanging = false;
S.audio.play().then(_applySession).catch(() => {}); S.audio.play().then(_applySession).catch(() => {});
}, 10000); }, 10000);
S.audio.addEventListener('canplay', () => {
clearTimeout(guard); _canplayFn = () => {
if (_playGen !== gen) return; // newer playCurrent() took over — do nothing clearTimeout(_guardTimer);
if (!S.audio.paused) { _applySession(); return; } // already playing _canplayFn = null;
if (_playGen !== gen) return;
if (!S.audio.paused) { _applySession(); return; }
_setAudioSession(); _setAudioSession();
S.audio.play().then(_applySession).catch(_resetFlag); S.audio.play().then(_applySession).catch(_resetFlag);
}, { once: true }); };
S.audio.addEventListener('canplay', _canplayFn, { once: true });
} }
const _initGuard = setTimeout(() => { _armCanplay(); // arm before play() so we never miss the canplay event
// 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);
_setAudioSession(); _setAudioSession();
S.audio.play() S.audio.play()
.then(() => { .then(() => {
clearTimeout(_initGuard); // Immediate success — cancel canplay guard, apply session now
clearTimeout(_guardTimer);
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
_applySession(); _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 => { .catch(err => {
clearTimeout(_initGuard); if (_playGen !== gen) return;
if (_playGen !== gen) return; // superseded — let the newer call handle everything // Any failure (AbortError, NotAllowedError, etc.): reset the element so
if (err?.name === 'AbortError') { // the audio engine starts fresh and canplay fires reliably.
// Audio element in bad state — reload so it settles; _trackChanging stays true // _trackChanging stays true so load()'s pause event stays suppressed.
// so load()'s pause event is still suppressed
S.audio.src = src; S.audio.src = src;
S.audio.load(); S.audio.load();
} _armCanplay(); // re-arm: canplay may have already fired once before the error
_waitCanPlay();
}); });
} }
@@ -613,6 +613,8 @@ export function _restoreLastPlayed() {
// set src so the play button works immediately without needing to re-select the song // set src so the play button works immediately without needing to re-select the song
S.audio.src = streamUrl(s.id); S.audio.src = streamUrl(s.id);
S.audio.load(); 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 {} } catch {}
} }