drag drop

This commit is contained in:
2026-02-06 05:23:07 -08:00
parent f8b5f12812
commit 03be6760cc
2 changed files with 174 additions and 23 deletions

View File

@@ -25,6 +25,7 @@
nowPlayingLabel,
prev,
removeTrack,
reorderTrackById,
schedulePersistNow,
setUiOpen,
setVolume,
@@ -274,6 +275,87 @@
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.
let dragId = $state<number | null>(null);
let dragOverId = $state<number | null>(null);
function onDragStart(trackId: number, e: DragEvent) {
dragId = trackId;
dragOverId = null;
// Best-effort: set payload (useful for some browsers)
try {
e.dataTransfer?.setData("text/plain", String(trackId));
e.dataTransfer!.effectAllowed = "move";
} catch {
// ignore
}
}
function onDragOver(trackId: number, e: DragEvent) {
// Required to allow dropping
e.preventDefault();
dragOverId = trackId;
try {
e.dataTransfer!.dropEffect = "move";
} catch {
// ignore
}
}
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");
const parsed = raw ? Number(raw) : NaN;
if (Number.isFinite(parsed)) sourceId = parsed;
}
if (sourceId == null) {
dragId = null;
dragOverId = null;
return;
}
if (sourceId === targetTrackId) {
dragId = null;
dragOverId = null;
return;
}
// Reorder by queue indices
const fromIndex = snap.queue.findIndex((t) => t.id === sourceId);
const toIndex = snap.queue.findIndex((t) => t.id === targetTrackId);
if (fromIndex === -1 || toIndex === -1) {
dragId = null;
dragOverId = null;
return;
}
reorderTrackById(sourceId, toIndex);
dragId = null;
dragOverId = null;
}
function onDragEnd() {
dragId = null;
dragOverId = null;
}
function draggableHint(trackId: number) {
if (dragId == null) return "";
if (dragId === trackId) return "opacity-70";
if (dragOverId === trackId) return "ring-2 ring-primary/40";
return "";
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds);
@@ -699,7 +781,17 @@
<ul class="rounded border">
{#each queueDisplay as item (item.track.id)}
<li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
class={[
"flex items-center gap-2 border-b px-2 py-2 last:border-b-0",
draggableHint(item.track.id),
]
.filter(Boolean)
.join(" ")}
draggable="true"
ondragstart={(e) => onDragStart(item.track.id, e)}
ondragover={(e) => onDragOver(item.track.id, e)}
ondrop={(e) => onDrop(item.track.id, e)}
ondragend={onDragEnd}
>
<button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline"
@@ -976,7 +1068,17 @@
<ul class="rounded border">
{#each queueDisplay as item (item.track.id)}
<li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
class={[
"flex items-center gap-2 border-b px-2 py-2 last:border-b-0",
draggableHint(item.track.id),
]
.filter(Boolean)
.join(" ")}
draggable="true"
ondragstart={(e) => onDragStart(item.track.id, e)}
ondragover={(e) => onDragOver(item.track.id, e)}
ondrop={(e) => onDrop(item.track.id, e)}
ondragend={onDragEnd}
>
<button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline"

View File

@@ -24,7 +24,7 @@ import type { Track } from "./types";
* should subscribe via `subscribe()` and drive any side-effects (like persistence).
*/
export type InsertMode = "play" | "playNext" | "add";
export type InsertMode = "play" | "playNext" | "add" | "jump";
export type PlayerSnapshot = {
queue: Track[];
@@ -136,11 +136,13 @@ let cursor = $state<number>(persisted?.cursor ?? 0);
let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));
let uiOpen = $state<boolean>(
persisted?.uiOpen ??
// Defaults per agreement:
// - mobile: closed
// - desktop: open
!isMobileLike(),
// Default based on the current viewport:
// - mobile: closed
// - desktop: open
//
// Note: we intentionally do NOT default from persisted `uiOpen`, so the UI
// always follows the current device/viewport expectation.
!isMobileLike(),
);
const currentTrack = $derived<Track | null>(
@@ -341,28 +343,28 @@ function applyCurrentIndex(next: number | null) {
if (!shuffleEnabled) return;
// Ensure traversal exists and is anchored at *this* currentIndex.
// This is important for jump-to-index behavior in shuffle mode.
ensureTraversalStateForCurrent();
// If we navigated forward within history, just advance cursor.
if (cursor < history.length && history[cursor] !== next) {
const existingPos = history.indexOf(next);
if (existingPos !== -1) {
cursor = existingPos;
} else {
// New visit: append and advance cursor.
history = [...history, next];
cursor = history.length - 1;
}
// If we jumped to an index that's not reflected at the current cursor,
// align cursor/history so that prev/next work relative to the jumped item.
// Strategy:
// - if next is already somewhere in history, move cursor there
// - otherwise, append it and set cursor to the end
const existingPos = history.indexOf(next);
if (existingPos !== -1) {
cursor = existingPos;
} else if (history[cursor] !== next) {
history = [...history, next];
cursor = history.length - 1;
}
// Consume from order if we used its head
if (order[0] === next) order = order.slice(1);
// Remove from future order if it appears later (avoid revisiting within cycle)
// If this index was scheduled in the future order, remove it so we don't revisit.
order = order.filter((i) => i !== next);
// If it was at the head, that's implicitly consumed as well.
if (order[0] === next) order = order.slice(1);
}
/** --- Public traversal actions --- */
@@ -517,20 +519,67 @@ export function removeTrack(id: number): void {
queueNotify();
}
/**
* 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.
*
* Notes:
* - This only reorders if the track exists in the queue.
* - `toIndex` is clamped to the valid range.
*/
export function reorderTrackById(id: number, toIndex: number): void {
const from = indexOfTrack(id);
if (from === -1) return;
const clampedTo = Math.max(
0,
Math.min(queue.length - 1, Math.floor(toIndex)),
);
if (from === clampedTo) return;
moveIndex(from, clampedTo);
queueNotify();
}
/**
* Core insertion behavior per your rules.
*
* - "jump": jump to an existing queued track (does not reorder)
* - "play": move/insert to right-after-current and then skip to it
* - "playNext": move/insert to right-after-current but don't skip
* - "add": append (deduped)
*
* Dedupe semantics:
* - If exists, we MOVE it instead of duplicating.
* - If exists, we MOVE it instead of duplicating (except "jump", which never moves).
*/
export function insertTrack(track: Track, mode: InsertMode): void {
// Normalize + basic guard
if (!track || !Number.isFinite(track.id) || !track.src) return;
// Clicking an already-queued song should MOVE PLAYHEAD to that queue position,
// not reshuffle the queue around the current track.
if (mode === "jump") {
const i = indexOfTrack(track.id);
if (i === -1) return;
applyCurrentIndex(i);
// In shuffle mode, make sure the future order is still valid relative to the
// new cursor position (i.e., "next" should come from order after this jump).
// We rebuild the remaining order while preserving already-played history.
if (shuffleEnabled) {
rebuildShuffleOrderPreservingPast();
}
queueNotify();
return;
}
// If the user hits "Play" / "Play next" on the *currently playing* track,
// treat it as a no-op. This avoids trying to move the current track to
// "right after itself" and then skipping, which can produce confusing