fix(queue): prevent ghost next_track after context switch

Auto-advance from a previous track could arrive after the user
switched to a new album/playlist, advancing the new queue's index
and skipping a track (observed: Sorceress track skipped).

Added queue epoch counter that increments on setQueue/clearQueue.
nextTrackGuarded() captures epoch before the invoke and discards
the result if the queue changed while in-flight. Applied to all
auto-advance paths (track-end, gapless transition) but not to
manual skip (user-initiated).

cc
This commit is contained in:
vicrodh committed 2026-04-06 11:42:28 -06:00
1 parent def821bd33
commit 9a95a54e0f
2 files changed
+32 -3

No files matched your search

+28
View File
@@ -368,11 +368,24 @@ export async function addTracksToQueueNext(tracks: BackendQueueTrack[]): Promise
}
}
/**
* Queue epoch counter — incremented on every setQueue/clearQueue call.
* Used by nextTrackGuarded() to discard stale auto-advance results
* that arrive after a context switch (album/playlist change).
*/
let queueEpoch = 0;
/** Current queue epoch (read-only for callers). */
export function getQueueEpoch(): number {
return queueEpoch;
}
/**
* Set queue with new tracks (V2)
*/
export async function setQueue(tracks: BackendQueueTrack[], startIndex: number, clearLocal = true): Promise<boolean> {
try {
queueEpoch++;
await invoke('v2_set_queue', { tracks, startIndex });
if (clearLocal) {
localTrackIds = new Set();
@@ -390,6 +403,7 @@ export async function setQueue(tracks: BackendQueueTrack[], startIndex: number,
*/
export async function clearQueue(): Promise<boolean> {
try {
queueEpoch++;
await invoke('v2_clear_queue');
return true;
} catch (err) {
@@ -422,6 +436,20 @@ export async function nextTrack(): Promise<BackendQueueTrack | null> {
}
}
/**
* Get next track, but discard the result if the queue changed while
* the invoke was in-flight (prevents ghost auto-advance after context switch).
*/
export async function nextTrackGuarded(): Promise<BackendQueueTrack | null> {
const epochBefore = queueEpoch;
const result = await nextTrack();
if (queueEpoch !== epochBefore) {
console.warn('[Queue] Discarding stale next_track result (queue changed during invoke)');
return null;
}
return result;
}
/**
* Get previous track from queue (V2)
*/
+4 -3
View File
@@ -221,6 +221,7 @@
clearQueue,
playQueueIndex,
nextTrack,
nextTrackGuarded,
previousTrack,
moveQueueTrack,
setLocalTrackIds,
@@ -4369,7 +4370,7 @@
return;
}
const previousTrackId = currentTrack?.id ?? null;
const nextTrackResult = await nextTrack();
const nextTrackResult = await nextTrackGuarded();
if (nextTrackResult) {
// Defensive fallback for issue #80:
// if backend returns same track on auto-advance while repeat-one is off,
@@ -4383,7 +4384,7 @@
'[Player] Auto-advance returned same track id, forcing one extra nextTrack()',
previousTrackId
);
const forcedNext = await nextTrack();
const forcedNext = await nextTrackGuarded();
if (forcedNext && forcedNext.id !== previousTrackId) {
await playQueueTrack(forcedNext);
return;
@@ -4496,7 +4497,7 @@
if (qconnectSuppressLocalPlaybackAutomation) return;
console.log('[Gapless] Handling transition to track', trackId);
// Advance the queue to match backend state
const advanced = await nextTrack();
const advanced = await nextTrackGuarded();
if (advanced && advanced.id === trackId) {
// Queue advanced successfully — update UI metadata
await playQueueTrack(advanced, undefined, true);