refactor(playlist): G.70 — extract three lifecycle hooks (route + bulk-picker dismiss + DnD reorder) (#637)

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.
This commit is contained in:
Frank Stellmacher
2026-05-13 14:01:22 +02:00
committed by GitHub
parent 16ee1ea373
commit e260669537
4 changed files with 95 additions and 45 deletions
+15
View File
@@ -0,0 +1,15 @@
import React, { useEffect } from 'react';
export function useBulkPlPickerOutsideClick(
showBulkPlPicker: boolean,
setShowBulkPlPicker: React.Dispatch<React.SetStateAction<boolean>>,
): 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]);
}
+44
View File
@@ -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<HTMLDivElement | null>;
songs: SubsonicSong[];
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
}
export interface PlaylistDnDReorderResult {
dropTargetIdx: { idx: number; before: boolean } | null;
setDropTargetIdx: React.Dispatch<React.SetStateAction<{ idx: number; before: boolean } | null>>;
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 };
}
+27
View File
@@ -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<React.SetStateAction<string | null>>;
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
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]);
}
+9 -45
View File
@@ -41,7 +41,6 @@ import { runPlaylistZipDownload } from '../utils/runPlaylistZipDownload';
import { runPlaylistSaveMeta } from '../utils/runPlaylistSaveMeta'; import { runPlaylistSaveMeta } from '../utils/runPlaylistSaveMeta';
import { runPlaylistLoad } from '../utils/runPlaylistLoad'; import { runPlaylistLoad } from '../utils/runPlaylistLoad';
import { startPlaylistRowDrag } from '../utils/startPlaylistRowDrag'; import { startPlaylistRowDrag } from '../utils/startPlaylistRowDrag';
import { runPlaylistReorderDrop } from '../utils/runPlaylistReorderDrop';
import { usePlaylistCovers } from '../hooks/usePlaylistCovers'; import { usePlaylistCovers } from '../hooks/usePlaylistCovers';
import { usePlaylistSelection } from '../hooks/usePlaylistSelection'; import { usePlaylistSelection } from '../hooks/usePlaylistSelection';
import { usePlaylistSuggestions } from '../hooks/usePlaylistSuggestions'; import { usePlaylistSuggestions } from '../hooks/usePlaylistSuggestions';
@@ -51,6 +50,9 @@ import { usePlaylistStarRating } from '../hooks/usePlaylistStarRating';
import { usePlaylistPreview } from '../hooks/usePlaylistPreview'; import { usePlaylistPreview } from '../hooks/usePlaylistPreview';
import { usePlaylistBulkPlayCallbacks } from '../hooks/usePlaylistBulkPlayCallbacks'; import { usePlaylistBulkPlayCallbacks } from '../hooks/usePlaylistBulkPlayCallbacks';
import { usePlaylistDerived } from '../hooks/usePlaylistDerived'; import { usePlaylistDerived } from '../hooks/usePlaylistDerived';
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
// ── Column configuration ────────────────────────────────────────────────────── // ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [ const PL_COLUMNS: readonly ColDef[] = [
@@ -126,7 +128,6 @@ export default function PlaylistDetail() {
const previewingId = usePreviewStore(s => s.previewingId); const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted); const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null); const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const zipDownloads = useZipDownloadStore(s => s.downloads); const zipDownloads = useZipDownloadStore(s => s.downloads);
const [zipDownloadId, setZipDownloadId] = useState<string | null>(null); const [zipDownloadId, setZipDownloadId] = useState<string | null>(null);
const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined; const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined;
@@ -157,15 +158,7 @@ export default function PlaylistDetail() {
const [showBulkPlPicker, setShowBulkPlPicker] = useState(false); const [showBulkPlPicker, setShowBulkPlPicker] = useState(false);
const { selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, bulkRemove } = const { selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, bulkRemove } =
usePlaylistSelection(songs, setSongs, savePlaylist); usePlaylistSelection(songs, setSongs, savePlaylist);
useBulkPlPickerOutsideClick(showBulkPlPicker, setShowBulkPlPicker);
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]);
// ── 2×2 cover quad (first 4 unique album covers) ───────────── // ── 2×2 cover quad (first 4 unique album covers) ─────────────
const { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl } = const { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl } =
@@ -190,20 +183,7 @@ export default function PlaylistDetail() {
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
// DnD usePlaylistRouteEffects({ setContextMenuSongId, setEditingMeta, location, navigate });
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]);
// ── Load ───────────────────────────────────────────────────── // ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
@@ -257,18 +237,10 @@ export default function PlaylistDetail() {
ratings, setRatings, starredSongs, setStarredSongs, ratings, setRatings, starredSongs, setStarredSongs,
}); });
// ── psy-drop DnD reordering ─────────────────────────────────── // ── DnD reorder listener + drag-over visual feedback ──────────
useEffect(() => { const { dropTargetIdx, handleRowMouseEnter } = usePlaylistDnDReorder({
const container = tracklistRef.current; tracklistRef, songs, savePlaylist, setSongs,
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]);
// ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ──
const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { const handleRowMouseDown = (e: React.MouseEvent, idx: number) => {
@@ -280,14 +252,6 @@ export default function PlaylistDetail() {
filterText, sortKey, sortDir, ratings, starredSongs, 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) ───────── // ── Playback actions (encapsulated like AlbumHeader) ─────────
const { handlePlayAll, handleShuffleAll, handleEnqueueAll } = usePlaylistBulkPlayCallbacks({ const { handlePlayAll, handleShuffleAll, handleEnqueueAll } = usePlaylistBulkPlayCallbacks({
songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue, songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue,