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