From 16ee1ea373b71245359a997b9930286abb8ec044 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 13:54:26 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.69=20=E2=80=94=20extrac?= =?UTF-8?q?t=20runPlaylistLoad=20+=20usePlaylistPreview=20+=20usePlaylistB?= =?UTF-8?q?ulkPlayCallbacks=20+=20usePlaylistDerived=20(cluster)=20(#636)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/usePlaylistBulkPlayCallbacks.ts | 39 ++++++++++ src/hooks/usePlaylistDerived.ts | 46 ++++++++++++ src/hooks/usePlaylistPreview.ts | 29 ++++++++ src/pages/PlaylistDetail.tsx | 89 +++++------------------ src/utils/runPlaylistLoad.ts | 38 ++++++++++ 5 files changed, 169 insertions(+), 72 deletions(-) create mode 100644 src/hooks/usePlaylistBulkPlayCallbacks.ts create mode 100644 src/hooks/usePlaylistDerived.ts create mode 100644 src/hooks/usePlaylistPreview.ts create mode 100644 src/utils/runPlaylistLoad.ts diff --git a/src/hooks/usePlaylistBulkPlayCallbacks.ts b/src/hooks/usePlaylistBulkPlayCallbacks.ts new file mode 100644 index 00000000..0f72cae1 --- /dev/null +++ b/src/hooks/usePlaylistBulkPlayCallbacks.ts @@ -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 }; +} diff --git a/src/hooks/usePlaylistDerived.ts b/src/hooks/usePlaylistDerived.ts new file mode 100644 index 00000000..9aa09ed5 --- /dev/null +++ b/src/hooks/usePlaylistDerived.ts @@ -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; + starredSongs: Set; +} + +export interface PlaylistDerived { + existingIds: Set; + 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 }; +} diff --git a/src/hooks/usePlaylistPreview.ts b/src/hooks/usePlaylistPreview.ts new file mode 100644 index 00000000..e040f4f4 --- /dev/null +++ b/src/hooks/usePlaylistPreview.ts @@ -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 }; +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 464a1405..8bd3ee53 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,8 +1,6 @@ -import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; -import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; +import { updatePlaylist } from '../api/subsonicPlaylists'; import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes'; -import { songToTrack } from '../utils/songToTrack'; -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square, AudioLines } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; @@ -38,10 +36,10 @@ import PlaylistSuggestions from '../components/playlist/PlaylistSuggestions'; import PlaylistHero from '../components/playlist/PlaylistHero'; import PlaylistTracklist from '../components/playlist/PlaylistTracklist'; import PlaylistFilterToolbar from '../components/playlist/PlaylistFilterToolbar'; -import { getDisplayedSongs, type PlaylistSortKey, type PlaylistSortDir } from '../utils/playlistDisplayedSongs'; +import type { PlaylistSortKey, PlaylistSortDir } from '../utils/playlistDisplayedSongs'; import { runPlaylistZipDownload } from '../utils/runPlaylistZipDownload'; import { runPlaylistSaveMeta } from '../utils/runPlaylistSaveMeta'; -import { playPlaylistAll, shufflePlaylistAll, enqueuePlaylistAll } from '../utils/playlistBulkPlayActions'; +import { runPlaylistLoad } from '../utils/runPlaylistLoad'; import { startPlaylistRowDrag } from '../utils/startPlaylistRowDrag'; import { runPlaylistReorderDrop } from '../utils/runPlaylistReorderDrop'; import { usePlaylistCovers } from '../hooks/usePlaylistCovers'; @@ -50,6 +48,9 @@ import { usePlaylistSuggestions } from '../hooks/usePlaylistSuggestions'; import { usePlaylistSongSearch } from '../hooks/usePlaylistSongSearch'; import { usePlaylistSongMutations } from '../hooks/usePlaylistSongMutations'; import { usePlaylistStarRating } from '../hooks/usePlaylistStarRating'; +import { usePlaylistPreview } from '../hooks/usePlaylistPreview'; +import { usePlaylistBulkPlayCallbacks } from '../hooks/usePlaylistBulkPlayCallbacks'; +import { usePlaylistDerived } from '../hooks/usePlaylistDerived'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -209,24 +210,9 @@ export default function PlaylistDetail() { useEffect(() => { if (!id) return; - setLoading(true); - getPlaylist(id) - .then(async ({ playlist, songs }) => { - const filteredSongs = await filterSongsToActiveLibrary(songs); - setPlaylist(playlist); - setSongs(filteredSongs); - if (playlist.coverArt) setCustomCoverId(playlist.coverArt); - const init: Record = {}; - const starred = new Set(); - filteredSongs.forEach(s => { - if (s.userRating) init[s.id] = s.userRating; - if (s.starred) starred.add(s.id); - }); - setRatings(init); - setStarredSongs(starred); - }) - .catch(() => {}) - .finally(() => setLoading(false)); + runPlaylistLoad({ + id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs, + }); }, [id, lastModified]); // ── Meta edit ───────────────────────────────────────────────── @@ -264,25 +250,7 @@ export default function PlaylistDetail() { }); // ── Preview (30s mid-song sample via Rust audio engine) ──────── - // 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(); - } - }, []); + const { startPreview } = usePlaylistPreview(); // ── Rating / Star ───────────────────────────────────────────── const { handleRate, handleToggleStar } = usePlaylistStarRating({ @@ -308,21 +276,9 @@ export default function PlaylistDetail() { }; // ── Memoized derivations ────────────────────────────────────── - 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; + const { existingIds, tracks, displayedSongs, displayedTracks, isFiltered } = usePlaylistDerived(songs, { + filterText, sortKey, sortDir, ratings, starredSongs, + }); // ── Drag-over visual feedback ───────────────────────────────── const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { @@ -333,20 +289,9 @@ export default function PlaylistDetail() { }; // ── Playback actions (encapsulated like AlbumHeader) ───────── - const handlePlayAll = useCallback( - () => playPlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), - [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], - ); - - const handleShuffleAll = useCallback( - () => shufflePlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), - [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], - ); - - const handleEnqueueAll = useCallback( - () => enqueuePlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), - [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], - ); + const { handlePlayAll, handleShuffleAll, handleEnqueueAll } = usePlaylistBulkPlayCallbacks({ + songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue, + }); // ── Render ──────────────────────────────────────────────────── if (loading) { diff --git a/src/utils/runPlaylistLoad.ts b/src/utils/runPlaylistLoad.ts new file mode 100644 index 00000000..9daf6e2e --- /dev/null +++ b/src/utils/runPlaylistLoad.ts @@ -0,0 +1,38 @@ +import type React from 'react'; +import { getPlaylist } from '../api/subsonicPlaylists'; +import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; +import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes'; + +export interface RunPlaylistLoadDeps { + id: string; + setLoading: (v: boolean) => void; + setPlaylist: React.Dispatch>; + setSongs: React.Dispatch>; + setCustomCoverId: React.Dispatch>; + setRatings: React.Dispatch>>; + setStarredSongs: React.Dispatch>>; +} + +export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise { + const { id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps; + setLoading(true); + try { + const { playlist, songs } = await getPlaylist(id); + const filteredSongs = await filterSongsToActiveLibrary(songs); + setPlaylist(playlist); + setSongs(filteredSongs); + if (playlist.coverArt) setCustomCoverId(playlist.coverArt); + const init: Record = {}; + const starred = new Set(); + filteredSongs.forEach(s => { + if (s.userRating) init[s.id] = s.userRating; + if (s.starred) starred.add(s.id); + }); + setRatings(init); + setStarredSongs(starred); + } catch { + // intentional swallow; load failure leaves loading false + playlist null + } finally { + setLoading(false); + } +}