From de711b8c403e45ad65a3de63bd1d2e8e91cf2ea2 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Fri, 6 Feb 2026 05:33:29 -0800 Subject: [PATCH] drag drop pt. 3 --- src/lib/components/GlobalPlayer.svelte | 103 +++++++++++++------------ src/lib/player/player.svelte.ts | 50 ++++++++++-- 2 files changed, 95 insertions(+), 58 deletions(-) diff --git a/src/lib/components/GlobalPlayer.svelte b/src/lib/components/GlobalPlayer.svelte index 7b3b1b1..b58bfdd 100644 --- a/src/lib/components/GlobalPlayer.svelte +++ b/src/lib/components/GlobalPlayer.svelte @@ -275,8 +275,12 @@ let clearQueueDialogOpen = $state(false); - // --- Drag & drop reorder (works for both linear and shuffle) --- - // We reorder by track.id (annSongId). The player module adjusts traversal state. + // --- Drag & drop reorder --- + // Important: in shuffle mode, the UI is showing `queueDisplay` (shuffled traversal), + // so we must compute targets against that order (not `snap.queue`). + // + // We still reorder by track.id (annSongId). The player module applies shuffle-aware + // semantics (linear: reorder queue; shuffle: reorder upcoming play order). let dragId = $state(null); let dragOverId = $state(null); let dragOverEdge = $state<"top" | "bottom" | null>(null); @@ -286,7 +290,6 @@ dragOverId = null; dragOverEdge = null; - // Best-effort: set payload (useful for some browsers) try { e.dataTransfer?.setData("text/plain", String(trackId)); e.dataTransfer!.effectAllowed = "move"; @@ -300,15 +303,12 @@ if (!el) return "bottom" as const; const rect = el.getBoundingClientRect(); - const y = e.clientY; const mid = rect.top + rect.height / 2; - return y < mid ? ("top" as const) : ("bottom" as const); + return e.clientY < mid ? ("top" as const) : ("bottom" as const); } function onDragOver(trackId: number, e: DragEvent) { - // Required to allow dropping e.preventDefault(); - dragOverId = trackId; dragOverEdge = edgeFromPointer(e); @@ -322,7 +322,6 @@ function onDrop(targetTrackId: number, e: DragEvent) { e.preventDefault(); - // Prefer our internal dragId; fall back to transfer data let sourceId = dragId; if (sourceId == null) { const raw = e.dataTransfer?.getData("text/plain"); @@ -337,11 +336,23 @@ return; } - // Reorder by queue indices (insert between rows based on pointer edge) - const fromIndex = snap.queue.findIndex((t) => t.id === sourceId); - const targetIndex = snap.queue.findIndex((t) => t.id === targetTrackId); + // Work in DISPLAY ORDER, then map to queue indices. + const display = queueDisplay; + const fromItem = display.find((it) => it.track.id === sourceId); + const targetItem = display.find((it) => it.track.id === targetTrackId); - if (fromIndex === -1 || targetIndex === -1) { + if (!fromItem || !targetItem) { + dragId = null; + dragOverId = null; + dragOverEdge = null; + return; + } + + const fromQueueIndex = fromItem.queueIndex; + const targetQueueIndex = targetItem.queueIndex; + + // If dropping onto itself, no-op + if (sourceId === targetTrackId) { dragId = null; dragOverId = null; dragOverEdge = null; @@ -350,46 +361,41 @@ const edge = dragOverEdge ?? edgeFromPointer(e); - // Compute insertion index in the *post-removal* list. - // - // We interpret: - // - edge === "top" -> insert before target row - // - edge === "bottom" -> insert after target row - // - // Because `reorderTrackById` uses "toIndex in current queue", we must - // translate the desired insertion slot into that API carefully. - // - // Approach: - // - Convert "insert after" into a "before the next item" index. - // - Adjust for the fact that removing `fromIndex` shifts indices. - let insertIndex = edge === "top" ? targetIndex : targetIndex + 1; + // Compute insertion slot in display order (0..display.length) + const fromPos = display.findIndex((it) => it.track.id === sourceId); + const targetPos = display.findIndex((it) => it.track.id === targetTrackId); - // Clamp to [0..len] - insertIndex = Math.max(0, Math.min(snap.queue.length, insertIndex)); - - // If moving downward and inserting after/before beyond itself, removal shifts indices. - // `reorderTrackById` expects a final *index of the moved item*, so we convert the - // insertion slot (0..len) into a destination index (0..len-1). - let toIndex: number; - if (insertIndex <= fromIndex) { - // You're inserting somewhere before (or at) the source position: - // final index is exactly the insert slot. - toIndex = insertIndex; - } else { - // You're inserting after the source position; after removal, everything shifts left by 1. - // The moved item ends up at insertIndex - 1. - toIndex = insertIndex - 1; - } - - // If dropping "between" where it already is, no-op - if (toIndex === fromIndex) { + if (fromPos === -1 || targetPos === -1) { dragId = null; dragOverId = null; dragOverEdge = null; return; } - reorderTrackById(sourceId, toIndex); + let insertPos = edge === "top" ? targetPos : targetPos + 1; + insertPos = Math.max(0, Math.min(display.length, insertPos)); + + // Convert insert slot (0..len) into a destination position (0..len-1) + // accounting for removal shift. + let toPos: number; + if (insertPos <= fromPos) toPos = insertPos; + else toPos = insertPos - 1; + + if (toPos === fromPos) { + dragId = null; + dragOverId = null; + dragOverEdge = null; + return; + } + + // Map destination display position back to a queue index: + // - If inserting at the end, use the last item's queueIndex (the module will interpret appropriately) + // - Otherwise, use the queueIndex of the item currently at `toPos` + const anchor = + toPos >= display.length ? display[display.length - 1] : display[toPos]; + const anchorQueueIndex = anchor?.queueIndex ?? targetQueueIndex; + + reorderTrackById(sourceId, anchorQueueIndex); dragId = null; dragOverId = null; @@ -405,12 +411,7 @@ function draggableHint(trackId: number) { if (dragId == null) return ""; if (dragId === trackId) return "opacity-70"; - if (dragOverId === trackId) { - if (dragOverEdge === "top") return "ring-2 ring-primary/40 ring-offset-0"; - if (dragOverEdge === "bottom") - return "ring-2 ring-primary/40 ring-offset-0"; - return "ring-2 ring-primary/40 ring-offset-0"; - } + if (dragOverId === trackId) return "ring-2 ring-primary/40 ring-offset-0"; return ""; } diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts index cd3bf9b..41f8048 100644 --- a/src/lib/player/player.svelte.ts +++ b/src/lib/player/player.svelte.ts @@ -522,15 +522,20 @@ export function removeTrack(id: number): void { /** * Reorder an existing queued track by id. * - * Works in both linear and shuffle mode: - * - Linear: `queue` order is updated and `currentIndex` is adjusted accordingly. - * - Shuffle: traversal state (`order`/`history`/`cursor`) is re-indexed via - * `reindexAfterMoveOrRemove` to preserve the same "already played" set and - * future schedule, but mapped onto the new queue indices. + * Semantics depend on shuffle: + * - Linear (shuffle off): reorder the underlying `queue` and adjust `currentIndex`. + * - Shuffle (shuffle on): reorder the UPCOMING play order (the `order` list), not the + * underlying `queue`, because the UI is presenting a shuffled traversal that users + * expect to be able to rearrange. + * + * In shuffle mode this only affects tracks that are still in the future schedule + * (`order`). Already-played history is left intact. * * Notes: - * - This only reorders if the track exists in the queue. - * - `toIndex` is clamped to the valid range. + * - `toIndex` is a queue index when shuffle is OFF (destination position in `queue`). + * - `toIndex` is a queue index when shuffle is ON too, but it is interpreted as + * "place this track before the track currently at queue index `toIndex` in the + * UPCOMING order", i.e. it changes `order`, not `queue`. */ export function reorderTrackById(id: number, toIndex: number): void { const from = indexOfTrack(id); @@ -540,6 +545,37 @@ export function reorderTrackById(id: number, toIndex: number): void { 0, Math.min(queue.length - 1, Math.floor(toIndex)), ); + + // Shuffle: reorder upcoming traversal schedule, not underlying queue. + if (shuffleEnabled) { + // Ensure traversal state exists + ensureTraversalStateForCurrent(); + + // Only reorder within the future schedule (`order`). + // If the dragged track isn't in `order`, it's either current, already played, or unscheduled. + const fromPos = order.indexOf(from); + if (fromPos === -1) { + queueNotify(); + return; + } + + // Determine insertion position relative to the target queue index within `order`. + // If target isn't currently in `order`, we clamp to the end of the future schedule. + let toPos = order.indexOf(clampedTo); + if (toPos === -1) toPos = order.length; + + // Moving an item forward past itself needs an index adjustment after removal. + const without = order.filter((i) => i !== from); + if (toPos > fromPos) toPos = Math.max(0, toPos - 1); + + const nextPos = Math.max(0, Math.min(without.length, toPos)); + order = [...without.slice(0, nextPos), from, ...without.slice(nextPos)]; + + queueNotify(); + return; + } + + // Linear: reorder the underlying queue if (from === clampedTo) return; moveIndex(from, clampedTo);