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() {
|
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 {}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user