mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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(
|
||||||
|
[...top, ...similar]
|
||||||
.map(songToTrack)
|
.map(songToTrack)
|
||||||
.filter(t => t.id !== seedTrack.id)
|
.filter(t => t.id !== seedTrack.id)
|
||||||
.map(t => ({ ...t, radioAdded: true as const }));
|
.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(
|
||||||
|
similar
|
||||||
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
||||||
.filter(t => !topTracks.some(top => top.id === t.id));
|
.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);
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user