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:
1 parent
c71ef4af85
commit
79f4d668f1
4 files changed
+153
-7
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user