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

+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 () => {