diff --git a/src/ui/hooks/useLocalPlaylistDragReorder.ts b/src/ui/hooks/useLocalPlaylistDragReorder.ts new file mode 100644 index 0000000..d1de961 --- /dev/null +++ b/src/ui/hooks/useLocalPlaylistDragReorder.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import type { Playlist, Track } from "../../datasource/types"; +import { isLocalPlaylist, reorderLocalPlaylistTracks } from "../../player/localPlaylists"; + +interface UseLocalPlaylistDragReorderParams { + playlist?: Playlist; + sortedTracks: Track[]; + setTracks: Dispatch>; +} + +export function useLocalPlaylistDragReorder({ + playlist, + sortedTracks, + setTracks, +}: UseLocalPlaylistDragReorderParams) { + const isLocalPlaylistView = playlist ? isLocalPlaylist(playlist) : false; + const [dropTargetIndex, setDropTargetIndex] = useState<{ localPath: string; insertAfter: boolean } | null>(null); + const sortedTracksRef = useRef(sortedTracks); + sortedTracksRef.current = sortedTracks; + const pointerDragRef = useRef<{ + pointerId: number; + localPath: string; + startY: number; + isDragging: boolean; + } | null>(null); + const dropTargetRef = useRef<{ localPath: string; insertAfter: boolean } | null>(null); + const suppressClickRef = useRef(false); + + useEffect(() => { + if (!isLocalPlaylistView) return; + + const handlePointerMove = (event: PointerEvent) => { + const drag = pointerDragRef.current; + if (event.pointerId !== drag?.pointerId) return; + + if (!drag.isDragging) { + const distance = Math.abs(event.clientY - drag.startY); + if (distance < 6) return; + drag.isDragging = true; + } + + event.preventDefault(); + const target = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest("[data-playlist-track-path]"); + if (!target) { + setDropTargetIndex(null); + dropTargetRef.current = null; + return; + } + + const bounds = target.getBoundingClientRect(); + const nextTarget = { + localPath: target.dataset.playlistTrackPath ?? "", + insertAfter: event.clientY >= bounds.top + bounds.height / 2, + }; + dropTargetRef.current = nextTarget; + setDropTargetIndex(nextTarget); + }; + + const handlePointerUp = (event: PointerEvent) => { + const drag = pointerDragRef.current; + if (event.pointerId !== drag?.pointerId) return; + + if (drag.isDragging && dropTargetRef.current && playlist) { + const fromPath = drag.localPath; + const toPath = dropTargetRef.current.localPath; + if (!fromPath || !toPath) { + pointerDragRef.current = null; + setDropTargetIndex(null); + return; + } + + const sorted = sortedTracksRef.current; + const fromIndex = sorted.findIndex((t) => (t.localPath ?? t.id) === fromPath); + const toIndex = sorted.findIndex((t) => (t.localPath ?? t.id) === toPath); + if (fromIndex < 0 || toIndex < 0) return; + + const clampedToIndex = dropTargetRef.current.insertAfter + ? Math.min(toIndex + 1, sorted.length) + : toIndex; + const insertIndex = fromIndex < clampedToIndex + ? clampedToIndex - 1 + : clampedToIndex; + + if (fromIndex !== insertIndex) { + reorderLocalPlaylistTracks(playlist.id, fromIndex, clampedToIndex); + setTracks((current) => { + const next = [...current]; + const [moved] = next.splice(fromIndex, 1); + next.splice(insertIndex, 0, moved); + return next; + }); + } + } + + if (drag.isDragging) { + suppressClickRef.current = true; + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + } + dropTargetRef.current = null; + pointerDragRef.current = null; + setDropTargetIndex(null); + }; + + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + }; + }, [isLocalPlaylistView, playlist, setTracks]); + + const handlePointerDown = (event: React.PointerEvent, track: Track) => { + if (!isLocalPlaylistView || event.button !== 0) return; + pointerDragRef.current = { + pointerId: event.pointerId, + localPath: track.localPath ?? track.id, + startY: event.clientY, + isDragging: false, + }; + }; + + return { + isLocalPlaylistView, + dropTargetIndex, + pointerDragRef, + suppressClickRef, + handlePointerDown, + }; +} diff --git a/src/ui/pages/AlbumView.module.css b/src/ui/pages/AlbumView.module.css index 7e1c2cb..5057f8e 100644 --- a/src/ui/pages/AlbumView.module.css +++ b/src/ui/pages/AlbumView.module.css @@ -197,61 +197,13 @@ } .sortDirection { - min-width: 13px; - display: inline-flex; - align-items: center; - justify-content: center; - color: currentColor; - font-size: 11px; - font-weight: var(--font-weight-semibold); -} - -.dateSortDirection { - overflow: hidden; -} - -.sortArrow { width: 13px; + height: 13px; display: inline-flex; - flex: 0 0 13px; align-items: center; justify-content: center; - overflow: hidden; - opacity: 1; - transform: translateX(0); - transition: - width var(--transition-fast), - flex-basis var(--transition-fast), - opacity var(--transition-fast), - transform var(--transition-fast); -} - -.sortHoverLabel { - max-width: 0; - display: inline-block; - overflow: hidden; - opacity: 0; - transform: translateX(-4px); - white-space: nowrap; - transition: - max-width var(--transition-fast), - opacity var(--transition-fast), - transform var(--transition-fast); -} - -.sortOptions .activeSortOption:hover .dateSortDirection .sortArrow, -.sortOptions .activeSortOption:focus-visible .dateSortDirection .sortArrow { - width: 0; - flex-basis: 0; - opacity: 0; - transform: translateX(-4px); -} - -.sortOptions .activeSortOption:hover .dateSortDirection .sortHoverLabel, -.sortOptions .activeSortOption:focus-visible .dateSortDirection .sortHoverLabel { - max-width: 44px; - opacity: 1; - transform: translateX(0); + color: currentColor; + flex-shrink: 0; } .playlistSearch { @@ -370,7 +322,7 @@ width: 100%; min-height: 58px; display: grid; - grid-template-columns: 32px minmax(0, 1fr) 28px 24px; + grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: var(--spacing-md); border: 0; @@ -382,6 +334,24 @@ cursor: pointer; } +.playlistTrack { + grid-template-columns: 32px 42px minmax(0, 1fr) auto; +} + +.trackArtwork { + width: 42px; + height: 42px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.trackActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-sm); +} + .trackEntering { animation: trackEnter 360ms var(--easing-smooth) both; animation-delay: var(--track-enter-delay, 0ms); @@ -392,7 +362,8 @@ background: var(--color-bg-overlay); } -.track svg:last-child { +.track svg:last-child, +.trackActions svg:last-child { pointer-events: none; } diff --git a/src/ui/pages/AlbumView.tsx b/src/ui/pages/AlbumView.tsx index f39d09e..8613436 100644 --- a/src/ui/pages/AlbumView.tsx +++ b/src/ui/pages/AlbumView.tsx @@ -223,7 +223,9 @@ export function AlbumView({ album, playerController, libraryController }: AlbumV fallback={track.artist} /> - +
+
); })} diff --git a/src/ui/pages/PlaylistView.tsx b/src/ui/pages/PlaylistView.tsx index 6f432f6..cd02dcf 100644 --- a/src/ui/pages/PlaylistView.tsx +++ b/src/ui/pages/PlaylistView.tsx @@ -15,7 +15,6 @@ import type { PlayerControllerActions } from "../../player/playerStore"; import { markPlaylistPlayed } from "../../player/recentPlaylists"; import { shuffleTracks } from "../../player/shuffleTracks"; import { useTrackContextMenu } from "../components/TrackContextMenu"; -import { isLocalPlaylist, reorderLocalPlaylistTracks } from "../../player/localPlaylists"; import styles from "./AlbumView.module.css"; import { ArtistLinks } from "../components/ArtistLinks"; import { usePlaylistContextMenu } from "../components/PlaylistContextMenu"; @@ -24,6 +23,7 @@ import { useKeyboardShortcuts } from "../settings/keyboardShortcuts"; import { shouldStartPageSearch } from "./pageSearchKeyboard"; import { PlaylistDownloadButton } from "../components/PlaylistDownloadButton"; import { DownloaderStatusBadge } from "../components/DownloaderStatusBadge"; +import { useLocalPlaylistDragReorder } from "../hooks/useLocalPlaylistDragReorder"; interface PlaylistViewProps { playlist?: Playlist; @@ -98,27 +98,16 @@ export function PlaylistView({ playlist, playerController, libraryController }: const [sort, setSort] = useState("dateAdded"); const [sortDirection, setSortDirection] = useState("desc"); const [playlistSearchQuery, setPlaylistSearchQuery] = useState(""); - const [dropTargetIndex, setDropTargetIndex] = useState<{ localPath: string; insertAfter: boolean } | null>(null); const loadMoreRef = useRef(null); const playlistSearchInputRef = useRef(null); const playlistIdRef = useRef(undefined); const isLoadingMoreRef = useRef(false); const tracksRef = useRef([]); - const pointerDragRef = useRef<{ - pointerId: number; - localPath: string; - startY: number; - isDragging: boolean; - } | null>(null); - const dropTargetRef = useRef<{ localPath: string; insertAfter: boolean } | null>(null); - const suppressClickRef = useRef(false); playlistIdRef.current = playlist?.id; isLoadingMoreRef.current = isLoadingMore; tracksRef.current = tracks; - const isLocalPlaylistView = playlist ? isLocalPlaylist(playlist) : false; - useEffect(() => { if (!playlist) return; let active = true; @@ -239,9 +228,6 @@ export function PlaylistView({ playlist, playerController, libraryController }: return sortDirection === "asc" ? sorted : sorted.reverse(); }, [sort, sortDirection, tracks]); - const sortedTracksRef = useRef(sortedTracks); - sortedTracksRef.current = sortedTracks; - const visibleTracks = useMemo(() => { const query = playlistSearchQuery.trim().toLocaleLowerCase(); if (!query) return sortedTracks; @@ -264,95 +250,16 @@ export function PlaylistView({ playlist, playerController, libraryController }: return delayIndexes; }, [enteringTrackKeys, visibleTracks]); - // Drag to reorder for local playlists - useEffect(() => { - if (!isLocalPlaylistView) return; - - const handlePointerMove = (event: PointerEvent) => { - const drag = pointerDragRef.current; - if (!drag || event.pointerId !== drag.pointerId) return; - - if (!drag.isDragging) { - const distance = Math.abs(event.clientY - drag.startY); - if (distance < 6) return; - drag.isDragging = true; - } - - event.preventDefault(); - const target = document - .elementFromPoint(event.clientX, event.clientY) - ?.closest("[data-playlist-track-path]"); - if (!target) { - setDropTargetIndex(null); - dropTargetRef.current = null; - return; - } - - const bounds = target.getBoundingClientRect(); - const nextTarget = { - localPath: target.dataset.playlistTrackPath ?? "", - insertAfter: event.clientY >= bounds.top + bounds.height / 2, - }; - dropTargetRef.current = nextTarget; - setDropTargetIndex(nextTarget); - }; - - const handlePointerUp = (event: PointerEvent) => { - const drag = pointerDragRef.current; - if (!drag || event.pointerId !== drag.pointerId) return; - - if (drag.isDragging && dropTargetRef.current && playlist) { - const fromPath = drag.localPath; - const toPath = dropTargetRef.current.localPath; - if (!fromPath || !toPath) { - pointerDragRef.current = null; - setDropTargetIndex(null); - return; - } - - const sorted = sortedTracksRef.current; - const fromIndex = sorted.findIndex((t) => (t.localPath ?? t.id) === fromPath); - const toIndex = sorted.findIndex((t) => (t.localPath ?? t.id) === toPath); - if (fromIndex < 0 || toIndex < 0) return; - - const clampedToIndex = dropTargetRef.current.insertAfter - ? Math.min(toIndex + 1, sorted.length) - : toIndex; - const insertIndex = fromIndex < clampedToIndex - ? clampedToIndex - 1 - : clampedToIndex; - - if (fromIndex !== insertIndex) { - reorderLocalPlaylistTracks(playlist.id, fromIndex, clampedToIndex); - setTracks((current) => { - const next = [...current]; - const [moved] = next.splice(fromIndex, 1); - next.splice(insertIndex, 0, moved); - return next; - }); - } - } - - if (drag.isDragging) { - suppressClickRef.current = true; - window.setTimeout(() => { - suppressClickRef.current = false; - }, 0); - } - dropTargetRef.current = null; - pointerDragRef.current = null; - setDropTargetIndex(null); - }; - - window.addEventListener("pointermove", handlePointerMove, { passive: false }); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - }; - }, [isLocalPlaylistView, playlist]); + const { + dropTargetIndex, + pointerDragRef, + suppressClickRef, + handlePointerDown, + } = useLocalPlaylistDragReorder({ + playlist, + sortedTracks, + setTracks, + }); if (!playlist) return null; @@ -421,16 +328,6 @@ export function PlaylistView({ playlist, playerController, libraryController }: event.currentTarget.blur(); }; - const handlePointerDown = (event: React.PointerEvent, track: Track) => { - if (!isLocalPlaylistView || event.button !== 0) return; - pointerDragRef.current = { - pointerId: event.pointerId, - localPath: track.localPath ?? track.id, - startY: event.clientY, - isDragging: false, - }; - }; - return (
- {playlistSorts.map((item) => ( - - ))} + )} + + ); + })}
)} {index + 1} + {track.title} - - +
+ +
); })}