drag drop
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user