fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503)

* fix(radio): queue navigation, dedup, and similar-first variety (#500)

After a Radio session ran a while, three things broke:

Queue navigation through duplicates. playTrack re-resolved the active
queue index by `findIndex(t.id === track.id)`, returning the *first*
matching id, so reaching the second occurrence of a track snapped
queueIndex back to the earlier slot — highlight jumped and the next
advance played the wrong follow-up. Added an optional
`targetQueueIndex` to playTrack, threaded through next(), previous(),
the audio:ended repeat-one path, queue-row click, and the queue-item
context menu. findIndex stays as the fallback for callers that just
have a track and a fresh queue.

Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the
next() top-up deduped against the live queue but trimmed the played
tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from
`existingIds` and a later Last.fm/topSongs response could re-add it;
and the `.filter(...)` pass admitted intra-batch repeats (top +
similar overlap is common) because it read the dedup set before
mutating it. A module-level radioSessionSeenIds set, fed by
enqueueRadio and both top-up paths and reset on artist change and
clearQueue, closes all three: trimmed ids stay in the set, ids about
to be replaced (fresh enqueueRadio wiping the pending radio block)
are removed first so callers can re-introduce them, and the dedup
pass mutates the set inline.

Variety. Starting Radio on a track stacked five top tracks of the
seed artist before any similar-artist material played. Switched the
seed path and both top-up paths to lead with similar songs (other
artists) and only fall back to top tracks when similar comes back
empty — preserves the "no Last.fm" graceful degradation but stops
the seed artist from monopolising the front of the queue.

Not affected: gapless audio:track_switched (already index-based, no
findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues
or enqueue-only paths), the artist-radio path (no seedTrack — already
picks just one top track and fills the rest from similar).

Reported by netherguy4.

* docs: changelog entry for PR #503

Logs the radio queue navigation/dedup/similar-first fix in v1.46.0
"## Fixed".
This commit is contained in:
Frank Stellmacher
2026-05-07 19:56:57 +02:00
committed by GitHub
parent d7ff1d3113
commit 726f3f0ff2
4 changed files with 120 additions and 35 deletions
+10 -9
View File
@@ -1391,19 +1391,20 @@ export default function ContextMenu() {
}
// Load radio queue in background — enqueueRadio replaces any pending radio
// tracks so clicking "Start Radio" again never stacks duplicate batches.
// Shuffle so the follow-up tracks feel fresh instead of always being the
// same "Top 5" in the same order every time.
// Lead with similar songs (other artists) so the listener doesn't get a
// wall of the seed artist's own top tracks before anything else plays.
// Top tracks stay as a fallback for setups without Last.fm / small
// libraries where similar comes back empty (issue #500).
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
// Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
// otherwise this feels the same as Instant Mix (track-based similar only).
const topTracks = shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const radioTracks = [...topTracks, ...similarTracks];
const radioTracks = similarTracks.length > 0
? similarTracks
: shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) {
console.error('Failed to load radio queue', e);
@@ -2142,7 +2143,7 @@ export default function ContextMenu() {
const song = item as Track;
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue))}>
<div className="context-menu-item" onClick={() => handleAction(() => playTrack(song, queue, undefined, undefined, contextMenu.queueIndex))}>
<Play size={14} /> {t('contextMenu.playNow')}
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
+3 -1
View File
@@ -1063,7 +1063,9 @@ function QueuePanelHostOrSolo() {
className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === 'queue-item' && contextMenu.queueIndex === idx ? 'context-active' : ''}`}
onClick={() => {
suppressNextAutoScrollRef.current = true;
playTrack(track, queue);
// Pass the row index so a click on a duplicate track lands on
// *this* slot, not the first occurrence (issue #500).
playTrack(track, queue, undefined, undefined, idx);
}}
onContextMenu={(e) => {
e.preventDefault();