fix: lock screen audio + notification — audioSession once, deferred metadata, SW artwork cache

- Set navigator.audioSession.type='playback' only once (guarded flag) — repeated
  reassignment was resetting the OS audio session and triggering Doze network cuts
- Defer updateMediaSession() until after play() resolves, not before audio.src swap —
  new MediaMetadata during HAVE_NOTHING state caused Samsung to close/reopen notification
- Fix silent bug: updatePlayerUI(s, true) now actually skips MediaSession (added param)
- 10s safety guards now attempt play() instead of just resetting _trackChanging flag
- SW v40: cache-first for getCoverArt with token-stripped keys — artwork served from
  cache even when Doze blocks network, survives session restarts with new auth tokens
- Pre-warm next track artwork 2s after song starts (earlier than 30s-remaining trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-04 18:37:23 +02:00
1 parent ec13124d99
commit d355129134
3 files changed
+93 -20

No files matched your search

-2
View File
@@ -127,8 +127,6 @@ window.addEventListener('load', () => {
S.audio.addEventListener('ended', onEnded);
S.audio.addEventListener('play', () => {
S._trackChanging = false;
// Tell OS this is a music playback app — keeps background network alive (Doze mode)
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
absInterrupt();
setPlayIcon(true);
document.getElementById('player-art').classList.add('spinning');
+55 -17
View File
@@ -16,6 +16,16 @@ function _cachedCoverUrl(id, size) {
return _coverCache.get(key);
}
// ─── AUDIO SESSION — set once, never again ───
// Reassigning audioSession.type on every track change resets the OS audio session,
// which can cut background network briefly — exactly what causes Doze to kill autoplay.
let _audioSessionSet = false;
function _setAudioSession() {
if (_audioSessionSet) return;
_audioSessionSet = true;
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
}
// ─── PRELOAD (resource hint keeps connection open through Doze gap) ───
let _preUrl = '';
let _preId = '';
@@ -74,37 +84,65 @@ export function playCurrent() {
const s = S.queue[S.qIdx];
const gen = ++_playGen; // capture generation — any callback that sees gen !== _playGen is stale
S._trackChanging = true;
// Keep Samsung notification alive — set playing BEFORE metadata swap
// Keep Samsung notification alive — set playing BEFORE any metadata swap
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
updatePlayerUI(s);
// 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.
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
// Only resets _trackChanging if no newer playCurrent() has started since.
// Prevents rapid skips from letting a stale reset() clobber the active transition.
const _reset = () => { if (_playGen === gen) S._trackChanging = false; };
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
// Wait for canplay (audio engine — not throttled by Android background timers).
// _trackChanging stays TRUE so Android audio-focus-loss pause events can't reach
// Samsung and dismiss the lock screen notification during recovery.
// 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).
const _applySession = () => {
if (_playGen !== gen) return;
updateMediaSession(s);
};
// 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(_reset, 10000); // 10s Kima-style failsafe
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.
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) return; // already playing — play event will clear flag
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
S.audio.play().catch(_reset);
if (!S.audio.paused) { _applySession(); return; } // already playing
_setAudioSession();
S.audio.play().then(_applySession).catch(_resetFlag);
}, { once: true });
}
const _initGuard = setTimeout(_reset, 10000);
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
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);
_setAudioSession();
S.audio.play()
.then(() => clearTimeout(_initGuard))
.then(() => {
clearTimeout(_initGuard);
_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
@@ -118,7 +156,7 @@ export function playCurrent() {
});
}
export function updatePlayerUI(s) {
export function updatePlayerUI(s, _skipMediaSession = false) {
document.getElementById('player-title').textContent = s.title;
const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean);
document.getElementById('player-artist-name').innerHTML = artistParts.length
@@ -136,7 +174,7 @@ export function updatePlayerUI(s) {
else art.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
updateStarBtn();
document.title = `${s.title} — NovaSonic`;
updateMediaSession(s);
if (!_skipMediaSession) updateMediaSession(s);
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
try {
localStorage.setItem('ns_last_played', JSON.stringify({