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
+44 -42
+44 -42
View File
@@ -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 {}
}