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.
280 lines
13 KiB
Plaintext
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);
|
|
},
|
|
};
|
|
}
|