fix(audio): grabbing the playhead thumb no longer teleports it

A pointerdown on the scrub bar unconditionally mapped the click X to a
position, so grabbing the handle itself made the playhead jump — worst
with Shift, whose branch force-snapped to the nearest chapter line
before the drag even started. The user's complaint: the handle click
flowed through to the track-click behavior behind it.

The bar now hit-tests the thumb first (GRAB_SLOP_PX, a pixel test like
the chapter magnetism — a seconds threshold goes sub-pixel on long
books). A grab enters a new "grab" drag mode: the position stays where
it is until the pointer moves, then advances RELATIVELY via the new
applyGrab helper (incremental like the touch cursor, fixed scale — the
mouse is always on the bar, so Shift = quarter-speed fine crawl instead
of a pull-away zoom).

Track clicks keep today's behavior exactly: click = jump with chapter
magnetism, Shift+click = nearest chapter line, Shift+drag = precision
rail, touch = incremental pull-away. MOUSE_GUIDE gains the two grab
rows so the dialog documents the gesture; new pure helpers are unit-
tested alongside the existing scrub math.
This commit is contained in:
John O'Keefe committed 2026-10-27 14:21:13 -04:00
1 parent c71ef4af85
commit 79f4d668f1
4 files changed
+153 -7

No files matched your search

+44
View File
@@ -73,6 +73,50 @@ export function snapToChapterPx(
return { seconds, snapped: false };
}
// ---- §9 handle grab: the thumb is not part of the track ----
// GRAB_SLOP_PX is how close a pointerdown must land to the playhead
// thumb, in bar pixels, for it to count as grabbing the HANDLE rather
// than clicking the track. A grab never teleports the position — not
// to the click X (free-drag behavior) and not to a chapter line
// (Shift+click behavior) — it drags RELATIVE to where the handle is.
export const GRAB_SLOP_PX = 12;
// isHandleGrab reports whether a pointerdown at clientX lands on the
// playhead thumb (within slopPx) instead of the bare track.
export function isHandleGrab(
clientX: number,
rect: DOMRect,
position: number,
duration: number,
slopPx = GRAB_SLOP_PX,
): boolean {
if (rect.width <= 0 || duration <= 0) return false;
const clamped = Math.min(duration, Math.max(0, position));
const thumbX = rect.left + (clamped / duration) * rect.width;
return Math.abs(clientX - thumbX) <= slopPx;
}
// GRAB_FINE_SCALE is the Shift+grab travel factor: the same relative
// drag crawls at quarter speed for precise placement.
export const GRAB_FINE_SCALE = 0.25;
// applyGrab advances a HANDLE drag by one pointermove: incremental
// (like applyDrag, a scale change never teleports the cursor), but
// with a FIXED scale instead of a pull-away zoom — the mouse is always
// on the bar, so Shift is the precision control, not vertical offset.
export function applyGrab(
position: number,
dxPx: number,
pxPerSecond: number,
duration: number,
scale = 1,
): 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) * scale));
}
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
// The scale factor decays an order of magnitude for every DRAG_REF_PX of