mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
d08875dc70
commit
16ee1ea373
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
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) {
|
||||
|
||||
@@ -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<React.SetStateAction<SubsonicPlaylist | null>>;
|
||||
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
setCustomCoverId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setRatings: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
||||
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
}
|
||||
|
||||
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
|
||||
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<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user