From e26066953769ae77256b2b17bb0e00d71c6738b4 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 14:01:22 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.70=20=E2=80=94=20extrac?= =?UTF-8?q?t=20three=20lifecycle=20hooks=20(route=20+=20bulk-picker=20dism?= =?UTF-8?q?iss=20+=20DnD=20reorder)=20(#637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-cut cluster pulling the last cluster of useEffect bodies + the DnD-reorder visual feedback out of PlaylistDetail.tsx. 452 → 423 LOC (−29). usePlaylistRouteEffects — bundles two route-driven effects: contextMenu reset (clears contextMenuSongId whenever playerStore's context menu closes) and openEditMeta-from-route (consumes the `openEditMeta` location.state flag, opens the meta modal, and clears the flag with a navigate-replace so back-nav doesn't re-trigger). Subscribes to playerStore directly. useBulkPlPickerOutsideClick — the global mousedown listener that closes the bulk-add-to-playlist picker when clicking outside the picker wrapper. No-op when closed. usePlaylistDnDReorder — owns dropTargetIdx state, the container.addEventListener('psy-drop', …) wiring that calls runPlaylistReorderDrop, and the handleRowMouseEnter drag-over-visual helper. Subscribes to DragDropContext directly. PlaylistDetail drops the direct runPlaylistReorderDrop import and the contextMenuOpen store subscription — both now live in the hooks that need them. Pure code move otherwise. --- src/hooks/useBulkPlPickerOutsideClick.ts | 15 +++++++ src/hooks/usePlaylistDnDReorder.ts | 44 +++++++++++++++++++ src/hooks/usePlaylistRouteEffects.ts | 27 ++++++++++++ src/pages/PlaylistDetail.tsx | 54 ++++-------------------- 4 files changed, 95 insertions(+), 45 deletions(-) create mode 100644 src/hooks/useBulkPlPickerOutsideClick.ts create mode 100644 src/hooks/usePlaylistDnDReorder.ts create mode 100644 src/hooks/usePlaylistRouteEffects.ts diff --git a/src/hooks/useBulkPlPickerOutsideClick.ts b/src/hooks/useBulkPlPickerOutsideClick.ts new file mode 100644 index 00000000..6aea879f --- /dev/null +++ b/src/hooks/useBulkPlPickerOutsideClick.ts @@ -0,0 +1,15 @@ +import React, { useEffect } from 'react'; + +export function useBulkPlPickerOutsideClick( + showBulkPlPicker: boolean, + setShowBulkPlPicker: React.Dispatch>, +): void { + useEffect(() => { + if (!showBulkPlPicker) return; + const handler = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showBulkPlPicker, setShowBulkPlPicker]); +} diff --git a/src/hooks/usePlaylistDnDReorder.ts b/src/hooks/usePlaylistDnDReorder.ts new file mode 100644 index 00000000..5d389b9d --- /dev/null +++ b/src/hooks/usePlaylistDnDReorder.ts @@ -0,0 +1,44 @@ +import React, { useEffect, useState } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { runPlaylistReorderDrop } from '../utils/runPlaylistReorderDrop'; + +export interface PlaylistDnDReorderDeps { + tracklistRef: React.RefObject; + songs: SubsonicSong[]; + savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise; + setSongs: React.Dispatch>; +} + +export interface PlaylistDnDReorderResult { + dropTargetIdx: { idx: number; before: boolean } | null; + setDropTargetIdx: React.Dispatch>; + handleRowMouseEnter: (idx: number, e: React.MouseEvent) => void; +} + +export function usePlaylistDnDReorder(deps: PlaylistDnDReorderDeps): PlaylistDnDReorderResult { + const { tracklistRef, songs, savePlaylist, setSongs } = deps; + const { isDragging } = useDragDrop(); + const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null); + + useEffect(() => { + const container = tracklistRef.current; + if (!container) return; + + const onPsyDrop = (e: Event) => { + runPlaylistReorderDrop({ e, songs, savePlaylist, setDropTargetIdx, setSongs }); + }; + + container.addEventListener('psy-drop', onPsyDrop); + return () => container.removeEventListener('psy-drop', onPsyDrop); + }, [songs, savePlaylist, tracklistRef, setSongs]); + + const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { + if (!isDragging) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const before = e.clientY < rect.top + rect.height / 2; + setDropTargetIdx({ idx, before }); + }; + + return { dropTargetIdx, setDropTargetIdx, handleRowMouseEnter }; +} diff --git a/src/hooks/usePlaylistRouteEffects.ts b/src/hooks/usePlaylistRouteEffects.ts new file mode 100644 index 00000000..50f63c9d --- /dev/null +++ b/src/hooks/usePlaylistRouteEffects.ts @@ -0,0 +1,27 @@ +import React, { useEffect } from 'react'; +import type { Location, NavigateFunction } from 'react-router-dom'; +import { usePlayerStore } from '../store/playerStore'; + +export interface PlaylistRouteEffectsDeps { + setContextMenuSongId: React.Dispatch>; + setEditingMeta: React.Dispatch>; + location: Location; + navigate: NavigateFunction; +} + +export function usePlaylistRouteEffects(deps: PlaylistRouteEffectsDeps): void { + const { setContextMenuSongId, setEditingMeta, location, navigate } = deps; + const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); + + useEffect(() => { + if (!contextMenuOpen) setContextMenuSongId(null); + }, [contextMenuOpen, setContextMenuSongId]); + + useEffect(() => { + const state = (location.state as { openEditMeta?: boolean } | null) ?? null; + if (state?.openEditMeta) { + setEditingMeta(true); + navigate(location.pathname, { replace: true, state: null }); + } + }, [location.state, location.pathname, navigate, setEditingMeta]); +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 8bd3ee53..f1545fec 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -41,7 +41,6 @@ import { runPlaylistZipDownload } from '../utils/runPlaylistZipDownload'; import { runPlaylistSaveMeta } from '../utils/runPlaylistSaveMeta'; import { runPlaylistLoad } from '../utils/runPlaylistLoad'; 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'; @@ -51,6 +50,9 @@ import { usePlaylistStarRating } from '../hooks/usePlaylistStarRating'; import { usePlaylistPreview } from '../hooks/usePlaylistPreview'; import { usePlaylistBulkPlayCallbacks } from '../hooks/usePlaylistBulkPlayCallbacks'; import { usePlaylistDerived } from '../hooks/usePlaylistDerived'; +import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects'; +import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick'; +import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -126,7 +128,6 @@ export default function PlaylistDetail() { const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const [contextMenuSongId, setContextMenuSongId] = useState(null); - const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const zipDownloads = useZipDownloadStore(s => s.downloads); const [zipDownloadId, setZipDownloadId] = useState(null); const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined; @@ -157,15 +158,7 @@ export default function PlaylistDetail() { const [showBulkPlPicker, setShowBulkPlPicker] = useState(false); const { selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, bulkRemove } = usePlaylistSelection(songs, setSongs, savePlaylist); - - useEffect(() => { - if (!showBulkPlPicker) return; - const handler = (e: MouseEvent) => { - if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [showBulkPlPicker]); + useBulkPlPickerOutsideClick(showBulkPlPicker, setShowBulkPlPicker); // ── 2×2 cover quad (first 4 unique album covers) ───────────── const { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl } = @@ -190,20 +183,7 @@ export default function PlaylistDetail() { pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); - // DnD - const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null); - - useEffect(() => { - if (!contextMenuOpen) setContextMenuSongId(null); - }, [contextMenuOpen]); - - useEffect(() => { - const state = (location.state as { openEditMeta?: boolean } | null) ?? null; - if (state?.openEditMeta) { - setEditingMeta(true); - navigate(location.pathname, { replace: true, state: null }); - } - }, [location.state, location.pathname, navigate]); + usePlaylistRouteEffects({ setContextMenuSongId, setEditingMeta, location, navigate }); // ── Load ───────────────────────────────────────────────────── const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); @@ -257,18 +237,10 @@ export default function PlaylistDetail() { ratings, setRatings, starredSongs, setStarredSongs, }); - // ── psy-drop DnD reordering ─────────────────────────────────── - useEffect(() => { - const container = tracklistRef.current; - if (!container) return; - - const onPsyDrop = (e: Event) => { - runPlaylistReorderDrop({ e, songs, savePlaylist, setDropTargetIdx, setSongs }); - }; - - container.addEventListener('psy-drop', onPsyDrop); - return () => container.removeEventListener('psy-drop', onPsyDrop); - }, [songs, savePlaylist, tracklistRef]); + // ── DnD reorder listener + drag-over visual feedback ────────── + const { dropTargetIdx, handleRowMouseEnter } = usePlaylistDnDReorder({ + tracklistRef, songs, savePlaylist, setSongs, + }); // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { @@ -280,14 +252,6 @@ export default function PlaylistDetail() { filterText, sortKey, sortDir, ratings, starredSongs, }); - // ── Drag-over visual feedback ───────────────────────────────── - const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { - if (!isDragging) return; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const before = e.clientY < rect.top + rect.height / 2; - setDropTargetIdx({ idx, before }); - }; - // ── Playback actions (encapsulated like AlbumHeader) ───────── const { handlePlayAll, handleShuffleAll, handleEnqueueAll } = usePlaylistBulkPlayCallbacks({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue,