refactor(playlist): G.67 — extract usePlaylistCovers + usePlaylistSelection + usePlaylistSuggestions (cluster) (#634)

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.
This commit is contained in:
Frank Stellmacher
2026-05-13 13:33:41 +02:00
committed by GitHub
parent 1a3eeea048
commit d0a270d90a
4 changed files with 168 additions and 108 deletions
+54
View File
@@ -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<string>();
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 };
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from 'react';
import type React from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
export interface PlaylistSelection {
selectedIds: Set<string>;
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
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<React.SetStateAction<SubsonicSong[]>>,
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>,
): PlaylistSelection {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(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 };
}
+43
View File
@@ -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<React.SetStateAction<SubsonicSong[]>>;
loadingSuggestions: boolean;
loadSuggestions: (currentSongs: SubsonicSong[]) => Promise<void>;
}
export function usePlaylistSuggestions(songs: SubsonicSong[], playlistId: string | undefined): PlaylistSuggestionsResult {
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
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<string, number> = {};
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 };
}