From d0a270d90a6e14c2c2700d31a42a55ae5a8a88f9 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 13:33:41 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.67=20=E2=80=94=20extrac?= =?UTF-8?q?t=20usePlaylistCovers=20+=20usePlaylistSelection=20+=20usePlayl?= =?UTF-8?q?istSuggestions=20(cluster)=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-cut cluster pulling tightly-scoped state + memo islands out of PlaylistDetail.tsx as custom hooks. 659 → 565 LOC (−94). Each hook returns the same names the page already used, so call sites stay identical. usePlaylistCovers(songs, customCoverId) — the 2×2 cover quad memo, the four cover-quad URLs (with their stable cache keys to avoid the buildCoverArtUrl-salt re-render loop documented in the comment), the customCover fetch URL + cache key, and the blurred background URL going through useCachedUrl. Returns coverQuadUrls / customCoverFetchUrl / customCoverCacheKey / resolvedBgUrl. The page no longer imports buildCoverArtUrl / coverArtCacheKey / useCachedUrl directly. usePlaylistSelection(songs, setSongs, savePlaylist) — selectedIds / lastSelectedIdx state + toggleSelect (with shift-range support) + allSelected + toggleAll + bulkRemove (savePlaylist + setSongs + clears selection). Returns the same shape the tracklist props already destructured. savePlaylist moves a few lines up in PlaylistDetail so the hook can be called with it. usePlaylistSuggestions(songs, playlist.id) — suggestions state + loadSuggestions (genre-weighted random pull, top 10) + the auto-load useEffect that fires on playlist change. Returns setSuggestions too so addSong on the page can still filter the just-added song out of the suggestions strip. The page drops the getRandomSongs import. Pure code move otherwise — no behaviour change. --- src/hooks/usePlaylistCovers.ts | 54 ++++++++++++ src/hooks/usePlaylistSelection.ts | 50 +++++++++++ src/hooks/usePlaylistSuggestions.ts | 43 ++++++++++ src/pages/PlaylistDetail.tsx | 129 +++++----------------------- 4 files changed, 168 insertions(+), 108 deletions(-) create mode 100644 src/hooks/usePlaylistCovers.ts create mode 100644 src/hooks/usePlaylistSelection.ts create mode 100644 src/hooks/usePlaylistSuggestions.ts diff --git a/src/hooks/usePlaylistCovers.ts b/src/hooks/usePlaylistCovers.ts new file mode 100644 index 00000000..5dc96f6e --- /dev/null +++ b/src/hooks/usePlaylistCovers.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { useCachedUrl } from '../components/CachedImage'; + +export interface PlaylistCovers { + coverQuadUrls: ({ src: string; cacheKey: string } | null)[]; + customCoverFetchUrl: string | null; + customCoverCacheKey: string | null; + resolvedBgUrl: string | null; +} + +export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string | null): PlaylistCovers { + const coverQuad = useMemo(() => { + const seen = new Set(); + const result: string[] = []; + for (const s of songs) { + if (s.coverArt && !seen.has(s.coverArt)) { + seen.add(s.coverArt); + result.push(s.coverArt); + if (result.length === 4) break; + } + } + return result; + }, [songs]); + + // Stable fetch URLs + cache keys for the 2×2 grid and blurred background. + // buildCoverArtUrl generates a new crypto salt on every call, so these MUST + // be memoized — otherwise every render produces new URLs, useCachedUrl + // re-triggers, state updates, another render → infinite flicker loop. + const coverQuadUrls = useMemo(() => + Array.from({ length: 4 }, (_, i) => { + const coverId = coverQuad[i % Math.max(1, coverQuad.length)]; + if (!coverId) return null; + return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) }; + }), + [coverQuad]); + + const effectiveBgId = customCoverId ?? coverQuad[0] ?? ''; + const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]); + const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]); + const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); + + const customCoverFetchUrl = useMemo( + () => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null, + [customCoverId], + ); + const customCoverCacheKey = useMemo( + () => customCoverId ? coverArtCacheKey(customCoverId, 300) : null, + [customCoverId], + ); + + return { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl }; +} diff --git a/src/hooks/usePlaylistSelection.ts b/src/hooks/usePlaylistSelection.ts new file mode 100644 index 00000000..6d8fe088 --- /dev/null +++ b/src/hooks/usePlaylistSelection.ts @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import type React from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; + +export interface PlaylistSelection { + selectedIds: Set; + setSelectedIds: React.Dispatch>>; + lastSelectedIdx: number | null; + allSelected: boolean; + toggleAll: () => void; + toggleSelect: (id: string, idx: number, shift: boolean) => void; + bulkRemove: () => void; +} + +export function usePlaylistSelection( + songs: SubsonicSong[], + setSongs: React.Dispatch>, + savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise, +): PlaylistSelection { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedIdx, setLastSelectedIdx] = useState(null); + + const toggleSelect = (id: string, idx: number, shift: boolean) => { + setSelectedIds(prev => { + const next = new Set(prev); + if (shift && lastSelectedIdx !== null) { + const from = Math.min(lastSelectedIdx, idx); + const to = Math.max(lastSelectedIdx, idx); + songs.slice(from, to + 1).forEach(s => next.add(s.id)); + } else { + next.has(id) ? next.delete(id) : next.add(id); + } + return next; + }); + setLastSelectedIdx(idx); + }; + + const allSelected = selectedIds.size === songs.length && songs.length > 0; + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id))); + + const bulkRemove = () => { + const prevCount = songs.length; + const next = songs.filter(s => !selectedIds.has(s.id)); + setSongs(next); + savePlaylist(next, prevCount); + setSelectedIds(new Set()); + }; + + return { selectedIds, setSelectedIds, lastSelectedIdx, allSelected, toggleAll, toggleSelect, bulkRemove }; +} diff --git a/src/hooks/usePlaylistSuggestions.ts b/src/hooks/usePlaylistSuggestions.ts new file mode 100644 index 00000000..5ba20499 --- /dev/null +++ b/src/hooks/usePlaylistSuggestions.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react'; +import type React from 'react'; +import { getRandomSongs } from '../api/subsonicLibrary'; +import type { SubsonicSong } from '../api/subsonicTypes'; + +export interface PlaylistSuggestionsResult { + suggestions: SubsonicSong[]; + setSuggestions: React.Dispatch>; + loadingSuggestions: boolean; + loadSuggestions: (currentSongs: SubsonicSong[]) => Promise; +} + +export function usePlaylistSuggestions(songs: SubsonicSong[], playlistId: string | undefined): PlaylistSuggestionsResult { + const [suggestions, setSuggestions] = useState([]); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + + const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { + if (!currentSongs.length) return; + // Count genres across playlist songs, pick the most common one + const genreCounts: Record = {}; + for (const s of currentSongs) { + if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1; + } + const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]); + // Fall back to no genre filter if none of the songs have genre tags + const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined; + const existingIds = new Set(currentSongs.map(s => s.id)); + setLoadingSuggestions(true); + setSuggestions([]); + try { + const random = await getRandomSongs(25, genre); + setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10)); + } catch {} + setLoadingSuggestions(false); + }, []); + + useEffect(() => { + if (songs.length > 0) loadSuggestions(songs); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlistId]); + + return { suggestions, setSuggestions, loadingSuggestions, loadSuggestions }; +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index d5d3f02a..fc42d6d6 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,8 +1,7 @@ import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonicPlaylists'; -import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonicStreamUrl'; import { setRating, star, unstar } from '../api/subsonicStarRating'; import { search } from '../api/subsonicSearch'; -import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibrary'; +import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; @@ -22,7 +21,6 @@ import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; -import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import StarRating from '../components/StarRating'; @@ -48,6 +46,9 @@ import { runPlaylistZipDownload } from '../utils/runPlaylistZipDownload'; import { playPlaylistAll, shufflePlaylistAll, enqueuePlaylistAll } from '../utils/playlistBulkPlayActions'; import { startPlaylistRowDrag } from '../utils/startPlaylistRowDrag'; import { runPlaylistReorderDrop } from '../utils/runPlaylistReorderDrop'; +import { usePlaylistCovers } from '../hooks/usePlaylistCovers'; +import { usePlaylistSelection } from '../hooks/usePlaylistSelection'; +import { usePlaylistSuggestions } from '../hooks/usePlaylistSuggestions'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -139,36 +140,21 @@ export default function PlaylistDetail() { searchErrors?: SpotifyCsvTrack[]; } | null>(null); + // ── Save ────────────────────────────────────────────────────── + const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => { + if (!id) return; + setSaving(true); + try { + await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount); + if (id) touchPlaylist(id); + } catch {} + setSaving(false); + }, [id, touchPlaylist]); + // ── Bulk select ─────────────────────────────────────────────────── - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [lastSelectedIdx, setLastSelectedIdx] = useState(null); const [showBulkPlPicker, setShowBulkPlPicker] = useState(false); - - const toggleSelect = (id: string, idx: number, shift: boolean) => { - setSelectedIds(prev => { - const next = new Set(prev); - if (shift && lastSelectedIdx !== null) { - const from = Math.min(lastSelectedIdx, idx); - const to = Math.max(lastSelectedIdx, idx); - songs.slice(from, to + 1).forEach(s => next.add(s.id)); - } else { - next.has(id) ? next.delete(id) : next.add(id); - } - return next; - }); - setLastSelectedIdx(idx); - }; - - const allSelected = selectedIds.size === songs.length && songs.length > 0; - const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id))); - - const bulkRemove = () => { - const prevCount = songs.length; - const next = songs.filter(s => !selectedIds.has(s.id)); - setSongs(next); - savePlaylist(next, prevCount); - setSelectedIds(new Set()); - }; + const { selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, bulkRemove } = + usePlaylistSelection(songs, setSongs, savePlaylist); useEffect(() => { if (!showBulkPlPicker) return; @@ -180,44 +166,8 @@ export default function PlaylistDetail() { }, [showBulkPlPicker]); // ── 2×2 cover quad (first 4 unique album covers) ───────────── - const coverQuad = useMemo(() => { - const seen = new Set(); - const result: string[] = []; - for (const s of songs) { - if (s.coverArt && !seen.has(s.coverArt)) { - seen.add(s.coverArt); - result.push(s.coverArt); - if (result.length === 4) break; - } - } - return result; - }, [songs]); - - // Stable fetch URLs + cache keys for the 2×2 grid and blurred background. - // buildCoverArtUrl generates a new crypto salt on every call, so these MUST - // be memoized — otherwise every render produces new URLs, useCachedUrl - // re-triggers, state updates, another render → infinite flicker loop. - const coverQuadUrls = useMemo(() => - Array.from({ length: 4 }, (_, i) => { - const coverId = coverQuad[i % Math.max(1, coverQuad.length)]; - if (!coverId) return null; - return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) }; - }), - [coverQuad]); - - const effectiveBgId = customCoverId ?? coverQuad[0] ?? ''; - const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]); - const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]); - const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); - - const customCoverFetchUrl = useMemo( - () => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null, - [customCoverId], - ); - const customCoverCacheKey = useMemo( - () => customCoverId ? coverArtCacheKey(customCoverId, 300) : null, - [customCoverId], - ); + const { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl } = + usePlaylistCovers(songs, customCoverId); // Song search const [searchOpen, setSearchOpen] = useState(false); @@ -229,8 +179,8 @@ export default function PlaylistDetail() { const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false); // Suggestions - const [suggestions, setSuggestions] = useState([]); - const [loadingSuggestions, setLoadingSuggestions] = useState(false); + const { suggestions, setSuggestions, loadingSuggestions, loadSuggestions } = + usePlaylistSuggestions(songs, playlist?.id); // ── Column resize/visibility ────────────────────────────────────────────── const { @@ -279,43 +229,6 @@ export default function PlaylistDetail() { .finally(() => setLoading(false)); }, [id, lastModified]); - // ── Suggestions ─────────────────────────────────────────────── - const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { - if (!currentSongs.length) return; - // Count genres across playlist songs, pick the most common one - const genreCounts: Record = {}; - for (const s of currentSongs) { - if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1; - } - const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]); - // Fall back to no genre filter if none of the songs have genre tags - const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined; - const existingIds = new Set(currentSongs.map(s => s.id)); - setLoadingSuggestions(true); - setSuggestions([]); - try { - const random = await getRandomSongs(25, genre); - setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10)); - } catch {} - setLoadingSuggestions(false); - }, []); - - useEffect(() => { - if (songs.length > 0) loadSuggestions(songs); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlist?.id]); - - // ── Save ────────────────────────────────────────────────────── - const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => { - if (!id) return; - setSaving(true); - try { - await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount); - if (id) touchPlaylist(id); - } catch {} - setSaving(false); - }, [id, touchPlaylist]); - // ── Meta edit ───────────────────────────────────────────────── const handleSaveMeta = async (opts: { name: string; comment: string; isPublic: boolean;