From 1a3eeea0485974a335b3ad651e4b508ec0974e9c Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 13:17:24 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.66=20=E2=80=94=20extrac?= =?UTF-8?q?t=20ZIP=20download=20+=20bulk=20play=20+=20row=20drag=20+=20reo?= =?UTF-8?q?rder=20drop=20(cluster)=20(#633)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four-cut cluster pulling action bodies out of PlaylistDetail.tsx. 741 → 635 LOC (−106). Each handler on the page becomes a thin wrapper around a parameterized utility; pure code move, no behaviour change. runPlaylistZipDownload — the buildDownloadUrl → invoke('download_zip') flow with start/complete/fail dispatches to useZipDownloadStore and the setZipDownloadId hand-off. Takes playlist + id + downloadFolder + requestDownloadFolder + the id-setter as deps. PlaylistDetail loses the invoke / join / buildDownloadUrl / sanitizeFilename imports that only this handler used. playlistBulkPlayActions — three exports (playPlaylistAll, shufflePlaylistAll, enqueuePlaylistAll) with a shared BulkPlayDeps shape (songsLength + id + tracks + touchPlaylist + playTrack + enqueue). Same length-guard, same touchPlaylist call, same shuffle / play / enqueue branches as before. startPlaylistRowDrag — the threshold-drag dispatch on row mousedown (5px deadzone, then bulk-songs / playlist-reorder / single-song payload depending on selection state + filtered-view flag). Takes the mouse event + idx + songs + selectedIds + isFiltered + startDrag. runPlaylistReorderDrop — the psy-drop event handler that lives inside the tracklist useEffect. Parses the custom-event detail, computes from→to indexes, and updates songs + savePlaylist + clears dropTargetIdx. The useEffect itself stays in the page because it wires up the event listener. PlaylistDetail's import list also drops `invoke`, `join`, `buildDownloadUrl`, `sanitizeFilename` — they followed the ZIP helper to its new file. --- src/pages/PlaylistDetail.tsx | 128 +++++---------------------- src/utils/playlistBulkPlayActions.ts | 36 ++++++++ src/utils/runPlaylistReorderDrop.ts | 45 ++++++++++ src/utils/runPlaylistZipDownload.ts | 36 ++++++++ src/utils/startPlaylistRowDrag.ts | 47 ++++++++++ 5 files changed, 187 insertions(+), 105 deletions(-) create mode 100644 src/utils/playlistBulkPlayActions.ts create mode 100644 src/utils/runPlaylistReorderDrop.ts create mode 100644 src/utils/runPlaylistZipDownload.ts create mode 100644 src/utils/startPlaylistRowDrag.ts diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index c70adae7..d5d3f02a 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,5 +1,5 @@ import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonicPlaylists'; -import { buildDownloadUrl, coverArtCacheKey, buildCoverArtUrl } from '../api/subsonicStreamUrl'; +import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonicStreamUrl'; import { setRating, star, unstar } from '../api/subsonicStarRating'; import { search } from '../api/subsonicSearch'; import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibrary'; @@ -20,8 +20,6 @@ import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; -import { invoke } from '@tauri-apps/api/core'; -import { join } from '@tauri-apps/api/path'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; @@ -29,7 +27,6 @@ import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import StarRating from '../components/StarRating'; import { - sanitizeFilename, formatDuration, formatSize, totalDurationLabel, @@ -47,6 +44,10 @@ import PlaylistHero from '../components/playlist/PlaylistHero'; 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 { playPlaylistAll, shufflePlaylistAll, enqueuePlaylistAll } from '../utils/playlistBulkPlayActions'; +import { startPlaylistRowDrag } from '../utils/startPlaylistRowDrag'; +import { runPlaylistReorderDrop } from '../utils/runPlaylistReorderDrop'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -346,24 +347,9 @@ export default function PlaylistDetail() { // ── ZIP Download ────────────────────────────────────────────── const handleDownload = async () => { if (!playlist || !id) return; - const folder = downloadFolder || await requestDownloadFolder(); - if (!folder) return; - - const filename = `${sanitizeFilename(playlist.name)}.zip`; - const destPath = await join(folder, filename); - const url = buildDownloadUrl(id); - const downloadId = crypto.randomUUID(); - - const { start, complete, fail } = useZipDownloadStore.getState(); - start(downloadId, filename); - setZipDownloadId(downloadId); - try { - await invoke('download_zip', { id: downloadId, url, destPath }); - complete(downloadId); - } catch (e) { - fail(downloadId); - console.error('ZIP download failed:', e); - } + await runPlaylistZipDownload({ + playlist, id, downloadFolder, requestDownloadFolder, setZipDownloadId, + }); }; // ── CSV Import ──────────────────────────────────────────────── @@ -461,76 +447,16 @@ export default function PlaylistDetail() { if (!container) return; const onPsyDrop = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (!detail?.data) return; - let parsed: any; - try { parsed = JSON.parse(detail.data); } catch { return; } - if (parsed.type !== 'playlist_reorder') return; - - setDropTargetIdx(null); - - const fromIdx: number = parsed.index; - - // Determine drop index from the event target row - const target = (e.target as HTMLElement).closest('[data-track-idx]'); - let toIdx = songs.length; - if (target) { - const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10); - const rect = target.getBoundingClientRect(); - const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2); - const before = cursorY < rect.top + rect.height / 2; - toIdx = before ? targetIdx : targetIdx + 1; - } - - if (fromIdx === toIdx || fromIdx === toIdx - 1) return; - - setSongs(prev => { - const next = [...prev]; - const [moved] = next.splice(fromIdx, 1); - const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx; - next.splice(insertAt, 0, moved); - savePlaylist(next); - return next; - }); + runPlaylistReorderDrop({ e, songs, savePlaylist, setDropTargetIdx, setSongs }); }; container.addEventListener('psy-drop', onPsyDrop); return () => container.removeEventListener('psy-drop', onPsyDrop); - }, [songs, savePlaylist]); + }, [songs, savePlaylist, tracklistRef]); // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { - if (e.button !== 0) return; - if ((e.target as HTMLElement).closest('button, input')) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) { - const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack); - startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); - } else if (!isFiltered) { - startDrag( - { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, - me.clientX, me.clientY - ); - } else { - // filtered view: single-song drag to queue - startDrag( - { data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' }, - me.clientX, me.clientY - ); - } - } - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + startPlaylistRowDrag({ e, idx, songs, selectedIds, isFiltered, startDrag }); }; // ── Memoized derivations ────────────────────────────────────── @@ -559,28 +485,20 @@ export default function PlaylistDetail() { }; // ── Playback actions (encapsulated like AlbumHeader) ───────── - const handlePlayAll = useCallback(() => { - if (!songs.length || !id) return; - touchPlaylist(id); - playTrack(tracks[0], tracks); - }, [songs.length, id, tracks, touchPlaylist, playTrack]); + const handlePlayAll = useCallback( + () => playPlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), + [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], + ); - const handleShuffleAll = useCallback(() => { - if (!songs.length || !id) return; - touchPlaylist(id); - const shuffled = [...tracks]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - playTrack(shuffled[0], shuffled); - }, [songs.length, id, tracks, touchPlaylist, playTrack]); + const handleShuffleAll = useCallback( + () => shufflePlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), + [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], + ); - const handleEnqueueAll = useCallback(() => { - if (!songs.length || !id) return; - touchPlaylist(id); - enqueue(tracks); - }, [songs.length, id, tracks, touchPlaylist, enqueue]); + const handleEnqueueAll = useCallback( + () => enqueuePlaylistAll({ songsLength: songs.length, id, tracks, touchPlaylist, playTrack, enqueue }), + [songs.length, id, tracks, touchPlaylist, playTrack, enqueue], + ); // ── Render ──────────────────────────────────────────────────── if (loading) { diff --git a/src/utils/playlistBulkPlayActions.ts b/src/utils/playlistBulkPlayActions.ts new file mode 100644 index 00000000..3e67a307 --- /dev/null +++ b/src/utils/playlistBulkPlayActions.ts @@ -0,0 +1,36 @@ +import type { Track } from '../store/playerStoreTypes'; + +export interface BulkPlayDeps { + songsLength: number; + id: string | undefined; + tracks: Track[]; + touchPlaylist: (id: string) => void; + playTrack: (track: Track, queue: Track[]) => void; + enqueue: (tracks: Track[]) => void; +} + +export function playPlaylistAll(deps: BulkPlayDeps): void { + const { songsLength, id, tracks, touchPlaylist, playTrack } = deps; + if (!songsLength || !id) return; + touchPlaylist(id); + playTrack(tracks[0], tracks); +} + +export function shufflePlaylistAll(deps: BulkPlayDeps): void { + const { songsLength, id, tracks, touchPlaylist, playTrack } = deps; + if (!songsLength || !id) return; + touchPlaylist(id); + const shuffled = [...tracks]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + playTrack(shuffled[0], shuffled); +} + +export function enqueuePlaylistAll(deps: BulkPlayDeps): void { + const { songsLength, id, tracks, touchPlaylist, enqueue } = deps; + if (!songsLength || !id) return; + touchPlaylist(id); + enqueue(tracks); +} diff --git a/src/utils/runPlaylistReorderDrop.ts b/src/utils/runPlaylistReorderDrop.ts new file mode 100644 index 00000000..b3c628ac --- /dev/null +++ b/src/utils/runPlaylistReorderDrop.ts @@ -0,0 +1,45 @@ +import type React from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; + +export interface RunPlaylistReorderDropDeps { + e: Event; + songs: SubsonicSong[]; + savePlaylist: (next: SubsonicSong[], prevCount?: number) => Promise; + setDropTargetIdx: React.Dispatch>; + setSongs: React.Dispatch>; +} + +export function runPlaylistReorderDrop(deps: RunPlaylistReorderDropDeps): void { + const { e, songs, savePlaylist, setDropTargetIdx, setSongs } = deps; + const detail = (e as CustomEvent).detail; + if (!detail?.data) return; + let parsed: any; + try { parsed = JSON.parse(detail.data); } catch { return; } + if (parsed.type !== 'playlist_reorder') return; + + setDropTargetIdx(null); + + const fromIdx: number = parsed.index; + + // Determine drop index from the event target row + const target = (e.target as HTMLElement).closest('[data-track-idx]'); + let toIdx = songs.length; + if (target) { + const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10); + const rect = target.getBoundingClientRect(); + const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2); + const before = cursorY < rect.top + rect.height / 2; + toIdx = before ? targetIdx : targetIdx + 1; + } + + if (fromIdx === toIdx || fromIdx === toIdx - 1) return; + + setSongs(prev => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx; + next.splice(insertAt, 0, moved); + savePlaylist(next); + return next; + }); +} diff --git a/src/utils/runPlaylistZipDownload.ts b/src/utils/runPlaylistZipDownload.ts new file mode 100644 index 00000000..6f57c77a --- /dev/null +++ b/src/utils/runPlaylistZipDownload.ts @@ -0,0 +1,36 @@ +import { invoke } from '@tauri-apps/api/core'; +import { join } from '@tauri-apps/api/path'; +import { buildDownloadUrl } from '../api/subsonicStreamUrl'; +import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { useZipDownloadStore } from '../store/zipDownloadStore'; +import { sanitizeFilename } from './playlistDetailHelpers'; + +export interface RunPlaylistZipDownloadDeps { + playlist: SubsonicPlaylist; + id: string; + downloadFolder: string | null; + requestDownloadFolder: () => Promise; + setZipDownloadId: (id: string | null) => void; +} + +export async function runPlaylistZipDownload(deps: RunPlaylistZipDownloadDeps): Promise { + const { playlist, id, downloadFolder, requestDownloadFolder, setZipDownloadId } = deps; + const folder = downloadFolder || await requestDownloadFolder(); + if (!folder) return; + + const filename = `${sanitizeFilename(playlist.name)}.zip`; + const destPath = await join(folder, filename); + const url = buildDownloadUrl(id); + const downloadId = crypto.randomUUID(); + + const { start, complete, fail } = useZipDownloadStore.getState(); + start(downloadId, filename); + setZipDownloadId(downloadId); + try { + await invoke('download_zip', { id: downloadId, url, destPath }); + complete(downloadId); + } catch (e) { + fail(downloadId); + console.error('ZIP download failed:', e); + } +} diff --git a/src/utils/startPlaylistRowDrag.ts b/src/utils/startPlaylistRowDrag.ts new file mode 100644 index 00000000..3e27eb4c --- /dev/null +++ b/src/utils/startPlaylistRowDrag.ts @@ -0,0 +1,47 @@ +import type React from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { songToTrack } from './songToTrack'; + +export interface StartPlaylistRowDragDeps { + e: React.MouseEvent; + idx: number; + songs: SubsonicSong[]; + selectedIds: Set; + isFiltered: boolean; + startDrag: (payload: { data: string; label: string }, x: number, y: number) => void; +} + +export function startPlaylistRowDrag(deps: StartPlaylistRowDragDeps): void { + const { e, idx, songs, selectedIds, isFiltered, startDrag } = deps; + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('button, input')) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) { + const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack); + startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); + } else if (!isFiltered) { + startDrag( + { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, + me.clientX, me.clientY + ); + } else { + // filtered view: single-song drag to queue + startDrag( + { data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' }, + me.clientX, me.clientY + ); + } + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +}