mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(playlist): G.66 — extract ZIP download + bulk play + row drag + reorder drop (cluster) (#633)
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.
This commit is contained in:
committed by
GitHub
parent
0b84a199e4
commit
1a3eeea048
+23
-105
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void>;
|
||||
setDropTargetIdx: React.Dispatch<React.SetStateAction<{ idx: number; before: boolean } | null>>;
|
||||
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
setZipDownloadId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export async function runPlaylistZipDownload(deps: RunPlaylistZipDownloadDeps): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user