From a0c6d09c410aff96a18f7769cd3ab102735d6961 Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 3 May 2026 22:49:35 +0200 Subject: [PATCH] fix: stable notification artwork + canplay retry for background audio - Cache cover art URLs per song so the same auth token is reused across metadata updates; Samsung/Chrome can cache the artwork image and the notification no longer visually reloads on every skip - Add canplay listener as background-safe play() retry: the audio element fires canplay from its internal state machine (not throttled like setTimeout by Android Doze), so play() is retried as soon as the browser has buffered enough data - Set navigator.audioSession.type='playback' on each _tryPlay attempt (not just on play event) so the OS audio session claim happens before Chrome requests the play grant - Guard _onForeground playbackState assignment with !_trackChanging to prevent visibilitychange/pageshow from stomping 'playing' to 'paused' during an in-progress track transition Co-Authored-By: Claude Sonnet 4.6 --- src/main.js | 3 ++- src/player.js | 25 ++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main.js b/src/main.js index 81a1efd..28c8180 100644 --- a/src/main.js +++ b/src/main.js @@ -166,7 +166,8 @@ window.addEventListener('load', () => { const s = S.queue[S.qIdx]; if(!s) return; updateMediaSession(s); - if(navigator.mediaSession) + // Don't stomp playbackState during a src-swap — _trackChanging guards the transition + if(navigator.mediaSession && !S._trackChanging) navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing'; // If audio was playing before backgrounding but is now paused (OS kill), try to resume if(!S.audio.paused) return; diff --git a/src/player.js b/src/player.js index d7aef7d..fefa697 100644 --- a/src/player.js +++ b/src/player.js @@ -61,7 +61,16 @@ export function playCurrent() { const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); _clearPreload(); S.audio.src = src; + // canplay is fired by the audio element's internal state machine — not throttled like + // setTimeout in background. Use it as a reliable retry trigger when play() is rejected. + S.audio.addEventListener('canplay', () => { + if (S.queue[S.qIdx]?.id === s.id && S.audio.paused) { + S._trackChanging = true; + S.audio.play().catch(() => { S._trackChanging = false; }); + } + }, { once: true }); const _tryPlay = (attempt) => { + try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} S.audio.play().catch(err => { S._trackChanging = false; if (err?.name === 'AbortError') return; // src changed mid-flight, stop @@ -345,6 +354,16 @@ export function setVolume(v) { } // ─── MEDIA SESSION ─── +// Cache cover art URLs — same auth token is reused so the OS can cache the artwork image. +// Without this, every metadata update regenerates a new token URL and Samsung re-fetches +// the artwork on each skip, causing the notification to visually "restart". +const _coverCache = new Map(); +function _cachedCoverUrl(id, size) { + const key = `${id}:${size}`; + if (!_coverCache.has(key)) _coverCache.set(key, coverUrl(id, size)); + return _coverCache.get(key); +} + export function updateMediaSession(s) { // Android native MediaSession bridge — use 512px art for high-DPI screens if (window.Android) { @@ -360,9 +379,9 @@ export function updateMediaSession(s) { if(!navigator.mediaSession) return; const artwork = s.coverArt ? [ - { src: coverUrl(s.coverArt,96), sizes:'96x96', type:'image/jpeg' }, - { src: coverUrl(s.coverArt,300), sizes:'300x300', type:'image/jpeg' }, - { src: coverUrl(s.coverArt,512), sizes:'512x512', type:'image/jpeg' }, + { src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' }, + { src: _cachedCoverUrl(s.coverArt, 300), sizes:'300x300', type:'image/jpeg' }, + { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, ] : []; navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); navigator.mediaSession.setActionHandler('play', togglePlay);