From 79f4d668f10b1aaf2312ddfa2476829dfae7b8cc Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 14:21:13 -0400 Subject: [PATCH] fix(audio): grabbing the playhead thumb no longer teleports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/audio-keys.ts | 5 +++ web/src/audio-scrubber.test.ts | 61 ++++++++++++++++++++++++++++++++++ web/src/audio-scrubber.ts | 44 ++++++++++++++++++++++++ web/src/audio.ts | 50 ++++++++++++++++++++++++---- 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/web/src/audio-keys.ts b/web/src/audio-keys.ts index 9b5ec8da..0b54b831 100644 --- a/web/src/audio-keys.ts +++ b/web/src/audio-keys.ts @@ -229,6 +229,11 @@ export interface PointerGuideRow { export const MOUSE_GUIDE: readonly PointerGuideRow[] = [ { label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" }, { label: "Drag", description: "Free scrub across the whole bar" }, + { + label: "Grab handle", + description: "Drag the playhead from where it is — nothing moves until you do", + }, + { label: "Shift + grab", description: "Same handle drag at quarter speed for fine placement" }, { label: "Shift + click", description: "Jump to the nearest chapter line" }, { label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" }, { label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" }, diff --git a/web/src/audio-scrubber.test.ts b/web/src/audio-scrubber.test.ts index 6dd7564a..3215c150 100644 --- a/web/src/audio-scrubber.test.ts +++ b/web/src/audio-scrubber.test.ts @@ -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); diff --git a/web/src/audio-scrubber.ts b/web/src/audio-scrubber.ts index 67fecf4b..bcedfe50 100644 --- a/web/src/audio-scrubber.ts +++ b/web/src/audio-scrubber.ts @@ -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 diff --git a/web/src/audio.ts b/web/src/audio.ts index b5a2fe76..40320e50 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -39,10 +39,13 @@ import { } from "./audio-sleep"; import { applyDrag, + applyGrab, bufferedAheadEnd, bufferedSpanPct, chapterIndexFor, denseTicks, + GRAB_FINE_SCALE, + isHandleGrab, MAGNET_RADIUS_PX, nearestChapterLine, railLiftPx, @@ -233,11 +236,18 @@ Alpine.data("audioReader", () => ({ railTickList: [] as Array<{ start: number; title: string }>, // §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" = // Shift+drag precision window, "touch" = incremental cursor with - // pull-away precision. The rail is only visible for the last two. - dragMode: null as null | "free" | "rail" | "touch", + // pull-away precision, "grab" = the pointerdown landed ON the + // playhead thumb — the position drags RELATIVE from where it is + // (never teleporting to the click X or a chapter line; Shift = + // quarter-speed fine crawl). The rail is only visible for the + // middle two. + dragMode: null as null | "free" | "rail" | "touch" | "grab", railVisible: false, railLiftY: 0, touchLastX: 0, + // Grab-drag anchor: the previous pointer X, so each move applies a + // delta to the CURRENT position instead of an absolute mapping. + grabLastX: 0, // Track manifest: one AudioTrack per playable file. Single-file books // (m4b/mp3) carry exactly one track whose URL is the whole file, so @@ -905,11 +915,13 @@ Alpine.data("audioReader", () => ({ // Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no // rail, never sticks at chapter lines); clicks land with pixel // magnetism on nearby chapter lines; Shift+click/drag opens the - // precision rail (±5 min, free mapping inside); touch drags are an - // incremental cursor whose scale tightens as the finger pulls off the - // bar (progressive precision — the rail is its feedback). The native - // range stays underneath for keyboard/aria — its drag default is - // prevented so the mappings never fight. + // precision rail (±5 min, free mapping inside); a pointerdown ON the + // playhead thumb is a GRAB instead — the position drags relative + // from where it is, teleporting nowhere (Shift = fine crawl); touch + // drags are an incremental cursor whose scale tightens as the finger + // pulls off the bar (progressive precision — the rail is its + // feedback). The native range stays underneath for keyboard/aria — + // its drag default is prevented so the mappings never fight. scrubPointerDown(e: PointerEvent) { if (e.button !== 0 || this.scrubbing) return; e.preventDefault(); @@ -930,6 +942,15 @@ Alpine.data("audioReader", () => ({ this.dragMode = "touch"; this.touchLastX = e.clientX; this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds; + } else if (isHandleGrab(e.clientX, rect, this.globalPosition(), duration)) { + // Grabbing the HANDLE itself: the playhead must not teleport — + // neither to the click X (free drag) nor to a chapter line + // (Shift+click). The thumb checks this BEFORE the Shift branch: + // the grab is relative from where the handle is, and Shift only + // slows the same drag to a fine crawl. + this.dragMode = "grab"; + this.grabLastX = e.clientX; + this.scrubValue = Math.min(duration, Math.max(0, this.globalPosition())); } else if (e.shiftKey) { // Shift+click/drag: the precision rail, anchored to the NEAREST // chapter line (Shift+click = snap regardless of radius). @@ -974,6 +995,21 @@ Alpine.data("audioReader", () => ({ }); return; } + if (this.dragMode === "grab") { + // Relative handle drag: each move advances from the CURRENT + // position by the pointer's delta (never re-maps the click X). + // Shift = quarter-speed fine placement. + const dx = e.clientX - this.grabLastX; + this.grabLastX = e.clientX; + this.scrubValue = applyGrab( + this.scrubValue, + dx, + rect.width / duration, + duration, + e.shiftKey ? GRAB_FINE_SCALE : 1, + ); + return; + } if (this.dragMode === "touch") { const dx = e.clientX - this.touchLastX; this.touchLastX = e.clientX;