From d08875dc7002d634e5638919e22488e2dbd39f77 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 13:46:59 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.68=20=E2=80=94=20extrac?= =?UTF-8?q?t=20runPlaylistSaveMeta=20+=203=20hooks=20(song=20search=20/=20?= =?UTF-8?q?mutations=20/=20star+rating)=20(#635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-cut cluster. 572 → 471 LOC (−101). Each handler on the page collapses to a one-line delegate; pure code move otherwise. runPlaylistSaveMeta — the meta save flow (updatePlaylistMeta then optional uploadPlaylistCoverArt + getPlaylist refresh + cover toast, or coverRemoved → null, then metaSaved toast + closes the modal). Takes the deps separately from the opts object so the call-site just passes through the modal's payload. usePlaylistSongSearch — searchOpen + searchQuery driven debounced search against subsonic. Owns the 350 ms timeout, the filter-out of songs already in the playlist, and the searching state. Returns { searchResults, setSearchResults, searching } so addSong on the page can still drop a just-added song out of the result list. usePlaylistSongMutations — addSong / removeSong. removeSong is trivial (filter + setSongs + savePlaylist with prevCount). addSong preserves the .main-content scrollTop save / requestAnimationFrame restore trick + drops the song out of both suggestions and search results + fires the add toast with playlist name interpolation. usePlaylistStarRating — handleRate (local override + playerStore userRatingOverride + setRating API) + handleToggleStar (e.stopPropagation, local set + playerStore starredOverride + star/unstar API). Reads starredOverrides / setStarredOverride from playerStore directly so the page doesn't have to thread them through. PlaylistDetail drops these direct imports (now consumed in the hooks/utility): updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, showToast, useRef. Pure code move. --- src/hooks/usePlaylistSongMutations.ts | 47 +++++++++++++ src/hooks/usePlaylistSongSearch.ts | 37 ++++++++++ src/hooks/usePlaylistStarRating.ts | 42 ++++++++++++ src/pages/PlaylistDetail.tsx | 99 +++++---------------------- src/utils/runPlaylistSaveMeta.ts | 46 +++++++++++++ 5 files changed, 189 insertions(+), 82 deletions(-) create mode 100644 src/hooks/usePlaylistSongMutations.ts create mode 100644 src/hooks/usePlaylistSongSearch.ts create mode 100644 src/hooks/usePlaylistStarRating.ts create mode 100644 src/utils/runPlaylistSaveMeta.ts diff --git a/src/hooks/usePlaylistSongMutations.ts b/src/hooks/usePlaylistSongMutations.ts new file mode 100644 index 00000000..ba865456 --- /dev/null +++ b/src/hooks/usePlaylistSongMutations.ts @@ -0,0 +1,47 @@ +import type React from 'react'; +import type { TFunction } from 'i18next'; +import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes'; +import { showToast } from '../utils/toast'; + +export interface PlaylistSongMutationsDeps { + songs: SubsonicSong[]; + setSongs: React.Dispatch>; + savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise; + setSuggestions: React.Dispatch>; + setSearchResults: React.Dispatch>; + playlist: SubsonicPlaylist | null; + t: TFunction; +} + +export interface PlaylistSongMutations { + removeSong: (idx: number) => void; + addSong: (song: SubsonicSong) => void; +} + +export function usePlaylistSongMutations(deps: PlaylistSongMutationsDeps): PlaylistSongMutations { + const { songs, setSongs, savePlaylist, setSuggestions, setSearchResults, playlist, t } = deps; + + const removeSong = (idx: number) => { + const prevCount = songs.length; + const next = songs.filter((_, i) => i !== idx); + setSongs(next); + savePlaylist(next, prevCount); + }; + + const addSong = (song: SubsonicSong) => { + if (songs.some(s => s.id === song.id)) return; + const scrollHost = document.querySelector('.main-content') as HTMLElement | null; + const savedScroll = scrollHost?.scrollTop ?? 0; + const next = [...songs, song]; + setSongs(next); + savePlaylist(next); + setSuggestions(prev => prev.filter(s => s.id !== song.id)); + setSearchResults(prev => prev.filter(s => s.id !== song.id)); + if (scrollHost) { + requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; }); + } + showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name })); + }; + + return { removeSong, addSong }; +} diff --git a/src/hooks/usePlaylistSongSearch.ts b/src/hooks/usePlaylistSongSearch.ts new file mode 100644 index 00000000..27bb690f --- /dev/null +++ b/src/hooks/usePlaylistSongSearch.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; +import type React from 'react'; +import { search } from '../api/subsonicSearch'; +import type { SubsonicSong } from '../api/subsonicTypes'; + +export interface PlaylistSongSearchResult { + searchResults: SubsonicSong[]; + setSearchResults: React.Dispatch>; + searching: boolean; +} + +export function usePlaylistSongSearch( + songs: SubsonicSong[], + searchOpen: boolean, + searchQuery: string, +): PlaylistSongSearchResult { + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const searchDebounce = useRef | null>(null); + + useEffect(() => { + if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; } + if (searchDebounce.current) clearTimeout(searchDebounce.current); + searchDebounce.current = setTimeout(async () => { + setSearching(true); + try { + const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 }); + const existingIds = new Set(songs.map(s => s.id)); + setSearchResults(res.songs.filter(s => !existingIds.has(s.id))); + } catch {} + setSearching(false); + }, 350); + return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; + }, [searchQuery, searchOpen, songs]); + + return { searchResults, setSearchResults, searching }; +} diff --git a/src/hooks/usePlaylistStarRating.ts b/src/hooks/usePlaylistStarRating.ts new file mode 100644 index 00000000..fe7088b7 --- /dev/null +++ b/src/hooks/usePlaylistStarRating.ts @@ -0,0 +1,42 @@ +import React from 'react'; +import { setRating, star, unstar } from '../api/subsonicStarRating'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { usePlayerStore } from '../store/playerStore'; + +export interface PlaylistStarRatingDeps { + ratings: Record; + setRatings: React.Dispatch>>; + starredSongs: Set; + setStarredSongs: React.Dispatch>>; +} + +export interface PlaylistStarRatingActions { + handleRate: (songId: string, rating: number) => void; + handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void; +} + +export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistStarRatingActions { + const { setRatings, starredSongs, setStarredSongs } = deps; + const starredOverrides = usePlayerStore(s => s.starredOverrides); + const setStarredOverride = usePlayerStore(s => s.setStarredOverride); + + const handleRate = (songId: string, rating: number) => { + setRatings(prev => ({ ...prev, [songId]: rating })); + usePlayerStore.getState().setUserRatingOverride(songId, rating); + setRating(songId, rating).catch(() => {}); + }; + + const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { + e.stopPropagation(); + const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); + setStarredSongs(prev => { + const next = new Set(prev); + isStarred ? next.delete(song.id) : next.add(song.id); + return next; + }); + setStarredOverride(song.id, !isStarred); + (isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {}); + }; + + return { handleRate, handleToggleStar }; +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index fc42d6d6..464a1405 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,10 +1,8 @@ -import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonicPlaylists'; -import { setRating, star, unstar } from '../api/subsonicStarRating'; -import { search } from '../api/subsonicSearch'; +import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; 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'; +import React, { useEffect, useState, useCallback, useMemo } 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'; @@ -22,7 +20,6 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; import { useTranslation } from 'react-i18next'; -import { showToast } from '../utils/toast'; import StarRating from '../components/StarRating'; import { formatDuration, @@ -43,12 +40,16 @@ import PlaylistTracklist from '../components/playlist/PlaylistTracklist'; import PlaylistFilterToolbar from '../components/playlist/PlaylistFilterToolbar'; import { getDisplayedSongs, type PlaylistSortKey, type PlaylistSortDir } from '../utils/playlistDisplayedSongs'; import { runPlaylistZipDownload } from '../utils/runPlaylistZipDownload'; +import { runPlaylistSaveMeta } from '../utils/runPlaylistSaveMeta'; 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'; +import { usePlaylistSongSearch } from '../hooks/usePlaylistSongSearch'; +import { usePlaylistSongMutations } from '../hooks/usePlaylistSongMutations'; +import { usePlaylistStarRating } from '../hooks/usePlaylistStarRating'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -172,11 +173,10 @@ export default function PlaylistDetail() { // Song search const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [searching, setSearching] = useState(false); - const searchDebounce = useRef | null>(null); const [selectedSearchIds, setSelectedSearchIds] = useState>(new Set()); const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false); + const { searchResults, setSearchResults, searching } = + usePlaylistSongSearch(songs, searchOpen, searchQuery); // Suggestions const { suggestions, setSuggestions, loadingSuggestions, loadSuggestions } = @@ -235,26 +235,10 @@ export default function PlaylistDetail() { coverFile: File | null; coverRemoved: boolean; }) => { if (!id || !playlist) return; - await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic); - setPlaylist(p => p - ? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic } - : p + await runPlaylistSaveMeta( + { id, playlist, t, setPlaylist, setCustomCoverId, setEditingMeta }, + opts, ); - if (opts.coverFile) { - try { - await uploadPlaylistCoverArt(id, opts.coverFile); - const { playlist: refreshed } = await getPlaylist(id); - setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev); - if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt); - showToast(t('playlists.coverUpdated')); - } catch (err) { - showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error'); - } - } else if (opts.coverRemoved) { - setCustomCoverId(null); - } - showToast(t('playlists.metaSaved')); - setEditingMeta(false); }; // ── ZIP Download ────────────────────────────────────────────── @@ -275,28 +259,9 @@ export default function PlaylistDetail() { }; // ── Remove ──────────────────────────────────────────────────── - const removeSong = (idx: number) => { - const prevCount = songs.length; - const next = songs.filter((_, i) => i !== idx); - setSongs(next); - savePlaylist(next, prevCount); - }; - - // ── Add ─────────────────────────────────────────────────────── - const addSong = (song: SubsonicSong) => { - if (songs.some(s => s.id === song.id)) return; - const scrollHost = document.querySelector('.main-content') as HTMLElement | null; - const savedScroll = scrollHost?.scrollTop ?? 0; - const next = [...songs, song]; - setSongs(next); - savePlaylist(next); - setSuggestions(prev => prev.filter(s => s.id !== song.id)); - setSearchResults(prev => prev.filter(s => s.id !== song.id)); - if (scrollHost) { - requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; }); - } - showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name })); - }; + const { removeSong, addSong } = usePlaylistSongMutations({ + songs, setSongs, savePlaylist, setSuggestions, setSearchResults, playlist, t, + }); // ── Preview (30s mid-song sample via Rust audio engine) ──────── // Pause/resume of the main player + timer + cancel-on-supersede are all @@ -320,39 +285,9 @@ export default function PlaylistDetail() { }, []); // ── Rating / Star ───────────────────────────────────────────── - const handleRate = (songId: string, rating: number) => { - setRatings(prev => ({ ...prev, [songId]: rating })); - usePlayerStore.getState().setUserRatingOverride(songId, rating); - setRating(songId, rating).catch(() => {}); - }; - - const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { - e.stopPropagation(); - const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); - setStarredSongs(prev => { - const next = new Set(prev); - isStarred ? next.delete(song.id) : next.add(song.id); - return next; - }); - setStarredOverride(song.id, !isStarred); - (isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {}); - }; - - // ── Search ──────────────────────────────────────────────────── - useEffect(() => { - if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; } - if (searchDebounce.current) clearTimeout(searchDebounce.current); - searchDebounce.current = setTimeout(async () => { - setSearching(true); - try { - const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 }); - const existingIds = new Set(songs.map(s => s.id)); - setSearchResults(res.songs.filter(s => !existingIds.has(s.id))); - } catch {} - setSearching(false); - }, 350); - return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; - }, [searchQuery, searchOpen, songs]); + const { handleRate, handleToggleStar } = usePlaylistStarRating({ + ratings, setRatings, starredSongs, setStarredSongs, + }); // ── psy-drop DnD reordering ─────────────────────────────────── useEffect(() => { diff --git a/src/utils/runPlaylistSaveMeta.ts b/src/utils/runPlaylistSaveMeta.ts new file mode 100644 index 00000000..80601512 --- /dev/null +++ b/src/utils/runPlaylistSaveMeta.ts @@ -0,0 +1,46 @@ +import type { TFunction } from 'i18next'; +import { getPlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonicPlaylists'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { showToast } from './toast'; + +export interface RunPlaylistSaveMetaDeps { + id: string; + playlist: SubsonicPlaylist; + t: TFunction; + setPlaylist: (updater: (p: SubsonicPlaylist | null) => SubsonicPlaylist | null) => void; + setCustomCoverId: (id: string | null) => void; + setEditingMeta: (v: boolean) => void; +} + +export async function runPlaylistSaveMeta( + deps: RunPlaylistSaveMetaDeps, + opts: { + name: string; + comment: string; + isPublic: boolean; + coverFile: File | null; + coverRemoved: boolean; + }, +): Promise { + const { id, playlist, t, setPlaylist, setCustomCoverId, setEditingMeta } = deps; + await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic); + setPlaylist(p => p + ? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic } + : p + ); + if (opts.coverFile) { + try { + await uploadPlaylistCoverArt(id, opts.coverFile); + const { playlist: refreshed } = await getPlaylist(id); + setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev); + if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt); + showToast(t('playlists.coverUpdated')); + } catch (err) { + showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error'); + } + } else if (opts.coverRemoved) { + setCustomCoverId(null); + } + showToast(t('playlists.metaSaved')); + setEditingMeta(false); +}