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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-03 22:49:35 +02:00
1 parent 3f5a5a4c43
commit a0c6d09c41
2 files changed
+24 -4

No files matched your search

+2 -1
View File
@@ -166,7 +166,8 @@ window.addEventListener('load', () => {
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if(!s) return; if(!s) return;
updateMediaSession(s); 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'; navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing';
// If audio was playing before backgrounding but is now paused (OS kill), try to resume // If audio was playing before backgrounding but is now paused (OS kill), try to resume
if(!S.audio.paused) return; if(!S.audio.paused) return;
+22 -3
View File
@@ -61,7 +61,16 @@ export function playCurrent() {
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;
// 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) => { const _tryPlay = (attempt) => {
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
S.audio.play().catch(err => { S.audio.play().catch(err => {
S._trackChanging = false; S._trackChanging = false;
if (err?.name === 'AbortError') return; // src changed mid-flight, stop if (err?.name === 'AbortError') return; // src changed mid-flight, stop
@@ -345,6 +354,16 @@ export function setVolume(v) {
} }
// ─── MEDIA SESSION ─── // ─── 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) { export function updateMediaSession(s) {
// Android native MediaSession bridge — use 512px art for high-DPI screens // Android native MediaSession bridge — use 512px art for high-DPI screens
if (window.Android) { if (window.Android) {
@@ -360,9 +379,9 @@ export function updateMediaSession(s) {
if(!navigator.mediaSession) return; if(!navigator.mediaSession) return;
const artwork = s.coverArt ? [ const artwork = s.coverArt ? [
{ src: coverUrl(s.coverArt,96), sizes:'96x96', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 96), sizes:'96x96', type:'image/jpeg' },
{ src: coverUrl(s.coverArt,300), sizes:'300x300', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 300), sizes:'300x300', type:'image/jpeg' },
{ src: coverUrl(s.coverArt,512), sizes:'512x512', 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.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
navigator.mediaSession.setActionHandler('play', togglePlay); navigator.mediaSession.setActionHandler('play', togglePlay);