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, nowPlayingLabel,
prev, prev,
removeTrack, removeTrack,
reorderTrackById,
schedulePersistNow, schedulePersistNow,
setUiOpen, setUiOpen,
setVolume, setVolume,
@@ -274,6 +275,87 @@
let clearQueueDialogOpen = $state(false); 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) { function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds); const s = Math.floor(seconds);
@@ -699,7 +781,17 @@
<ul class="rounded border"> <ul class="rounded border">
{#each queueDisplay as item (item.track.id)} {#each queueDisplay as item (item.track.id)}
<li <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 <button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline" class="min-w-0 flex-1 truncate text-left text-sm hover:underline"
@@ -976,7 +1068,17 @@
<ul class="rounded border"> <ul class="rounded border">
{#each queueDisplay as item (item.track.id)} {#each queueDisplay as item (item.track.id)}
<li <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 <button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline" 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). * 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 = { export type PlayerSnapshot = {
queue: Track[]; queue: Track[];
@@ -136,11 +136,13 @@ let cursor = $state<number>(persisted?.cursor ?? 0);
let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME)); let volume = $state<number>(clamp01(persisted?.volume ?? DEFAULT_VOLUME));
let uiOpen = $state<boolean>( let uiOpen = $state<boolean>(
persisted?.uiOpen ?? // Default based on the current viewport:
// Defaults per agreement: // - mobile: closed
// - mobile: closed // - desktop: open
// - desktop: open //
!isMobileLike(), // 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>( const currentTrack = $derived<Track | null>(
@@ -341,28 +343,28 @@ function applyCurrentIndex(next: number | null) {
if (!shuffleEnabled) return; if (!shuffleEnabled) return;
// Ensure traversal exists and is anchored at *this* currentIndex.
// This is important for jump-to-index behavior in shuffle mode.
ensureTraversalStateForCurrent(); ensureTraversalStateForCurrent();
// If we navigated forward within history, just advance cursor. // If we jumped to an index that's not reflected at the current cursor,
if (cursor < history.length && history[cursor] !== next) { // align cursor/history so that prev/next work relative to the jumped item.
const existingPos = history.indexOf(next); // Strategy:
if (existingPos !== -1) { // - if next is already somewhere in history, move cursor there
cursor = existingPos; // - otherwise, append it and set cursor to the end
} else { const existingPos = history.indexOf(next);
// New visit: append and advance cursor. if (existingPos !== -1) {
history = [...history, next]; cursor = existingPos;
cursor = history.length - 1;
}
} else if (history[cursor] !== next) { } else if (history[cursor] !== next) {
history = [...history, next]; history = [...history, next];
cursor = history.length - 1; cursor = history.length - 1;
} }
// Consume from order if we used its head // If this index was scheduled in the future order, remove it so we don't revisit.
if (order[0] === next) order = order.slice(1);
// Remove from future order if it appears later (avoid revisiting within cycle)
order = order.filter((i) => i !== next); 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 --- */ /** --- Public traversal actions --- */
@@ -517,20 +519,67 @@ export function removeTrack(id: number): void {
queueNotify(); 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. * 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 * - "play": move/insert to right-after-current and then skip to it
* - "playNext": move/insert to right-after-current but don't skip * - "playNext": move/insert to right-after-current but don't skip
* - "add": append (deduped) * - "add": append (deduped)
* *
* Dedupe semantics: * 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 { export function insertTrack(track: Track, mode: InsertMode): void {
// Normalize + basic guard // Normalize + basic guard
if (!track || !Number.isFinite(track.id) || !track.src) return; 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, // 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 // 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 // "right after itself" and then skipping, which can produce confusing