feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)

Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
This commit is contained in:
John O'Keefe committed 2026-10-22 13:05:10 -04:00
1 parent 1f30602c23
commit 96fd2aad42
6 files changed
+386 -69

No files matched your search

+96 -20
View File
@@ -1,9 +1,9 @@
// Scrubber math (player plan §1 — progress bar overhaul). Pure and
// DOM-free except for passing rects in; the Alpine component owns the
// element, the pointer events and the state. Everything here is
// unit-tested — the snap threshold, the buffer→global mapping and the
// precision-rail mapping are exactly the parts that must hold for an
// 86-hour, 1189-track book AND a 2-hour single-file m4b.
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
// passing rects in; the Alpine component owns the element, the pointer
// events and the state. Everything here is unit-tested — the pixel
// magnetism, the pull-away drag scaling and the precision-rail mapping
// are exactly the parts that must hold for an 86-hour, 1189-track book
// AND a 26-hour single-file m4b.
export interface ScrubChapter {
start: number;
@@ -29,26 +29,102 @@ export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): numb
return found;
}
// snapToChapter returns the chapter start when the position falls
// within thresholdSeconds of one (click-to-snap), otherwise the
// position unchanged.
export function snapToChapter(
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
// in bar pixels, for it to take over the landing position.
export const MAGNET_RADIUS_PX = 10;
// nearestChapterLine finds the chapter start closest to a position and
// reports its distance in bar pixels. Empty chapters → null.
export function nearestChapterLine(
seconds: number,
chapters: ScrubChapter[],
thresholdSeconds = 5,
): { seconds: number; snapped: boolean } {
let best: { seconds: number; snapped: boolean } = { seconds, snapped: false };
let bestDistance = thresholdSeconds;
for (const ch of chapters) {
const distance = Math.abs(ch.start - seconds);
if (distance <= bestDistance) {
bestDistance = distance;
best = { seconds: ch.start, snapped: true };
}
duration: number,
widthPx: number,
): { index: number; seconds: number; distancePx: number } | null {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
const pxPerSecond = widthPx / duration;
let best: { index: number; seconds: number; distancePx: number } | null = null;
for (let i = 0; i < chapters.length; i += 1) {
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
if (best === null || distancePx < best.distancePx)
best = { index: i, seconds: chapters[i].start, distancePx };
}
return best;
}
// snapToChapterPx applies the magnet: within radiusPx of a line the
// position lands ON the line; force (Shift+click) extends the pull to
// the nearest line regardless of distance. Otherwise unchanged.
export function snapToChapterPx(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
radiusPx = MAGNET_RADIUS_PX,
force = false,
): { seconds: number; snapped: boolean } {
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
if (near === null) return { seconds, snapped: false };
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
return { seconds, snapped: false };
}
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
// away, ~0.01× further. Exponential keeps the zoom continuous — any
// precision level is one gesture (plan §9 option C).
export const DRAG_REF_PX = 80;
export const DRAG_SCALE_MIN = 0.001;
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
if (refPx <= 0) return 1;
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
}
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
// horizontal travel (px) converts at the bar's scale, then shrinks by
// the current vertical offset. Incremental, not absolute — changing the
// scale mid-drag only affects subsequent travel, so pulling away never
// teleports the position. Clamped to the book.
export function applyDrag(
position: number,
dxPx: number,
dyPx: number,
pxPerSecond: number,
duration: number,
refPx: number = DRAG_REF_PX,
): number {
const clamped = Math.min(duration, Math.max(0, position));
if (pxPerSecond <= 0 || duration <= 0) return clamped;
return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * dragScale(dyPx, refPx)));
}
// Touch feedback thresholds: the rail appears once the finger is at
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
export const TOUCH_RAIL_MIN_DY_PX = 12;
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
// keeps the edge labels readable.
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
}
// railLiftPx converts the vertical offset into the rail's translateY:
// toward the finger (negative = up), capped so the rail never chases
// the finger off the player.
export function railLiftPx(dyPx: number): number {
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
}
// denseTicks decides which chapter starts get a rendered tick: a
// chapter earns a mark only when it is at least minGapPx from the last
// kept one — an 1189-chapter book would otherwise paint the bar solid.