From 726f3f0ff28928160b5a9e6a154e2a6596d2f9e1 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 7 May 2026 19:56:57 +0200 Subject: [PATCH] fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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". --- CHANGELOG.md | 8 +++ src/components/ContextMenu.tsx | 19 ++--- src/components/QueuePanel.tsx | 4 +- src/store/playerStore.ts | 124 ++++++++++++++++++++++++++------- 4 files changed, 120 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 657cb783..d9be1b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The currently-playing track in any tracklist (**AlbumDetail**, **ArtistDetail**, **PlaylistDetail**, **Favorites**, **RandomMix**) ran an **`opacity` pulse** on the entire row plus three **`transform` keyframe** EQ-bar siblings — both compositor properties, but on **WebKitGTK without compositing** (Linux + NVIDIA proprietary + `WEBKIT_DISABLE_COMPOSITING_MODE=1`) every animated row falls back to a full **software repaint** of the subtree per frame. On AlbumDetail the combined cost held the WebProcess at **~80 % CPU** for the duration of playback; CPU dropped immediately on pause/stop. * `.track-row.active` keeps the **accent-tinted background** but no longer pulses. The "now playing" indicator becomes a single Lucide **`AudioLines` icon** (one SVG per active row instead of three animated spans). Cleanup: dead `track-pulse` + `eq-bounce` keyframes and a duplicate, shadowed `.eq-bar` block in `theme.css`. +### Radio — queue navigation, dedup, and similar-first variety + +**By [@Psychotoxical](https://github.com/Psychotoxical), reported by netherguy4, PR [#503](https://github.com/Psychotoxical/psysonic/pull/503)** + +* **Queue navigation through duplicates**: `playTrack` re-resolved the active slot via `findIndex(... .id === ...)`, which returns the **first** matching id. Reaching a track's second occurrence snapped `queueIndex` back to the earlier slot — highlight visibly jumped and the next auto-advance played the wrong follow-up. `next()`, `previous()`, the `audio:ended` repeat-one path, queue-row click and the queue-item Play Now now pass an explicit target index through `playTrack`. +* **Queue duplicates**: `enqueueRadio` didn't dedupe incoming tracks; the `next()` top-up deduped against the live queue but trimmed the played tail down to **5 history entries**, so songs heard a few advances ago could be re-added by a later Last.fm / topSongs response; and the `.filter(...)` pass admitted intra-batch repeats (top + similar overlap is common) because it read the dedup set before mutating it. A radio-session-scoped seen-set, reset on artist change and `clearQueue`, closes all three paths. +* **Variety**: Starting Radio on a track no longer queues five top tracks of the seed artist before any similar-artist material plays. The seed path and both top-up paths lead with similar songs and only fall back to top tracks when similar comes back empty (no Last.fm / small library). + ### Track preview — volume slider ignored during preview **By [@Psychotoxical](https://github.com/Psychotoxical), reported by netherguy4, PR [#502](https://github.com/Psychotoxical/psysonic/pull/502)** diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index a1091e11..b2146fea 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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 ( <> -
handleAction(() => playTrack(song, queue))}> +
handleAction(() => playTrack(song, queue, undefined, undefined, contextMenu.queueIndex))}> {t('contextMenu.playNow')}
handleAction(() => { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 16747744..5b1707c8 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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(); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index a581bebc..b141b3e0 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -235,8 +235,14 @@ interface PlayerState { playRadio: (station: InternetRadioStation) => void; /** `_orbitConfirmed` is an internal bypass flag — callers outside the - * orbit bulk-gate should leave it `undefined`. */ - playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean) => void; + * orbit bulk-gate should leave it `undefined`. + * `targetQueueIndex` lets callers that already know the exact target + * position (next()/previous()/queue-row click) bypass the `findIndex` + * by-id fallback, which otherwise resolves to the *first* occurrence + * and breaks navigation when the same track appears multiple times in + * the queue (issue #500). Ignored if out of range or if the track id + * at that position doesn't match. */ + playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean, targetQueueIndex?: number) => void; /** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */ reseedQueueForInstantMix: (track: Track) => void; pause: () => void; @@ -409,6 +415,13 @@ let radioFetching = false; // Artist ID used to start the current radio session — persists across track // advances so proactive loading works even when songs lack artistId. let currentRadioArtistId: string | null = null; +// Track ids the current radio session has already enqueued — *including* +// entries that were trimmed off the front of the queue when it grew too long +// (`HISTORY_KEEP` in next()'s top-up path). Without this the queue's own +// id-set wasn't enough to dedupe: a song played 8 tracks ago is gone from +// the queue and the next Last.fm/topSongs response could re-add it. Reset +// on `setRadioArtistId(other)` and on `clearQueue()`. Issue #500. +let radioSessionSeenIds = new Set(); let cachedLoudnessGainByTrackId: Record = {}; let stableLoudnessGainByTrackId: Record = {}; let lastNormalizationUiUpdateAtMs = 0; @@ -1538,7 +1551,7 @@ function handleAudioEnded() { return; } - const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); + const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState(); isAudioPaused = false; usePlayerStore.setState({ isPlaying: false, @@ -1560,7 +1573,8 @@ function handleAudioEnded() { authState.hotCacheDownloadDir || null, ); } - usePlayerStore.getState().playTrack(currentTrack, queue, false); + // Pin to the current slot — the track may appear elsewhere in the queue. + usePlayerStore.getState().playTrack(currentTrack, queue, false, false, queueIndex); } else { usePlayerStore.getState().next(false); } @@ -2466,7 +2480,7 @@ export const usePlayerStore = create()( }, // ── playTrack ──────────────────────────────────────────────────────────── - playTrack: (track, queue, manual = true, _orbitConfirmed = false) => { + playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) => { // Orbit bulk-gate: only gate when the `queue` argument *replaces* // the current queue (Play All / Play Album / Play Playlist / Hero // play buttons). Navigation calls — queue-row click, next(), @@ -2527,7 +2541,18 @@ export const usePlayerStore = create()( seekFallbackVisualTarget = null; } const newQueue = queue ?? state.queue; - const idx = newQueue.findIndex(t => sameQueueTrackId(t.id, track.id)); + // Prefer an explicit target index from the caller (next/previous/queue-row + // click already know the exact slot). `findIndex` returns the *first* + // matching id, which jumps backwards when the queue contains the same + // track twice — breaking radio playback (issue #500). + const explicitIdxValid = + typeof targetQueueIndex === 'number' + && targetQueueIndex >= 0 + && targetQueueIndex < newQueue.length + && sameQueueTrackId(newQueue[targetQueueIndex]?.id, track.id); + const idx = explicitIdxValid + ? (targetQueueIndex as number) + : newQueue.findIndex(t => sameQueueTrackId(t.id, track.id)); if (manual) { pushQueueUndoFromGetter(get); } @@ -2985,7 +3010,7 @@ export const usePlayerStore = create()( applySkipStarOnManualNext(currentTrack, manual); const nextIdx = queueIndex + 1; if (nextIdx < queue.length) { - get().playTrack(queue[nextIdx], queue, manual); + get().playTrack(queue[nextIdx], queue, manual, false, nextIdx); // Proactively top up auto-added tracks when ≤ 2 remain ahead, // so the queue never runs dry without a visible loading pause. const { infiniteQueueEnabled } = useAuthStore.getState(); @@ -3014,17 +3039,25 @@ export const usePlayerStore = create()( Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]) .then(([similar, top]) => { const existingIds = new Set(get().queue.map(t => t.id)); - const fresh: Track[] = [...top, ...similar] - .map(songToTrack) - .filter(t => !existingIds.has(t.id)) - .slice(0, 10) - .map(t => ({ ...t, radioAdded: true as const })); + // Lead with similar (other artists) for variety; top tracks + // of the upcoming artist are only a fallback when similar + // is empty. Single-pass loop dedupes against the live queue, + // the session seen-set, and intra-batch overlap (issue #500). + const sourceList = similar.length > 0 ? similar : top; + const fresh: Track[] = []; + for (const raw of sourceList) { + if (fresh.length >= 10) break; + const t = songToTrack(raw); + if (existingIds.has(t.id) || radioSessionSeenIds.has(t.id)) continue; + radioSessionSeenIds.add(t.id); + fresh.push({ ...t, radioAdded: true as const }); + } if (fresh.length > 0) { // Trim played tracks from the front to keep the queue bounded. // Without trimming the queue grows unboundedly, making every // Zustand persist write larger and causing UI freezes over time. // Keep the last HISTORY_KEEP played tracks so the user can still - // navigate backwards a few songs. + // navigate backwards a few songs. Trimmed ids stay in the seen-set. const HISTORY_KEEP = 5; set(state => { const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); @@ -3041,7 +3074,7 @@ export const usePlayerStore = create()( } } } else if (repeatMode === 'all' && queue.length > 0) { - get().playTrack(queue[0], queue, manual); + get().playTrack(queue[0], queue, manual, false, 0); } else { // Queue exhausted. Check radio first (independent of infinite queue setting), // then infinite queue, then stop. @@ -3053,15 +3086,21 @@ export const usePlayerStore = create()( .then(([similar, top]) => { radioFetching = false; const existingIds = new Set(get().queue.map(t => t.id)); - const fresh: Track[] = [...top, ...similar] - .map(songToTrack) - .filter(t => !existingIds.has(t.id)) - .slice(0, 10) - .map(t => ({ ...t, radioAdded: true as const })); + // Same source preference + dedup contract as the proactive + // top-up: similar first, top only as a fallback (issue #500). + const sourceList = similar.length > 0 ? similar : top; + const fresh: Track[] = []; + for (const raw of sourceList) { + if (fresh.length >= 10) break; + const t = songToTrack(raw); + if (existingIds.has(t.id) || radioSessionSeenIds.has(t.id)) continue; + radioSessionSeenIds.add(t.id); + fresh.push({ ...t, radioAdded: true as const }); + } if (fresh.length > 0) { const currentQueue = get().queue; const newQueue = [...currentQueue, ...fresh]; - get().playTrack(fresh[0], newQueue, false); + get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); } else { invoke('audio_stop').catch(console.error); isAudioPaused = false; @@ -3124,7 +3163,7 @@ export const usePlayerStore = create()( return; } const prevIdx = queueIndex - 1; - if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue); + if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue, true, false, prevIdx); }, // ── seek ───────────────────────────────────────────────────────────────── @@ -3236,23 +3275,56 @@ export const usePlayerStore = create()( }); }, - setRadioArtistId: (artistId) => { currentRadioArtistId = artistId; }, + setRadioArtistId: (artistId) => { + if (artistId !== currentRadioArtistId) { + radioSessionSeenIds = new Set(); + } + currentRadioArtistId = artistId; + }, enqueueRadio: (tracks, artistId) => { - if (artistId) currentRadioArtistId = artistId; + if (artistId !== undefined) { + if (artistId !== currentRadioArtistId) { + radioSessionSeenIds = new Set(); + } + currentRadioArtistId = artistId; + } pushQueueUndoFromGetter(get); set(state => { // Drop all upcoming (not yet played) radio tracks — clicking "Start Radio" // again replaces the pending radio batch instead of stacking on top. const beforeAndCurrent = state.queue.slice(0, state.queueIndex + 1); const upcoming = state.queue.slice(state.queueIndex + 1).filter(t => !t.radioAdded); + // Tracks about to leave the queue here. Callers like ContextMenu.startRadio + // pass the previous pending radio back in `tracks` to merge with new + // similars — the seen-set must not block those re-introductions. + const droppedRadioIds = state.queue + .slice(state.queueIndex + 1) + .filter(t => t.radioAdded) + .map(t => t.id); + for (const id of droppedRadioIds) radioSessionSeenIds.delete(id); + // Capture surviving queue ids in the seen-set so the next radio top-up + // can dedupe against the seed track + already-queued non-radio items. + for (const t of beforeAndCurrent) radioSessionSeenIds.add(t.id); + for (const t of upcoming) radioSessionSeenIds.add(t.id); + // Drop incoming tracks already seen earlier this session AND + // intra-batch duplicates (top + similar Last.fm responses commonly + // overlap). The seen-set is mutated inside the loop so a repeated + // id later in `tracks` is rejected by the same pass that admitted + // the first occurrence (issue #500). + const dedupedTracks: Track[] = []; + for (const t of tracks) { + if (radioSessionSeenIds.has(t.id)) continue; + radioSessionSeenIds.add(t.id); + dedupedTracks.push(t); + } // Insert new radio tracks before any autoAdded tracks in the upcoming section. const firstAutoIdx = upcoming.findIndex(t => t.autoAdded); const merged = firstAutoIdx === -1 - ? [...upcoming, ...tracks] + ? [...upcoming, ...dedupedTracks] : [ ...upcoming.slice(0, firstAutoIdx), - ...tracks, + ...dedupedTracks, ...upcoming.slice(firstAutoIdx), ]; const newQueue = [...beforeAndCurrent, ...merged]; @@ -3307,6 +3379,8 @@ export const usePlayerStore = create()( isAudioPaused = false; clearSeekFallbackRetry(); if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); + radioSessionSeenIds = new Set(); + currentRadioArtistId = null; set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); syncQueueToServer([], null, 0); },