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:
1 parent
d355129134
commit
1ede8727a2
1 file changed
+42
-40
+42
-40
@@ -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
|
||||
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();
|
||||
}
|
||||
_waitCanPlay();
|
||||
_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 {}
|
||||
}
|
||||
Reference in new issue
Block a user