Files
SoulStream/handoff/rounds/ins-r37-reorder.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

280 lines
13 KiB
Plaintext

/* 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);
},
};
}