From d3551291347efb82277d036593937171168bd4be Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 4 May 2026 18:37:23 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20lock=20screen=20audio=20+=20notification?= =?UTF-8?q?=20=E2=80=94=20audioSession=20once,=20deferred=20metadata,=20SW?= =?UTF-8?q?=20artwork=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/sw.js | 39 +++++++++++++++++++++++++++- src/main.js | 2 -- src/player.js | 72 +++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/public/sw.js b/public/sw.js index 09af483..42c2ce3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,9 @@ -const CACHE = 'ns-v39'; +const CACHE = 'ns-v40'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; +// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. +// The actual fetch still uses the full URL (with token); only the stored key is normalized. +const STRIP_PARAMS = ['t', 's', 'u', 'v', 'c', 'f']; self.addEventListener('install', e => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); @@ -23,9 +26,43 @@ self.addEventListener('message', e => { } }); +// Build a stable cache key by stripping Navidrome auth params +function _cacheKey(url) { + try { + const u = new URL(url); + STRIP_PARAMS.forEach(p => u.searchParams.delete(p)); + return u.toString(); + } catch { + return url; + } +} + self.addEventListener('fetch', e => { if (!e.request.url.startsWith(self.location.origin)) return; if (SKIP_PATHS.some(p => e.request.url.includes(p))) return; + + const isCoverArt = e.request.url.includes('/rest/getCoverArt'); + + if (isCoverArt) { + // Cache-first for artwork — serves instantly from SW cache even when Doze blocks network. + // Key is token-stripped so it survives session restarts with new auth tokens. + const keyUrl = _cacheKey(e.request.url); + const keyReq = new Request(keyUrl); + e.respondWith( + caches.open(CACHE).then(cache => + cache.match(keyReq).then(cached => { + if (cached) return cached; + return fetch(e.request).then(res => { + if (res.status === 200) cache.put(keyReq, res.clone()); + return res; + }).catch(() => Response.error()); + }) + ) + ); + return; + } + + // All other requests: network-first, fall back to cache e.respondWith( fetch(e.request) .then(res => { diff --git a/src/main.js b/src/main.js index fd1212a..0c59b77 100644 --- a/src/main.js +++ b/src/main.js @@ -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'); diff --git a/src/player.js b/src/player.js index 19e3374..92ac03d 100644 --- a/src/player.js +++ b/src/player.js @@ -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 ? `` : '
♪
'; 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({