fix: resolve queue desync causing player bar / NP to show wrong song

Root cause: addToQueue was sent to native twice for every radio top-up.
fillRadioQueue() already sends addToQueue internally, but onTrackChanged
and onQueueEnded also re-sent the same songs in their .then() callbacks,
causing native to accumulate duplicates. With native having double the
songs JS has, native indices no longer map to the correct JS queue entries.

Also fixed: playQueue called fillRadioQueue before setQueue was dispatched,
so addToQueue could land in the old native queue before setQueue wiped it.

Changes:
- fillRadioQueue: add _queueGen guard — bail if playQueue() replaced the
  queue while the network request was in flight
- playQueue: increment _queueGen; move fillRadioQueue() inside the rAF
  after setQueue so addToQueue always lands in the correct native queue
- onTrackChanged: remove .then() that re-sent addToQueue (double-send fix)
- onQueueEnded: remove addToQueue re-send, keep only _nsBridge('play')
- onPlaybackStateChanged: add safety-net sync when isPlaying transitions
  to true — catches any remaining edge cases where onTrackChanged missed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-21 00:02:42 +02:00
1 parent f97e31178c
commit 9fe8656a21
1 file changed
+30 -13
+30 -13
View File
@@ -134,6 +134,20 @@ window.NSPlayer = {
document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying); document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying);
if (navigator.mediaSession) if (navigator.mediaSession)
navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused'; navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused';
// Safety net: when playback starts, verify JS is showing the correct track.
// Catches any edge cases where onTrackChanged was missed or queue drifted.
if (state.isPlaying) {
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
const liveIdx = live.currentIndex;
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
liveIdx >= 0 && liveIdx < S.queue.length) {
S.qIdx = liveIdx;
const s = S.queue[liveIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
}
} catch {}
}
} }
} catch (e) {} } catch (e) {}
}, },
@@ -158,15 +172,9 @@ window.NSPlayer = {
updatePlayerUI(s, true); updatePlayerUI(s, true);
onNpSongChange(s.id); onNpSongChange(s.id);
if (indexChanged) renderQueue(); if (indexChanged) renderQueue();
// Radio mode: top up the queue when running low; push new songs to native. // Radio mode: top up the queue when running low.
if (S.radioMode && S.queue.length - S.qIdx < 5) { // fillRadioQueue sends addToQueue to native internally — no re-send here.
const prevLen = S.queue.length; if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
fillRadioQueue().then(() => {
S.queue.slice(prevLen).forEach(t =>
_nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1)
);
});
}
} catch (e) {} } catch (e) {}
}, },
@@ -200,10 +208,9 @@ window.NSPlayer = {
setPlayIcon(false); setPlayIcon(false);
if (S.radioMode) { if (S.radioMode) {
const prevLen = S.queue.length; const prevLen = S.queue.length;
// fillRadioQueue sends addToQueue to native internally — only trigger play here.
fillRadioQueue().then(() => { fillRadioQueue().then(() => {
const newTracks = S.queue.slice(prevLen); if (S.queue.length > prevLen) _nsBridge('play');
newTracks.forEach(t => _nsBridge('addToQueue', JSON.stringify(_trackToNative(t)), -1));
if (newTracks.length > 0) _nsBridge('play');
}); });
} }
}, },
@@ -272,8 +279,12 @@ function _clearPreload() {
// ─── PLAYBACK ───────────────────────────────────────────────────────────── // ─── PLAYBACK ─────────────────────────────────────────────────────────────
let _playGen = 0; let _playGen = 0;
// Incremented on every playQueue() call so in-flight fillRadioQueue callbacks
// from the previous queue can detect they're stale and bail out.
let _queueGen = 0;
export function playQueue(songs, startIdx) { export function playQueue(songs, startIdx) {
_queueGen++;
S.queue = songs.map(s => ({...s})); S.queue = songs.map(s => ({...s}));
S.qIdx = startIdx; S.qIdx = startIdx;
_clearPreload(); _clearPreload();
@@ -291,8 +302,10 @@ export function playQueue(songs, startIdx) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { window.NSBridge.setQueue(trackListJson, startIdx, true); } try { window.NSBridge.setQueue(trackListJson, startIdx, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); } catch (e) { console.warn('NSBridge.setQueue failed', e); }
}); // fillRadioQueue AFTER setQueue so addToQueue lands in the new native queue,
// not the old one that setQueue is about to wipe.
if (S.radioMode) fillRadioQueue(); if (S.radioMode) fillRadioQueue();
});
return; return;
} }
@@ -624,6 +637,7 @@ export async function toggleRadioMode() {
export async function fillRadioQueue() { export async function fillRadioQueue() {
if(!S.radioMode) return; if(!S.radioMode) return;
const gen = _queueGen;
try { try {
const seed = S.queue[S.qIdx]; const seed = S.queue[S.qIdx];
let newSongs = []; let newSongs = [];
@@ -636,6 +650,9 @@ export async function fillRadioQueue() {
const r = await api('getRandomSongs', '&size=20'); const r = await api('getRandomSongs', '&size=20');
newSongs = r.randomSongs?.song || []; newSongs = r.randomSongs?.song || [];
} }
// Bail if playQueue() was called while we were awaiting — appending to a
// replaced queue would desync native (setQueue already wiped the old one).
if (gen !== _queueGen) return;
// Deduplicate against songs already queued // Deduplicate against songs already queued
const existingIds = new Set(S.queue.map(s => s.id)); const existingIds = new Set(S.queue.map(s => s.id));
newSongs = newSongs.filter(s => !existingIds.has(s.id)); newSongs = newSongs.filter(s => !existingIds.has(s.id));