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.