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 ? `` : '