diff --git a/ANDROID.md b/ANDROID.md
index 9a3dd21..ef29dd5 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -441,12 +441,54 @@ describe / pin / duplicate / delete, `playlist_add_items`,
a file (the desktop's repair panel as a sheet: the library searched by
the row's name, a pick relinks the row through `playlist_relink_item`;
an unknown placeholder adopts the track's identity), Go to artist,
- Remove from playlist; Move up, Move down and Move to the top reorder
- without a drag (`playlist_set_order`, the whole order in one call, as the
- desktop's drop does).
+ Remove from playlist; Move up, Move down and Move to the top reorder from
+ the sheet (`playlist_set_order`, the whole order in one call, as the
+ desktop's drop does) — the accessible path, and the one that stays honest
+ while the filter is on.
A removal is undoable for seven seconds from the neutral toast
(`notice.svelte.js` — the desktop's undo pill); the stash pins the
playlist it came from.
+- **Drag to reorder** (`src/mobile/reorder.js` — one Svelte action, on the
+ container, for the playlist page's rows and the player sheet's queue
+ alike). Press and hold a row for 320ms and it lifts (with a short tap in
+ the phone's motor), the rows below make room, and letting go lands it.
+ Release without having moved and the row's sheet opens exactly as a long
+ press always opened it — which matters most on the queue, where a row has
+ no ⋯ and `oncontextmenu` is the only door to Play now, Move to next and
+ Remove from queue. A press that moves more than 8px before the hold lands
+ is a scroll, and the gesture lets go of it.
+ Nothing carries `touch-action: none`: a flick that starts on a row still
+ scrolls the list. `touch-action` is latched at touchstart and cannot be
+ flipped mid-gesture, so the page's own scroll is refused instead by a
+ non-passive `touchmove` listener added at the lift — which works because
+ the finger has been still for 320ms and the browser has not started a
+ scroll yet. A click and a contextmenu guard sit on the container in the
+ CAPTURE phase while a lift is live (a released drag would otherwise fire
+ the row's own click and play the track, and Android's own 500ms long
+ press would open the sheet in the middle of the drag); both are off when
+ no lift is live, so a tap and a long press behave as they always have.
+ Every row is measured once at the lift, in the scroller's CONTENT space,
+ so the edge pull — within 64px of either edge, ramped, up to 14px a
+ frame — leaves the snapshot true and the row keeps up with the finger.
+ Each displaced row moves by the exact difference between its own top and
+ its neighbour's rather than by one assumed row height (the desktop's
+ `rowH`), because the queue face puts an album separator between rows.
+ The drop reuses the mutations the Move rows already use: `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` with the whole id order. It is optimistic — the rows
+ go where you put them at once, and come back with the error toast if the
+ backend says no. Two refusals guard the middle of a gesture: the playlist
+ drops nothing if a refetch has replaced its rows (`pls.dragging` also
+ defers the page's own re-read while a drag is live, the phone's version
+ of the desktop's `skippedWhileDrag`), and the queue drops nothing if
+ `player.qi` has moved, because a track boundary shifts the whole rendered
+ window and the absolute indices would then address the wrong rows.
+ Dragging is off while the playlist's filter has anything in it — a
+ filtered list's visible order is not the stored order — and the note
+ under the field says so; the sheet's Move rows address rows by id and
+ stay correct under a filter, which is why they are the way to reorder
+ without clearing it.
- **Add to library / Download all tracks** on the page's sheet — the
desktop's bulk actions (`runBulk` in `playlists.svelte.js`, the rules
verbatim): the first files every already-streamed track whose cache is
@@ -522,7 +564,7 @@ describe / pin / duplicate / delete, `playlist_add_items`,
itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the listener door (it needs
-yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
+yt-dlp, a desktop tool), and Share on SoulStream.
## The first run
@@ -1146,14 +1188,18 @@ a catalog record, its Save and the Transfers burst (and the shade's notice:
posted with a count while rows are in flight, gone when they are not) →
Gaps → the playlist
shelf and a page with every row kind (a local row, the stream row through
-the resolver, the filter, a removal and its undo, unpin, rename, a new
-playlist, Add to playlist from a record's sheet, delete) → the import doors
+the resolver, the filter, a removal and its undo, a row dragged into place
+and dragged back, a drag refused while the filter is on, a press released
+without moving still opening the row's sheet, a drop the backend rejects
+snapping back, unpin, rename, a new playlist, Add to playlist from a
+record's sheet, delete) → the import doors
(a pasted tracklist, a link with its catalog verification, pasted track
links read a page each, a folder of audio files off the phone, a playlist
file through the phone's own document picker — with that picker cancelled
and a file it can hand no path for refused — and a share from another
app) → the playlist repair (an unknown row relinked from the
-library) → the artwork viewer → a queue row's sheet → a catalog song's,
+library) → the artwork viewer → a queue row's sheet and a queue row
+dragged into place → a catalog song's,
a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
of theirs streamed → a buddy's sheet → You (every switch and chip saved
@@ -1190,7 +1236,7 @@ list, a null page), `offline(on)` takes the client down — everything the
backend funnels through `with_client()` answers "not connected to Soulseek",
the two connects answer the server string, and `soulseek:state` goes out — and
`clear()` puts all of it back. They reject with a plain string because that is
-what Tauri rejects with. Twelve mock steps drive them, and the phone walk carries
+what Tauri rejects with. Fourteen mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). A mock step can
also set the
@@ -1259,10 +1305,10 @@ symptom.
- **Off by design — external runtimes.** yt-dlp and deno, the vocal
separator, the lyric alignment models, and the listener door that needs
them.
-- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
- stand in), catalog track previews, the full discography page, and the
- fused-artist portrait verdict — that last one needs the identity merge the
- phone does not run, so porting it means porting the merge first.
+- **Still desktop-only, smaller.** Catalog track previews, the full
+ discography page, and the fused-artist portrait verdict — that last one
+ needs the identity merge the phone does not run, so porting it means
+ porting the merge first.
- **Per library folder.** Follows and the play history live in the library
folder, so the phone's own folder starts empty of both; they arrive with a
shared library.
diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte
index e2da71f..6611b33 100644
--- a/src/mobile/HalfSheet.svelte
+++ b/src/mobile/HalfSheet.svelte
@@ -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)}
- {#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}
diff --git a/src/mobile/Sheet.svelte b/src/mobile/Sheet.svelte
index 853e12b..8c36d20 100644
--- a/src/mobile/Sheet.svelte
+++ b/src/mobile/Sheet.svelte
@@ -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 };
{:else}
-
+
Up next · from {player.from || player.album.title}
diff --git a/src/mobile/player.svelte.js b/src/mobile/player.svelte.js
index 72a1010..10cfe05 100644
--- a/src/mobile/player.svelte.js
+++ b/src/mobile/player.svelte.js
@@ -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);
diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js
index 8a3ab8c..61acf39 100644
--- a/src/mobile/playlists.svelte.js
+++ b/src/mobile/playlists.svelte.js
@@ -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);
diff --git a/src/mobile/reorder.js b/src/mobile/reorder.js
new file mode 100644
index 0000000..8f5c1a8
--- /dev/null
+++ b/src/mobile/reorder.js
@@ -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);
+ },
+ };
+}
diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte
index 52af637..c3561c2 100644
--- a/src/mobile/screens/Playlist.svelte
+++ b/src/mobile/screens/Playlist.svelte
@@ -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 };
{#if pl}
@@ -108,11 +154,12 @@
(q = e.currentTarget.value)} type="search" placeholder="Search in playlist" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search in playlist" />
{#if q}{/if}