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

+61
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyDrag,
applyGrab,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
@@ -9,6 +10,9 @@ import {
dragScale,
DRAG_REF_PX,
DRAG_SCALE_MIN,
GRAB_FINE_SCALE,
GRAB_SLOP_PX,
isHandleGrab,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
@@ -194,6 +198,63 @@ describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
});
});
describe("isHandleGrab / applyGrab (§9 handle grab: no teleport)", () => {
// The 26h book on an 800px bar: the thumb sits at rect.left + X.
it("hits within the slop of the thumb and misses beyond it", () => {
const r = rect(800);
const thumbX = r.left + (duration26h / 2 / duration26h) * 800; // 500
expect(isHandleGrab(thumbX, r, duration26h / 2, duration26h)).toBe(true);
expect(isHandleGrab(thumbX + GRAB_SLOP_PX, r, duration26h / 2, duration26h)).toBe(true);
expect(isHandleGrab(thumbX + GRAB_SLOP_PX + 1, r, duration26h / 2, duration26h)).toBe(false);
expect(isHandleGrab(thumbX - GRAB_SLOP_PX, r, duration26h / 2, duration26h)).toBe(true);
// 1px ≈ 117s on this bar — a grab is a PIXEL test, not seconds.
expect(isHandleGrab(thumbX + 30, r, duration26h / 2, duration26h)).toBe(false);
});
it("clamps the thumb to the book edges", () => {
const r = rect(800);
// Past-the-end position: thumb paints at the right edge.
expect(isHandleGrab(900 + GRAB_SLOP_PX, r, duration26h + 500, duration26h)).toBe(true);
expect(isHandleGrab(100, r, -50, duration26h)).toBe(true);
});
it("is false on a dead bar or book", () => {
expect(isHandleGrab(150, rect(0), 42, duration26h)).toBe(false);
expect(isHandleGrab(150, rect(800), 42, 0)).toBe(false);
});
it("advances the position 1:1 with pointer travel", () => {
const pxPerSecond = 800 / duration26h;
expect(applyGrab(5000, 100, pxPerSecond, duration26h)).toBeCloseTo(
5000 + (100 / 800) * duration26h,
5,
);
// 20px ≈ 2345s: no clamp on either side of 5000.
expect(applyGrab(5000, -20, pxPerSecond, duration26h)).toBeCloseTo(
5000 - (20 / 800) * duration26h,
5,
);
});
it("crawls at fine scale without teleporting when Shift toggles mid-drag", () => {
const pxPerSecond = 800 / duration26h;
expect(applyGrab(5000, 100, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBeCloseTo(
5000 + 100 * ((1 / pxPerSecond) * GRAB_FINE_SCALE),
5,
);
// Scale changes only affect SUBSEQUENT travel — a zero-delta move
// with a new scale must leave the position alone.
expect(applyGrab(5000, 0, pxPerSecond, duration26h, GRAB_FINE_SCALE)).toBe(5000);
});
it("clamps to the book and survives a dead bar", () => {
const pxPerSecond = 800 / duration26h;
expect(applyGrab(duration26h, 100, pxPerSecond, duration26h)).toBe(duration26h);
expect(applyGrab(1, -100, pxPerSecond, duration26h)).toBe(0);
expect(applyGrab(50, 100, 0, duration26h)).toBe(50);
});
});
describe("touch rail feedback (span shrink + lift)", () => {
it("starts at the full ±5-min span and shrinks with the zoom", () => {
expect(touchRailSpan(0)).toBe(RAIL_SPAN);