diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 58380c8d..b1e4066f 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -23,6 +23,16 @@ function sanitizeFilename(name: string): string { .substring(0, 200) || 'download'; } +/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */ +function shuffleArray(arr: T[]): T[] { + const result = [...arr]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + // ── Add-to-Playlist submenu ─────────────────────────────────────── export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) { const { t } = useTranslation(); @@ -221,12 +231,16 @@ 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. try { const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); - const radioTracks = [...top, ...similar] - .map(songToTrack) - .filter(t => t.id !== seedTrack.id) - .map(t => ({ ...t, radioAdded: true as const })); + const radioTracks = shuffleArray( + [...top, ...similar] + .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); @@ -238,11 +252,17 @@ export default function ContextMenu() { const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited>); try { const top = await getTopSongs(artistName); - const topTracks = top.map(t => ({ ...songToTrack(t), radioAdded: true as const })); + // Shuffle so each Radio session starts from a different track rather + // than always kicking off with the #1 most-played song. + const topTracks = shuffleArray( + top.map(t => ({ ...songToTrack(t), radioAdded: true as const })) + ); if (topTracks.length === 0) { // No local top songs — fall back to waiting for similar tracks const similar = await similarPromise; - const fallback = similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })); + const fallback = shuffleArray( + similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })) + ); if (fallback.length === 0) return; const state = usePlayerStore.getState(); if (state.currentTrack) { @@ -253,22 +273,29 @@ export default function ContextMenu() { } return; } - // Start playback immediately from top songs + // Start playback from the first shuffled top track only. + // No other tracks are queued yet — positions 2+ will be filled + // exclusively by the similar-songs result below. const state = usePlayerStore.getState(); if (state.currentTrack) { - state.enqueueRadio(topTracks, artistId); + state.enqueueRadio([topTracks[0]], artistId); } else { state.setRadioArtistId(artistId); - playTrack(topTracks[0], topTracks); + playTrack(topTracks[0], [topTracks[0]]); } - // Enrich with similar tracks in the background + // Populate positions 2+ from similar songs only — never from the + // remaining top tracks. Mixing in topTracks.slice(1) meant that when + // getSimilarSongs2 returned nothing (no Last.fm, small library, etc.) + // the queue fell back to the same top-4 the user just heard. + // If similarTracks is also empty, the proactive top-up in next() + // will refill the queue when the first track nears its end. similarPromise.then(similar => { - const similarTracks = similar - .map(t => ({ ...songToTrack(t), radioAdded: true as const })) - .filter(t => !topTracks.some(top => top.id === t.id)); + const similarTracks = shuffleArray( + similar + .map(t => ({ ...songToTrack(t), radioAdded: true as const })) + .filter(t => t.id !== topTracks[0].id) + ); if (similarTracks.length === 0) return; - // Collect pending (upcoming) radio tracks so enqueueRadio re-inserts them - // together with the new similar tracks rather than losing them. const { queue, queueIndex } = usePlayerStore.getState(); const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded); usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 973dd41c..1b387654 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -203,6 +203,11 @@ radioAudio.preload = 'none'; let radioStopping = false; // Pending reconnect timer for stalled streams — null when no reconnect is scheduled. let radioReconnectTimer: ReturnType | null = null; +// Counts how many stalled-reconnects have been attempted for the current station. +// Reset to 0 on successful playback. Hard-stop after MAX_RADIO_RECONNECTS so a +// dead stream doesn't loop forever and leak resources in the background. +let radioReconnectCount = 0; +const MAX_RADIO_RECONNECTS = 5; function clearRadioReconnectTimer() { if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; } @@ -211,23 +216,40 @@ function clearRadioReconnectTimer() { radioAudio.addEventListener('ended', () => { // Stream disconnected unexpectedly — clear radio state. clearRadioReconnectTimer(); + radioReconnectCount = 0; usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 }); }); radioAudio.addEventListener('error', () => { clearRadioReconnectTimer(); - if (radioStopping) { radioStopping = false; return; } + if (radioStopping) { radioStopping = false; radioReconnectCount = 0; return; } + radioReconnectCount = 0; usePlayerStore.setState({ isPlaying: false, currentRadio: null }); showToast('Radio stream error', 3000, 'error'); }); +// Playing: stream is delivering audio — reset the reconnect counter. +radioAudio.addEventListener('playing', () => { + radioReconnectCount = 0; +}); // Stalled: stream stopped delivering data — try to reconnect after 4 s. +// On macOS/WKWebView, reassigning src during a stall can itself trigger +// another stall event before the new connection is established. The +// radioReconnectTimer guard prevents stacking, and MAX_RADIO_RECONNECTS +// ensures we don't loop forever on a dead stream. radioAudio.addEventListener('stalled', () => { if (radioReconnectTimer) return; // already scheduled + if (radioReconnectCount >= MAX_RADIO_RECONNECTS) { + radioReconnectCount = 0; + usePlayerStore.setState({ isPlaying: false, currentRadio: null }); + showToast('Radio stream disconnected', 4000, 'error'); + return; + } radioReconnectTimer = setTimeout(() => { radioReconnectTimer = null; if (!usePlayerStore.getState().currentRadio) return; - // Re-assign src to force a fresh connection, then resume playback. - const src = radioAudio.src; - radioAudio.src = src; + radioReconnectCount++; + // Use load() + play() instead of src reassignment — more reliable on + // macOS WKWebView where setting src can fire a premature error event. + radioAudio.load(); radioAudio.play().catch(console.error); }, 4000); }); @@ -719,6 +741,7 @@ export const usePlayerStore = create()( ++playGeneration; isAudioPaused = false; clearRadioReconnectTimer(); + radioReconnectCount = 0; gaplessPreloadingId = null; if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null; // Stop Rust engine in case a regular track was playing. @@ -973,7 +996,19 @@ export const usePlayerStore = create()( .slice(0, 10) .map(t => ({ ...t, radioAdded: true as const })); if (fresh.length > 0) { - set(state => ({ queue: [...state.queue, ...fresh] })); + // 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. + const HISTORY_KEEP = 5; + set(state => { + const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP); + return { + queue: [...state.queue.slice(trimStart), ...fresh], + queueIndex: state.queueIndex - trimStart, + }; + }); } }) .catch(() => {}) @@ -1254,7 +1289,13 @@ export const usePlayerStore = create()( currentTrack: state.currentTrack, queue: state.queue, queueIndex: state.queueIndex, - currentTime: state.currentTime, + // currentTime is intentionally NOT persisted here. + // handleAudioProgress fires every 100ms and each setState with a + // persisted field triggers a full JSON serialisation of the queue to + // localStorage. After ~10 minutes of Artist Radio the queue grows to + // 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's + // storage process to crash on macOS → black screen + audio stop. + // Resume position is recovered from Subsonic savePlayQueue (5s debounce). lastfmLovedCache: state.lastfmLovedCache, }), }