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:
KeelrLeek committed 2026-09-14 03:24:26 -05:00
1 parent defd8c5e6d
commit 1258b2b78d
9 files changed
+556 -24

No files matched your search

+58 -12
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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>
+4 -2
View File
@@ -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);
+7 -3
View File
@@ -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);
+279
View File
@@ -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);
},
};
}
+49 -2
View File
@@ -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); }}>
+24
View File
@@ -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); }
+108 -1
View File
@@ -48,6 +48,46 @@ window.__click = function (sel, text) {
}
throw new Error('nothing to click for ' + sel + (text ? ' ~ "' + text + '"' : ''));
};
/* a drag, as the events a finger sends: press, hold past the action's own
320ms, walk down (or up) to just past the target row's middle, let go.
Both rows are measured BEFORE the press, because the action takes its
own snapshot at the lift and that is the geometry it will compare
against. Returns false when the list is too short to drag — a real
library is not obliged to hold three rows — so a thin walk says so in
its expectation rather than dying here. cdp.evaluate awaits promises. */
window.__drag = function (sel, from, to, holdMs) {
var rows = document.querySelectorAll(sel);
var a = rows[from], b = rows[to];
if (rows.length < 2 || !a || !b) return false;
var ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
var x = Math.round(ra.left + Math.min(40, ra.width / 2));
var y0 = Math.round(ra.top + ra.height / 2);
var y1 = Math.round(rb.top + rb.height / 2 + (to > from ? 4 : -4));
function ev(type, cy) {
a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 7, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: cy }));
}
var nap = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
return (async function () {
ev('pointerdown', y0);
await nap(holdMs || 420);
for (var i = 1; i <= 8; i++) { ev('pointermove', Math.round(y0 + (y1 - y0) * (i / 8))); await nap(24); }
await nap(60);
ev('pointerup', y1);
return true;
})();
};
/* the other half of the same gesture: pressed and released without moving,
which is still the row's sheet */
window.__hold = function (sel, i, holdMs) {
var a = document.querySelectorAll(sel)[i || 0];
if (!a) throw new Error('no row to hold for ' + sel);
var r = a.getBoundingClientRect();
var x = Math.round(r.left + Math.min(40, r.width / 2)), y = Math.round(r.top + r.height / 2);
function ev(type) {
a.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 8, pointerType: 'touch', isPrimary: true, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: y }));
}
return new Promise(function (res) { ev('pointerdown'); setTimeout(function () { ev('pointerup'); res(true); }, holdMs || 420); });
};
window.__settle = function () {
/* put the app back at Home with nothing open, whatever it was doing */
var s = document.querySelector('.sheet.up');
@@ -188,6 +228,12 @@ const PROBE = `(function () {
wifiOnly: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
firstBadge: (document.querySelector('.grid2 .release .ed-badge') || {}).textContent || '',
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
/* the ORDER, read from the stores rather than off the screen: after
a drop the page re-reads its rows, so this is what the backend
kept, not what the drag painted */
plOrder: (function () { try { return __ss.pls.pls.current.items.map(function (x) { return x.title; }).join('|'); } catch (e) { return ''; } })(),
qOrder: (function () { try { return __ss.player.queue.slice(__ss.player.qi + 1, __ss.player.qi + 9).map(function (x) { return x.t.title; }).join('|'); } catch (e) { return ''; } })(),
plNote: (document.querySelector('.pl-note') || {}).textContent || '',
imp: document.querySelectorAll('.imp-rows .trk').length,
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
@@ -425,6 +471,11 @@ const MOCK_STEPS = [
/* the second upcoming row moved up */
{ do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", wait: 600 },
/* and dragged: the first upcoming row two places down, then back up.
The order is read off player.queue, not off the rows */
{ name: "queue-drag-arm", wait: 300 },
{ do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 900 },
{ do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 900 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
@@ -646,6 +697,20 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Move up')`, name: "playlist-moved-up", wait: 900 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Move down')`, name: "playlist-moved-down", wait: 900 },
/* and dragged: the fourth row lifted and dropped at the top, dragged
back, refused while the filter is on, still opening the sheet when a
press is released without moving, and — with the backend armed to
say no — snapping back and saying so */
{ do: `__drag('.pl-rows .trk', 3, 0)`, name: "playlist-dragged", wait: 1400 },
{ do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-dragged-back", wait: 1400 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 },
{ do: `__drag('.pl-rows .trk', 0, 3)`, name: "playlist-drag-filtered", wait: 1000 },
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 500 },
{ do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `__ssMock.fail('playlist_set_order', 'database is locked')`, wait: 200 },
{ do: `__drag('.pl-rows .trk', 0, 2)`, name: "playlist-drag-refused", wait: 1400 },
{ do: `(function () { __ssMock.fail('playlist_set_order', ''); __ss.player.error = ''; return true; })()`, wait: 400 },
/* the bulk actions from the page's sheet: Add to library files the
streamed row (its cache is complete); Download all finds it cached too */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
@@ -1165,6 +1230,20 @@ const MOCK_EXPECT = {
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
/* the order after a drop, from the store the page publishes — which is
what the backend kept, the page having re-read its rows by now */
"playlist-dragged": (i) => i.plOrder === "Track 4|Track 1|Track 2|Track 3|One More Time|ID" && i.plfirst.startsWith("Track 4"),
"playlist-dragged-back": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plfirst.startsWith("Track 1"),
/* a filtered list's visible order is not the stored one, so the drag is
refused outright and the note under the field says why */
"playlist-drag-filtered": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && i.plrows === 4 && /Clear the search/.test(i.plNote),
/* pressed and released without moving: still the row's sheet */
"playlist-drag-held": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
/* the optimistic order snapped back, and the app said so */
"playlist-drag-refused": (i) => i.plOrder === "Track 1|Track 2|Track 3|Track 4|One More Time|ID" && !!i.toast,
"queue-drag-arm": (i) => { globalThis.__q0 = i.qOrder; return i.qrows >= 3 && !!i.qOrder; },
"queue-dragged": (i) => { const a = String(globalThis.__q0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); },
"queue-dragged-back": (i) => !!i.qOrder && i.qOrder === globalThis.__q0,
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
@@ -1280,6 +1359,11 @@ const PHONE_STEPS = [
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 1200 },
{ do: `history.back()`, name: "viewer-closed", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
/* and the queue, on the real one: a row down two and back up. This
writes the session key and re-arms the preload, nothing else */
{ name: "queue-drag-arm", wait: 400 },
{ do: `__drag('.q-face .row', 0, 2)`, name: "queue-dragged", wait: 1000 },
{ do: `__drag('.q-face .row', 2, 0)`, name: "queue-dragged-back", wait: 1000 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
@@ -1326,6 +1410,12 @@ const PHONE_STEPS = [
{ do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 },
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
/* dragged on the real core: the second row to the top and straight
back, so the playlist is exactly as it was when it is deleted four
steps from here. Nothing outside the walk's own playlist is touched */
{ name: "playlist-drag-arm", wait: 400 },
{ do: `__drag('.pl-rows .trk', 1, 0)`, name: "playlist-dragged", wait: 2000 },
{ do: `__drag('.pl-rows .trk', 0, 1)`, name: "playlist-dragged-back", wait: 2000 },
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
/* export, on the real core: the file is built by the desktop's own
writers and handed to Android's create-document sheet. That sheet is
@@ -1511,6 +1601,19 @@ const PHONE_EXPECT = {
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
"playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast,
"playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,
/* the drags, tolerant of a real library the walk did not choose: a
one-track record makes a one-row playlist, and a one-row list has
nowhere to drag to. Anything longer is asserted exactly, against the
order stashed one step earlier — and the real core may answer faster
than the mock does, so every one of these reads settled state. The
tolerance stops at the rows: once there are enough of them the stash
must be there too, or a probe that reads nothing would pass silently */
"playlist-drag-arm": (i) => { globalThis.__pl0 = i.plOrder; return i.plrows >= 1 && !!i.plOrder; },
"playlist-dragged": (i) => { if (i.plrows < 2) return true; const a = String(globalThis.__pl0 || "").split("|"); if (a.length < 2) return false; const [x] = a.splice(1, 1); a.unshift(x); return i.plOrder === a.join("|"); },
"playlist-dragged-back": (i) => i.plrows < 2 || (!!i.plOrder && i.plOrder === globalThis.__pl0),
"queue-drag-arm": (i) => { globalThis.__pq0 = i.qOrder; return i.qrows < 3 || !!i.qOrder; },
"queue-dragged": (i) => { if (i.qrows < 3) return true; const a = String(globalThis.__pq0 || "").split("|"); if (a.length < 3) return false; const [x] = a.splice(0, 1); a.splice(2, 0, x); return i.qOrder === a.join("|"); },
"queue-dragged-back": (i) => i.qrows < 3 || (!!i.qOrder && i.qOrder === globalThis.__pq0),
"playlist-playing": (i) => i.mini && !!i.playing && !i.toast,
"playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast,
@@ -1722,7 +1825,11 @@ async function main() {
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
};
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
cdp.on["Log.entryAdded"] = (p) => {
if (p.entry.level !== "error") return;
if (/navigator\.vibrate/.test(p.entry.text || "")) return;
problems.push("LOG " + p.entry.text + " " + (p.entry.url || ""));
};
/* after a reload the WebView may present a NEW page target: the old
socket then never answers. Attach again, keeping the handlers. */
const reattach = async () => {