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:
1 parent
ec13124d99
commit
d355129134
3 files changed
+93
-20
No files matched your search
+38
-1
@@ -1,6 +1,9 @@
|
|||||||
const CACHE = 'ns-v39';
|
const CACHE = 'ns-v40';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
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 => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
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 => {
|
self.addEventListener('fetch', e => {
|
||||||
if (!e.request.url.startsWith(self.location.origin)) return;
|
if (!e.request.url.startsWith(self.location.origin)) return;
|
||||||
if (SKIP_PATHS.some(p => e.request.url.includes(p))) 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(
|
e.respondWith(
|
||||||
fetch(e.request)
|
fetch(e.request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|||||||
@@ -127,8 +127,6 @@ window.addEventListener('load', () => {
|
|||||||
S.audio.addEventListener('ended', onEnded);
|
S.audio.addEventListener('ended', onEnded);
|
||||||
S.audio.addEventListener('play', () => {
|
S.audio.addEventListener('play', () => {
|
||||||
S._trackChanging = false;
|
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();
|
absInterrupt();
|
||||||
setPlayIcon(true);
|
setPlayIcon(true);
|
||||||
document.getElementById('player-art').classList.add('spinning');
|
document.getElementById('player-art').classList.add('spinning');
|
||||||
|
|||||||
+55
-17
@@ -16,6 +16,16 @@ function _cachedCoverUrl(id, size) {
|
|||||||
return _coverCache.get(key);
|
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) ───
|
// ─── PRELOAD (resource hint keeps connection open through Doze gap) ───
|
||||||
let _preUrl = '';
|
let _preUrl = '';
|
||||||
let _preId = '';
|
let _preId = '';
|
||||||
@@ -74,37 +84,65 @@ export function playCurrent() {
|
|||||||
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; // capture generation — any callback that sees gen !== _playGen is stale
|
||||||
S._trackChanging = true;
|
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';
|
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);
|
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;
|
||||||
|
|
||||||
// Only resets _trackChanging if no newer playCurrent() has started since.
|
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
||||||
// Prevents rapid skips from letting a stale reset() clobber the active transition.
|
|
||||||
const _reset = () => { if (_playGen === gen) S._trackChanging = false; };
|
|
||||||
|
|
||||||
// Wait for canplay (audio engine — not throttled by Android background timers).
|
// Called once audio is confirmed playing — safe moment to swap MediaSession metadata
|
||||||
// _trackChanging stays TRUE so Android audio-focus-loss pause events can't reach
|
// and kick off next-track preload (artwork already warm via the 2s timer below).
|
||||||
// Samsung and dismiss the lock screen notification during recovery.
|
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() {
|
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', () => {
|
S.audio.addEventListener('canplay', () => {
|
||||||
clearTimeout(guard);
|
clearTimeout(guard);
|
||||||
if (_playGen !== gen) return; // newer playCurrent() took over — do nothing
|
if (_playGen !== gen) return; // newer playCurrent() took over — do nothing
|
||||||
if (!S.audio.paused) return; // already playing — play event will clear flag
|
if (!S.audio.paused) { _applySession(); return; } // already playing
|
||||||
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
|
_setAudioSession();
|
||||||
S.audio.play().catch(_reset);
|
S.audio.play().then(_applySession).catch(_resetFlag);
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const _initGuard = setTimeout(_reset, 10000);
|
const _initGuard = setTimeout(() => {
|
||||||
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
|
// 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()
|
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 => {
|
.catch(err => {
|
||||||
clearTimeout(_initGuard);
|
clearTimeout(_initGuard);
|
||||||
if (_playGen !== gen) return; // superseded — let the newer call handle everything
|
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;
|
document.getElementById('player-title').textContent = s.title;
|
||||||
const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean);
|
const artistParts = (s.artist || '').split(/\s*(?:\/|•|,)\s*/).filter(Boolean);
|
||||||
document.getElementById('player-artist-name').innerHTML = artistParts.length
|
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>';
|
else art.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||||
updateStarBtn();
|
updateStarBtn();
|
||||||
document.title = `${s.title} — NovaSonic`;
|
document.title = `${s.title} — NovaSonic`;
|
||||||
updateMediaSession(s);
|
if (!_skipMediaSession) updateMediaSession(s);
|
||||||
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
|
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('ns_last_played', JSON.stringify({
|
localStorage.setItem('ns_last_played', JSON.stringify({
|
||||||
|
|||||||
Reference in new issue
Block a user