fix(radio): prevent macOS WKWebView crash + shuffle Artist Radio queue

- Remove currentTime from Zustand persist partialize: it caused 100ms
  localStorage writes that grew with the queue, crashing WKWebView SQLite
  after ~10 min of Artist Radio on macOS
- Trim old played radio tracks from queue (keep last 5) to cap localStorage
  payload size during proactive radio top-up in next()
- Add reconnect logic for internet radio stall events (max 5 retries)
- Shuffle initial Artist Radio queue via Fisher-Yates so positions 2+
  are drawn from similar-artist tracks, not predictable top-5 tracks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-09 13:23:09 +02:00
parent 4e3efa3814
commit 0da92c2fa1
2 changed files with 89 additions and 21 deletions
+42 -15
View File
@@ -23,6 +23,16 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download'; .substring(0, 200) || 'download';
} }
/** Fisher-Yates in-place shuffle — returns a new array, does not mutate the input. */
function shuffleArray<T>(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 ─────────────────────────────────────── // ── Add-to-Playlist submenu ───────────────────────────────────────
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) { export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -221,12 +231,16 @@ export default function ContextMenu() {
} }
// Load radio queue in background — enqueueRadio replaces any pending radio // Load radio queue in background — enqueueRadio replaces any pending radio
// tracks so clicking "Start Radio" again never stacks duplicate batches. // 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 { try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const radioTracks = [...top, ...similar] const radioTracks = shuffleArray(
.map(songToTrack) [...top, ...similar]
.filter(t => t.id !== seedTrack.id) .map(songToTrack)
.map(t => ({ ...t, radioAdded: true as const })); .filter(t => t.id !== seedTrack.id)
.map(t => ({ ...t, radioAdded: true as const }))
);
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId); if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) { } catch (e) {
console.error('Failed to load radio queue', e); console.error('Failed to load radio queue', e);
@@ -238,11 +252,17 @@ export default function ContextMenu() {
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>); const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
try { try {
const top = await getTopSongs(artistName); 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) { if (topTracks.length === 0) {
// No local top songs — fall back to waiting for similar tracks // No local top songs — fall back to waiting for similar tracks
const similar = await similarPromise; 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; if (fallback.length === 0) return;
const state = usePlayerStore.getState(); const state = usePlayerStore.getState();
if (state.currentTrack) { if (state.currentTrack) {
@@ -253,22 +273,29 @@ export default function ContextMenu() {
} }
return; 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(); const state = usePlayerStore.getState();
if (state.currentTrack) { if (state.currentTrack) {
state.enqueueRadio(topTracks, artistId); state.enqueueRadio([topTracks[0]], artistId);
} else { } else {
state.setRadioArtistId(artistId); 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 => { similarPromise.then(similar => {
const similarTracks = similar const similarTracks = shuffleArray(
.map(t => ({ ...songToTrack(t), radioAdded: true as const })) similar
.filter(t => !topTracks.some(top => top.id === t.id)); .map(t => ({ ...songToTrack(t), radioAdded: true as const }))
.filter(t => t.id !== topTracks[0].id)
);
if (similarTracks.length === 0) return; 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 { queue, queueIndex } = usePlayerStore.getState();
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded); const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId); usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
+47 -6
View File
@@ -203,6 +203,11 @@ radioAudio.preload = 'none';
let radioStopping = false; let radioStopping = false;
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled. // Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null; let radioReconnectTimer: ReturnType<typeof setTimeout> | 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() { function clearRadioReconnectTimer() {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; } if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
@@ -211,23 +216,40 @@ function clearRadioReconnectTimer() {
radioAudio.addEventListener('ended', () => { radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state. // Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer(); clearRadioReconnectTimer();
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 }); usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
}); });
radioAudio.addEventListener('error', () => { radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer(); clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; return; } if (radioStopping) { radioStopping = false; radioReconnectCount = 0; return; }
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null }); usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error'); 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. // 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', () => { radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled 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 = setTimeout(() => {
radioReconnectTimer = null; radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return; if (!usePlayerStore.getState().currentRadio) return;
// Re-assign src to force a fresh connection, then resume playback. radioReconnectCount++;
const src = radioAudio.src; // Use load() + play() instead of src reassignment — more reliable on
radioAudio.src = src; // macOS WKWebView where setting src can fire a premature error event.
radioAudio.load();
radioAudio.play().catch(console.error); radioAudio.play().catch(console.error);
}, 4000); }, 4000);
}); });
@@ -719,6 +741,7 @@ export const usePlayerStore = create<PlayerState>()(
++playGeneration; ++playGeneration;
isAudioPaused = false; isAudioPaused = false;
clearRadioReconnectTimer(); clearRadioReconnectTimer();
radioReconnectCount = 0;
gaplessPreloadingId = null; gaplessPreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null; if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// Stop Rust engine in case a regular track was playing. // Stop Rust engine in case a regular track was playing.
@@ -973,7 +996,19 @@ export const usePlayerStore = create<PlayerState>()(
.slice(0, 10) .slice(0, 10)
.map(t => ({ ...t, radioAdded: true as const })); .map(t => ({ ...t, radioAdded: true as const }));
if (fresh.length > 0) { 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(() => {}) .catch(() => {})
@@ -1254,7 +1289,13 @@ export const usePlayerStore = create<PlayerState>()(
currentTrack: state.currentTrack, currentTrack: state.currentTrack,
queue: state.queue, queue: state.queue,
queueIndex: state.queueIndex, 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, lastfmLovedCache: state.lastfmLovedCache,
}), }),
} }