android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to the top on a row's sheet, which is fine but is not what a finger is for. Press and hold a row for 320ms now and it lifts, the rows below make room, and letting go lands it — on the playlist page and on the player sheet's queue, through one action in src/mobile/reorder.js. The Move rows stay: they are the accessible path round 28 named to a screen reader, and they are the only way to reorder while the filter is on. Release a press without having moved it and the row's sheet opens exactly as a long press always opened it, which is what keeps the queue's sheet reachable at all — a queue row has no ⋯, so `oncontextmenu` is its only door to Play now, Move to next and Remove from queue. Most of the work is in not fighting the page. Nothing carries `touch-action: none`, because a flick that starts on a row is how the list is scrolled and `touch-action` is latched at touchstart anyway; the scroll is refused instead by a non-passive `touchmove` listener added at the lift, which works precisely because the finger has been still for 320ms and the browser has not started one. A press that moves more than 8px before then is a scroll and the gesture lets go of it. Click and contextmenu guards sit on the container in the capture phase while a lift is live, or a released drag would fire the row's own click and play the track. Rows are measured once at the lift in the scroller's content space, so the edge pull — within 64px of either edge — leaves the snapshot true, and each displaced row moves by the exact difference between its top and its neighbour's rather than by one assumed row height, because the queue face puts album separators between rows. No new mutation and no new command: `moveItem` and `moveInQueue` each take a numeric target now as well as "up" / "down" / "top", so there is still exactly one call to `playlist_set_order` carrying the whole id order, and the existing queue guards were already the right guards. The drop is optimistic and snaps back with the error toast if the backend says no. Two refusals cover the middle of a gesture — the playlist drops nothing if a refetch has replaced its rows, and the queue drops nothing if `player.qi` has moved — and dragging is off entirely while the playlist's filter has anything in it, because a filtered list's visible order is not the stored order. Both walks drive it with real pointer events and assert the order out of the store: a drag, a drag back, a refusal under the filter, a press that still opens the sheet, and a drop the backend is armed to reject.
This commit is contained in:
1 parent
defd8c5e6d
commit
1258b2b78d
9 files changed
+556
-24
No files matched your search
@@ -380,7 +380,7 @@
|
||||
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
|
||||
{#if trackArg.queueIndex > player.qi + 1}
|
||||
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))}
|
||||
{@render row("chevron-up", "Move up", "", act(() => moveInQueue(trackArg.queueIndex, "up")))}
|
||||
{@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveInQueue(trackArg.queueIndex, "up")))}
|
||||
{/if}
|
||||
{#if trackArg.queueIndex < player.queue.length - 1}
|
||||
{@render row("chevron-down", "Move down", "", act(() => moveInQueue(trackArg.queueIndex, "down")))}
|
||||
@@ -521,7 +521,7 @@
|
||||
{#if rows.length > 1}
|
||||
{@const at = rows.findIndex((r) => r.id === item.id)}
|
||||
<div class="sheet-hair"></div>
|
||||
{#if at > 0}{@render row("chevron-up", "Move up", "", act(() => moveItem(p, rows, item, "up").catch(oops)))}{/if}
|
||||
{#if at > 0}{@render row("chevron-up", "Move up", "or press and hold a row to drag it", act(() => moveItem(p, rows, item, "up").catch(oops)))}{/if}
|
||||
{#if at < rows.length - 1}{@render row("chevron-down", "Move down", "", act(() => moveItem(p, rows, item, "down").catch(oops)))}{/if}
|
||||
{#if at > 1}{@render row("skip-back", "Move to the top", "", act(() => moveItem(p, rows, item, "top").catch(oops)))}{/if}
|
||||
{/if}
|
||||
|
||||
+25
-2
@@ -13,10 +13,11 @@
|
||||
import Lane from "./Lane.svelte";
|
||||
import Cover from "./Cover.svelte";
|
||||
import { art } from "./art.svelte.js";
|
||||
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
|
||||
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat, moveInQueue } from "./player.svelte.js";
|
||||
import { nav, closeSheet, morphSource, openHalf, openViewer, push } from "./nav.svelte.js";
|
||||
import { lyr, loadLyrics } from "./lyrics.svelte.js";
|
||||
import { mmss, drag } from "./util.js";
|
||||
import { reorder } from "./reorder.js";
|
||||
|
||||
let frameEl = $state(null);
|
||||
let faceEl = $state(null);
|
||||
@@ -144,6 +145,28 @@
|
||||
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }
|
||||
|
||||
/* ── the queue, dragged ───────────────────────────────────────────
|
||||
The same gesture the playlist page has (./reorder.js). Press and
|
||||
release without moving and the row's sheet opens exactly as the long
|
||||
press always opened it — which matters more here than anywhere: a
|
||||
queue row has no ⋯, so that sheet is the only door to Play now, Move
|
||||
to next and Remove from queue. */
|
||||
let qiAt = -1;
|
||||
function queueLift() { qiAt = player.qi; }
|
||||
function queueDrop(from, to) {
|
||||
/* a track boundary crossed mid-drag moves the whole rendered
|
||||
window, and these are absolute indices: they would address the
|
||||
wrong rows. Drop nothing rather than the wrong thing. */
|
||||
if (player.qi !== qiAt) return;
|
||||
moveInQueue(qiAt + 1 + from, qiAt + 1 + to);
|
||||
}
|
||||
function queueHold(k) {
|
||||
const i = player.qi + 1 + k;
|
||||
const e = player.queue[i];
|
||||
if (e) openHalf("track", { album: e.album, track: e.t, queueIndex: i });
|
||||
}
|
||||
const queueDrag = { enabled: true, rowSel: ".row", scrollSel: ".np-face", onLift: queueLift, onDrop: queueDrop, onHold: queueHold };
|
||||
</script>
|
||||
|
||||
<div class="sheet" class:up={shown} class:morphing use:drag={sheetDrag}
|
||||
@@ -217,7 +240,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="q-face">
|
||||
<div class="q-face" use:reorder={queueDrag}>
|
||||
<div class="sec" style="padding-top:8px">
|
||||
<h2>Up next <em>· from {player.from || player.album.title}</em></h2>
|
||||
<button class="all" class:on={player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={14} /></button>
|
||||
|
||||
@@ -416,9 +416,11 @@ export function removeFromQueue(i) {
|
||||
armPreload();
|
||||
writeSession();
|
||||
}
|
||||
/** An upcoming row one place up or down, never past the current track. */
|
||||
/** An upcoming row one place up or down — or to an absolute index, which
|
||||
* is what a drop on the queue face hands it — never past the current
|
||||
* track. The guards were already the right guards for either. */
|
||||
export function moveInQueue(i, dir) {
|
||||
const j = i + (dir === "up" ? -1 : 1);
|
||||
const j = typeof dir === "number" ? dir : i + (dir === "up" ? -1 : 1);
|
||||
if (i <= player.qi || j <= player.qi || i >= player.queue.length || j >= player.queue.length) return;
|
||||
const [row] = player.queue.splice(i, 1);
|
||||
player.queue.splice(j, 0, row);
|
||||
|
||||
@@ -21,6 +21,9 @@ export const pls = $state({
|
||||
covers: {},
|
||||
/** the playlist page that is open: { id, items } — the bar's sheet reads it */
|
||||
current: null,
|
||||
/** true while a row is in hand on the playlist page: the page's own
|
||||
* re-read waits for the drop rather than replacing the rows under it */
|
||||
dragging: false,
|
||||
/** the last removal: { playlistId, rows: [{ item, position }], label } */
|
||||
undo: null,
|
||||
});
|
||||
@@ -225,13 +228,14 @@ export async function exportPlaylist(p, format) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── reorder: a row up, down or to the top; the complete id order goes
|
||||
in one call, as the desktop's drop does ── */
|
||||
/* ── reorder: a row up, down, to the top, or to whatever index a drag
|
||||
dropped it at; the complete id order goes in one call either way, as the
|
||||
desktop's drop does ── */
|
||||
export async function moveItem(p, items, item, where) {
|
||||
const ids = items.map((i) => i.id);
|
||||
const from = ids.indexOf(item.id);
|
||||
if (from < 0) return false;
|
||||
const to = where === "top" ? 0 : where === "up" ? from - 1 : from + 1;
|
||||
const to = typeof where === "number" ? Math.max(0, Math.min(ids.length - 1, where)) : where === "top" ? 0 : where === "up" ? from - 1 : from + 1;
|
||||
if (to < 0 || to >= ids.length || to === from) return false;
|
||||
ids.splice(from, 1);
|
||||
ids.splice(to, 0, item.id);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/* Drag to reorder — the playlist page's rows and the player sheet's queue,
|
||||
one gesture for both. A phone should drag better than a desktop does:
|
||||
the desktop has a grip and a mouse (src/views/Playlist.svelte), the phone
|
||||
has a finger that is also how the list is scrolled, so most of this file
|
||||
is about telling those two apart and then getting out of the scroller's
|
||||
way.
|
||||
|
||||
The contract, and it is strictly additive:
|
||||
|
||||
hold 320ms the row lifts (and the phone taps back)
|
||||
move the rows below make room; let go and it lands
|
||||
release, unmoved the row's sheet opens, exactly as it always did
|
||||
|
||||
That last line is load-bearing. A queue row has no ⋯ — `oncontextmenu`
|
||||
is its only door to Play now, Move to next and Remove from queue — so a
|
||||
long press that merely started a drag would strand that sheet.
|
||||
|
||||
A Svelte action on the CONTAINER, in util.js's `drag` idiom: armed on
|
||||
pointerdown, live only once the hold has landed, every pointer capture
|
||||
in a try/catch (a synthetic pointer id is not capturable, and that must
|
||||
not be fatal — it is what the walk drives this with).
|
||||
|
||||
use:reorder={{ enabled, rowSel, skipSel, scrollSel,
|
||||
onLift(from), onDrop(from, to), onHold(index), onEnd(dropped) }}
|
||||
|
||||
`enabled` may be a value or a FUNCTION, and the playlist passes a
|
||||
function on purpose: an action's `update` is deprecated in Svelte 5, so
|
||||
an option object that has to change identity to be heard is one this
|
||||
cannot lean on. Asked at the press instead, the answer is always the
|
||||
current one.
|
||||
*/
|
||||
|
||||
/** ms of stillness before a press becomes a lift: past a tap, and short of
|
||||
* Android's own 500ms contextmenu, so the gesture is ours by then */
|
||||
const HOLD = 320;
|
||||
/** px of movement before that: the list is being scrolled, let go of it */
|
||||
const SLOP = 8;
|
||||
/** px from the scroller's edge that pulls the list along */
|
||||
const EDGE = 64;
|
||||
/** px per frame at the very edge */
|
||||
const MAXSTEP = 14;
|
||||
|
||||
export function reorder(el, opts) {
|
||||
let o = opts;
|
||||
let armed = null; /* { id, x, y, row } — pressed, the hold not yet landed */
|
||||
let live = null; /* the lift: the rows, their rects, the slot we are over */
|
||||
let holdTimer = 0;
|
||||
/* a released drag would otherwise fire the row's own click and play the
|
||||
track, and Android's long press would open the row's sheet in the
|
||||
middle of one. Both are swallowed at the container in the CAPTURE
|
||||
phase — which beats the row's handler and Svelte's delegated root
|
||||
listener alike — and only while a lift is live. */
|
||||
let suppressUntil = 0;
|
||||
|
||||
const contentY = (clientY) => (live.scroller ? clientY - live.box.top + live.scroller.scrollTop : clientY);
|
||||
/** asked at the press, at the lift and at the drop, never cached */
|
||||
const ok = () => { const v = o && o.enabled; return typeof v === "function" ? !!v() : !!v; };
|
||||
|
||||
function attach() {
|
||||
/* on the window, not the row: the gesture has to survive a capture
|
||||
that never happened */
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
}
|
||||
function detach() {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
}
|
||||
/* `touch-action` is latched at touchstart and cannot be flipped
|
||||
mid-gesture, and touch-action: none on a row would cost the list the
|
||||
flick that scrolls it — most of the page. So the scroll is refused
|
||||
here instead: after 320ms of stillness the browser has not started
|
||||
one yet, and eating the first touchmove after the lift cancels it.
|
||||
Registered imperatively because it must be NON-passive; Svelte
|
||||
registers touch handlers passively, and a passive listener may not
|
||||
preventDefault. */
|
||||
const eatTouch = (e) => { if (live && e.cancelable) e.preventDefault(); };
|
||||
const guard = (e) => {
|
||||
if (Date.now() > suppressUntil) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
function down(e) {
|
||||
if (live || armed || !ok()) return;
|
||||
if (e.button > 0) return;
|
||||
const t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
if (o.skipSel && t.closest(o.skipSel)) return; /* the row's own ⋯ */
|
||||
const row = t.closest(o.rowSel);
|
||||
if (!row || !el.contains(row)) return;
|
||||
armed = { id: e.pointerId, x: e.clientX, y: e.clientY, row };
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = setTimeout(lift, HOLD);
|
||||
attach();
|
||||
}
|
||||
|
||||
function lift() {
|
||||
holdTimer = 0;
|
||||
if (!armed || live || !ok()) { if (armed) { armed = null; detach(); } return; }
|
||||
const a = armed;
|
||||
const rows = [].slice.call(el.querySelectorAll(o.rowSel));
|
||||
const from = rows.indexOf(a.row);
|
||||
if (from < 0 || rows.length < 2) { armed = null; detach(); return; }
|
||||
const scroller = o.scrollSel ? el.closest(o.scrollSel) : null;
|
||||
const box = scroller ? scroller.getBoundingClientRect() : { top: 0 };
|
||||
const top0 = scroller ? scroller.scrollTop : 0;
|
||||
/* every row measured once, in the scroller's CONTENT space. That is
|
||||
what makes the edge pull work: as scrollTop grows the snapshot
|
||||
stays true and the delta grows on its own, so the row keeps up
|
||||
with the finger. Measured rather than one assumed row height —
|
||||
the desktop's `rowH` — because the queue face puts an album
|
||||
separator between rows. */
|
||||
const snap = rows.map((r) => {
|
||||
const b = r.getBoundingClientRect();
|
||||
const top = b.top - box.top + top0;
|
||||
return { el: r, top, mid: top + b.height / 2 };
|
||||
});
|
||||
live = {
|
||||
id: a.id, row: a.row, from, to: from, snap, scroller, box,
|
||||
grabY: a.y - box.top + top0, y: a.y, y0: a.y, x0: a.x,
|
||||
moved: false, raf: 0, shift: snap.map(() => 0),
|
||||
};
|
||||
armed = null;
|
||||
el.classList.add("dragging");
|
||||
a.row.classList.add("lifted");
|
||||
try { a.row.setPointerCapture(a.id); } catch { /* synthetic ids are not capturable */ }
|
||||
window.addEventListener("touchmove", eatTouch, { passive: false });
|
||||
el.addEventListener("click", guard, true);
|
||||
el.addEventListener("contextmenu", guard, true);
|
||||
suppressUntil = Date.now() + 400;
|
||||
try { if (navigator.vibrate) navigator.vibrate(8); } catch { /* no motor, or none allowed */ }
|
||||
if (o.onLift) o.onLift(from);
|
||||
paint();
|
||||
}
|
||||
|
||||
function paint() {
|
||||
if (!live) return;
|
||||
live.raf = 0;
|
||||
const s = live.scroller;
|
||||
if (s) {
|
||||
const b = s.getBoundingClientRect();
|
||||
live.box = b;
|
||||
/* the edges pull: near the top or the bottom of the scroller the
|
||||
list comes to the finger, rather than the finger having to
|
||||
leave the screen to reach the end of a long playlist */
|
||||
const above = live.y - b.top;
|
||||
const below = b.bottom - live.y;
|
||||
let step = 0;
|
||||
if (above < EDGE) step = -Math.ceil(((EDGE - Math.max(0, above)) / EDGE) * MAXSTEP);
|
||||
else if (below < EDGE) step = Math.ceil(((EDGE - Math.max(0, below)) / EDGE) * MAXSTEP);
|
||||
if (step) {
|
||||
const was = s.scrollTop;
|
||||
s.scrollTop = Math.max(0, Math.min(s.scrollHeight - s.clientHeight, was + step));
|
||||
/* the list actually moved: keep the frames coming even while
|
||||
the finger is still */
|
||||
if (s.scrollTop !== was) live.raf = requestAnimationFrame(paint);
|
||||
}
|
||||
}
|
||||
const dy = contentY(live.y) - live.grabY;
|
||||
live.row.style.transform = "translateY(" + Math.round(dy) + "px)";
|
||||
/* where the lifted row's own middle now sits decides the slot */
|
||||
const mid = live.snap[live.from].mid + dy;
|
||||
let to = live.from;
|
||||
for (let i = 0; i < live.snap.length; i++) {
|
||||
if (i === live.from) continue;
|
||||
if (i < live.from && mid < live.snap[i].mid) to = Math.min(to, i);
|
||||
else if (i > live.from && mid > live.snap[i].mid) to = Math.max(to, i);
|
||||
}
|
||||
/* each displaced row takes its neighbour's slot — the exact
|
||||
difference of their measured tops, not a row height. Only the
|
||||
rows whose shift CHANGED are written to, so a frame that moves
|
||||
nothing costs nothing. */
|
||||
for (let i = 0; i < live.snap.length; i++) {
|
||||
if (i === live.from) continue;
|
||||
let sh = 0;
|
||||
if (to > live.from && i > live.from && i <= to) sh = live.snap[i - 1].top - live.snap[i].top;
|
||||
else if (to < live.from && i >= to && i < live.from) sh = live.snap[i + 1].top - live.snap[i].top;
|
||||
if (sh !== live.shift[i]) {
|
||||
live.shift[i] = sh;
|
||||
live.snap[i].el.style.transform = sh ? "translateY(" + Math.round(sh) + "px)" : "";
|
||||
}
|
||||
}
|
||||
live.to = to;
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
if (live) {
|
||||
if (e.pointerId !== live.id) return;
|
||||
live.y = e.clientY;
|
||||
/* the same slop the arming uses, not a hair's breadth: a finger
|
||||
held still for 320ms still wanders a few px, and a press
|
||||
"released without moving" has to open the row's sheet the way
|
||||
it always did — on the queue there is no other door to it. */
|
||||
if (Math.abs(e.clientY - live.y0) > SLOP || Math.abs(e.clientX - live.x0) > SLOP) live.moved = true;
|
||||
suppressUntil = Date.now() + 400;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
/* one frame, however many moves arrive in it */
|
||||
if (!live.raf) live.raf = requestAnimationFrame(paint);
|
||||
return;
|
||||
}
|
||||
if (!armed || e.pointerId !== armed.id) return;
|
||||
if (Math.abs(e.clientY - armed.y) > SLOP || Math.abs(e.clientX - armed.x) > SLOP) {
|
||||
/* it moved before the hold landed: that was a scroll */
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = 0;
|
||||
armed = null;
|
||||
detach();
|
||||
}
|
||||
}
|
||||
|
||||
function finish(e) {
|
||||
if (e && live && e.pointerId !== live.id) return;
|
||||
if (e && !live && armed && e.pointerId !== armed.id) return;
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = 0;
|
||||
detach();
|
||||
armed = null;
|
||||
const was = live;
|
||||
live = null;
|
||||
if (!was) return;
|
||||
if (was.raf) cancelAnimationFrame(was.raf);
|
||||
window.removeEventListener("touchmove", eatTouch);
|
||||
/* every mark cleared BEFORE the mutation. The playlist is keyed by
|
||||
id so a drop MOVES nodes; the queue is keyed by index so it
|
||||
re-fills them — either way an inline transform left on one is a
|
||||
bug you can see. */
|
||||
el.classList.remove("dragging");
|
||||
was.row.classList.remove("lifted");
|
||||
was.row.style.transform = "";
|
||||
for (let i = 0; i < was.snap.length; i++) was.snap[i].el.style.transform = "";
|
||||
try { was.row.releasePointerCapture(was.id); } catch { /* never captured */ }
|
||||
suppressUntil = Date.now() + 350;
|
||||
setTimeout(unguard, 420);
|
||||
/* asked once more here: a filter typed with a finger still down
|
||||
would make the landing index a lie about the saved order */
|
||||
/* a cancelled pointer is not a drop: Android takes the touch away
|
||||
when it decides the gesture is its own, and landing the row then
|
||||
would be a reorder nobody asked for. Unmoved it is still the long
|
||||
press it looks like, so the sheet opens either way. */
|
||||
const cancelled = !!e && e.type === "pointercancel";
|
||||
const dropped = !cancelled && was.to !== was.from && ok();
|
||||
if (dropped) { if (o.onDrop) o.onDrop(was.from, was.to); }
|
||||
else if (!was.moved) { if (o.onHold) o.onHold(was.from); }
|
||||
if (o.onEnd) o.onEnd(dropped);
|
||||
}
|
||||
function unguard() {
|
||||
if (live) return; /* another lift has taken the guards over */
|
||||
el.removeEventListener("click", guard, true);
|
||||
el.removeEventListener("contextmenu", guard, true);
|
||||
}
|
||||
|
||||
el.addEventListener("pointerdown", down);
|
||||
return {
|
||||
/* deprecated in Svelte 5 and not relied on — `enabled` is asked for
|
||||
through a function — but honoured if it ever does arrive */
|
||||
update(next) { o = next; },
|
||||
destroy() {
|
||||
clearTimeout(holdTimer);
|
||||
/* torn down with a row still in hand — a navigation that happened
|
||||
under the finger. onEnd is what releases the page's deferred
|
||||
re-read, so it has to be told, or the playlist would stop
|
||||
re-reading its rows for the rest of the session. */
|
||||
if (live) {
|
||||
if (live.raf) cancelAnimationFrame(live.raf);
|
||||
live = null;
|
||||
if (o && o.onEnd) { try { o.onEnd(false); } catch { /* the page is leaving anyway */ } }
|
||||
}
|
||||
armed = null;
|
||||
detach();
|
||||
window.removeEventListener("touchmove", eatTouch);
|
||||
el.removeEventListener("pointerdown", down);
|
||||
el.removeEventListener("click", guard, true);
|
||||
el.removeEventListener("contextmenu", guard, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,9 +13,10 @@
|
||||
import { lib } from "../lib.svelte.js";
|
||||
import { player } from "../player.svelte.js";
|
||||
import { openHalf } from "../nav.svelte.js";
|
||||
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick } from "../playlists.svelte.js";
|
||||
import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js";
|
||||
import { say } from "../trouble.js";
|
||||
import { mmss, longDur, stamp } from "../util.js";
|
||||
import { reorder } from "../reorder.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
@@ -32,6 +33,11 @@
|
||||
if (!p) return;
|
||||
void p.revision;
|
||||
void again;
|
||||
/* a refetch that lands mid-drag would yank the rows out from under
|
||||
the gesture; the desktop defers one the same way
|
||||
(skippedWhileDrag). Reading the flag is what makes this effect
|
||||
re-run by itself the moment the drag has finished. */
|
||||
if (pls.dragging) return;
|
||||
const g = ++gen;
|
||||
api.playlistItems(p.id).then(
|
||||
(r) => { if (g === gen) { items = r || []; loaded = true; loadErr = ""; } },
|
||||
@@ -65,6 +71,46 @@
|
||||
|
||||
function sheet(item) { openHalf("pltrack", { playlist: pl, items, item }); }
|
||||
function tap(item) { if (!rowClick(pl, items, item)) sheet(item); }
|
||||
|
||||
/* ── dragged into place ───────────────────────────────────────────
|
||||
Press and hold a row and it lifts, the rows below make room, and
|
||||
letting go lands it (../reorder.js). A press released without moving
|
||||
still opens the row's sheet, so nothing that worked yesterday stops
|
||||
working. The sheet's Move rows stay too: they are the accessible
|
||||
path, and the one that is still honest under a filter — a filtered
|
||||
list's visible order is NOT the stored order, so the drag itself is
|
||||
refused while the search has anything in it, the way the desktop
|
||||
refuses it ("reordering a partial view would be a lie about the
|
||||
saved sequence"), and the note under the field says so. */
|
||||
const canDrag = $derived(!q.trim() && items.length > 1);
|
||||
let dragFrom = null;
|
||||
function dragLift() { pls.dragging = true; dragFrom = items; }
|
||||
function dragHold(i) { const it = visible[i]; if (it) sheet(it); }
|
||||
/* a drop clears the flag when the write settles, not when the finger
|
||||
lifts: releasing it earlier would let the deferred re-read land the
|
||||
OLD order between the two */
|
||||
function dragEnd(dropped) { if (!dropped) pls.dragging = false; }
|
||||
function dragDrop(from, to) {
|
||||
/* a refetch that landed mid-drag replaced the rows under us: the
|
||||
indices the gesture collected address a list that is gone */
|
||||
if (items !== dragFrom || q.trim()) { pls.dragging = false; return; }
|
||||
const snap = items.slice();
|
||||
const next = snap.slice();
|
||||
const [row] = next.splice(from, 1);
|
||||
if (!row) { pls.dragging = false; return; }
|
||||
next.splice(to, 0, row);
|
||||
/* optimistic: the row is where you put it before the backend has
|
||||
been asked, and comes back if the answer is no */
|
||||
items = next;
|
||||
moveItem(pl, snap, row, to)
|
||||
.catch((e) => { items = snap; player.error = String(e); })
|
||||
.finally(() => { pls.dragging = false; });
|
||||
}
|
||||
/* `enabled` is a function, not a value: an action's `update` is
|
||||
deprecated in Svelte 5, so an options object that has to change
|
||||
identity to be heard is not something to lean on. This one is asked
|
||||
at the press, every press. */
|
||||
const rowDrag = { enabled: () => canDrag, rowSel: ".trk", skipSel: ".r", scrollSel: ".screen", onLift: dragLift, onDrop: dragDrop, onHold: dragHold, onEnd: dragEnd };
|
||||
</script>
|
||||
|
||||
{#if pl}
|
||||
@@ -108,11 +154,12 @@
|
||||
<input value={q} oninput={(e) => (q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" />
|
||||
{#if q}<button class="iconbtn sm" onclick={() => (q = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
|
||||
</div>
|
||||
{#if q && items.length > 1}<div class="pl-note">Clear the search to drag rows into place.</div>{/if}
|
||||
{/if}
|
||||
{#if loaded && items.length && !visible.length}
|
||||
<div class="stub"><b>Nothing here matches “{q}”.</b></div>
|
||||
{/if}
|
||||
<div class="tracks pl-rows">
|
||||
<div class="tracks pl-rows" use:reorder={rowDrag}>
|
||||
{#each visible as item, i (item.id)}
|
||||
{@const now = nowItemId != null && nowItemId === item.id}
|
||||
<button class="trk" class:playing={now} class:unresolved={item.kind === "unresolved"} class:missing={!!item.missing} onclick={() => tap(item)} oncontextmenu={(e) => { e.preventDefault(); sheet(item); }}>
|
||||
|
||||
@@ -1589,3 +1589,27 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
.setup-note .setup-rows .row + .row { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); }
|
||||
.setup-note .setup-rows .row .t { font-size: 13.5px; }
|
||||
.setup-note .setup-rows .row .m { font-size: 12px; }
|
||||
|
||||
/* ── dragged into place: the playlist's rows and the queue's ───────────
|
||||
Almost nothing, because both lists are already the app's own rows. No
|
||||
row carries `touch-action: none` — a flick that starts on one still
|
||||
scrolls the list; the gesture takes the scroll away for itself only
|
||||
after the 320ms lift, and in JS (src/mobile/reorder.js). The spring is
|
||||
the app's own spatial one, and the global reduce-motion rule above
|
||||
flattens it for nothing. */
|
||||
.tracks.dragging .trk,
|
||||
.q-face.dragging .row {
|
||||
transition: background var(--m-fast), transform var(--m-base);
|
||||
}
|
||||
/* the row in hand: off the spring, above its neighbours, lifted off the page */
|
||||
.tracks.dragging .trk.lifted,
|
||||
.q-face.dragging .row.lifted {
|
||||
transition: none;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
background: var(--hover);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
/* why the rows will not move while the filter has something in it */
|
||||
.pl-note { padding: 2px var(--pad) 8px; font-size: 12px; color: var(--text-muted); }
|
||||
Reference in new issue
Block a user