refactor(playlist): G.69 — extract runPlaylistLoad + usePlaylistPreview + usePlaylistBulkPlayCallbacks + usePlaylistDerived (cluster) (#636)

Four-cut cluster pulling the remaining handler bodies + memo island
out of PlaylistDetail.tsx. 507 → 437 LOC (−70).

runPlaylistLoad — async fetch + populate flow that the load
useEffect drives: getPlaylist → filterSongsToActiveLibrary →
setPlaylist + setSongs + setCustomCoverId from playlist.coverArt +
rebuild ratings + starredSongs from each song's stored userRating /
starred flag. Takes the six setters as deps. Page keeps the
useEffect that calls it on id / lastModified change.

usePlaylistPreview — startPreview callback (dispatches into
previewStore with the 'suggestions' source) plus the unmount-time
stopPreview cleanup. Returns just { startPreview }. No props
needed.

usePlaylistBulkPlayCallbacks — wraps playPlaylistAll /
shufflePlaylistAll / enqueuePlaylistAll utilities behind three
useCallback handles with the right deps array. Takes
{ songsLength, id, tracks, touchPlaylist, playTrack, enqueue }.

usePlaylistDerived — existingIds, tracks (songToTrack), sorted +
filtered displayedSongs via getDisplayedSongs, displayedTracks (with
the cheap aliasing optimization when displayedSongs === songs),
isFiltered. Subscribes to playerStore's userRatingOverrides +
starredOverrides directly so the page no longer threads them through
the memo deps.

PlaylistDetail drops these direct imports — they followed the new
modules: getPlaylist, filterSongsToActiveLibrary, songToTrack,
useMemo, getDisplayedSongs (now type-only),
playPlaylistAll/shuffle/enqueue from playlistBulkPlayActions.
Pure code move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 13:54:26 +02:00
committed by GitHub
parent d08875dc70
commit 16ee1ea373
5 changed files with 169 additions and 72 deletions
+39
View File
@@ -0,0 +1,39 @@
import { useCallback } from 'react';
import type { Track } from '../store/playerStoreTypes';
import { enqueuePlaylistAll, playPlaylistAll, shufflePlaylistAll } from '../utils/playlistBulkPlayActions';
export interface PlaylistBulkPlayCallbacksDeps {
songsLength: number;
id: string | undefined;
tracks: Track[];
touchPlaylist: (id: string) => void;
playTrack: (track: Track, queue: Track[]) => void;
enqueue: (tracks: Track[]) => void;
}
export interface PlaylistBulkPlayCallbacks {
handlePlayAll: () => void;
handleShuffleAll: () => void;
handleEnqueueAll: () => void;
}
export function usePlaylistBulkPlayCallbacks(deps: PlaylistBulkPlayCallbacksDeps): PlaylistBulkPlayCallbacks {
const { songsLength, id, tracks, touchPlaylist, playTrack, enqueue } = deps;
const handlePlayAll = useCallback(
() => playPlaylistAll({ songsLength, id, tracks, touchPlaylist, playTrack, enqueue }),
[songsLength, id, tracks, touchPlaylist, playTrack, enqueue],
);
const handleShuffleAll = useCallback(
() => shufflePlaylistAll({ songsLength, id, tracks, touchPlaylist, playTrack, enqueue }),
[songsLength, id, tracks, touchPlaylist, playTrack, enqueue],
);
const handleEnqueueAll = useCallback(
() => enqueuePlaylistAll({ songsLength, id, tracks, touchPlaylist, playTrack, enqueue }),
[songsLength, id, tracks, touchPlaylist, playTrack, enqueue],
);
return { handlePlayAll, handleShuffleAll, handleEnqueueAll };
}
+46
View File
@@ -0,0 +1,46 @@
import { useMemo } from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import { usePlayerStore } from '../store/playerStore';
import { songToTrack } from '../utils/songToTrack';
import { getDisplayedSongs, type PlaylistSortDir, type PlaylistSortKey } from '../utils/playlistDisplayedSongs';
export interface PlaylistDerivedOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
starredSongs: Set<string>;
}
export interface PlaylistDerived {
existingIds: Set<string>;
tracks: Track[];
displayedSongs: SubsonicSong[];
displayedTracks: Track[];
isFiltered: boolean;
}
export function usePlaylistDerived(songs: SubsonicSong[], opts: PlaylistDerivedOptions): PlaylistDerived {
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const { filterText, sortKey, sortDir, ratings, starredSongs } = opts;
const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]);
const tracks = useMemo(() => songs.map(songToTrack), [songs]);
const displayedSongs = useMemo(
() => getDisplayedSongs(songs, {
filterText, sortKey, sortDir,
ratings, userRatingOverrides, starredOverrides, starredSongs,
}),
[songs, filterText, sortKey, sortDir, ratings, userRatingOverrides, starredOverrides, starredSongs],
);
const displayedTracks = useMemo(
() => displayedSongs === songs ? tracks : displayedSongs.map(songToTrack),
[displayedSongs, songs, tracks],
);
const isFiltered = displayedSongs !== songs;
return { existingIds, tracks, displayedSongs, displayedTracks, isFiltered };
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useEffect } from 'react';
import { usePreviewStore } from '../store/previewStore';
import type { SubsonicSong } from '../api/subsonicTypes';
export function usePlaylistPreview(): {
startPreview: (song: SubsonicSong) => void;
} {
// Pause/resume of the main player + timer + cancel-on-supersede are all
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
// engine events so we just dispatch here and read `previewingId` for UI.
const startPreview = useCallback((song: SubsonicSong) => {
usePreviewStore.getState().startPreview({
id: song.id,
title: song.title,
artist: song.artist,
coverArt: song.coverArt,
duration: song.duration,
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
}, []);
// Cancel any in-flight preview when the user navigates away.
useEffect(() => () => {
if (usePreviewStore.getState().previewingId) {
usePreviewStore.getState().stopPreview();
}
}, []);
return { startPreview };
}