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
@@ -229,6 +229,11 @@ export interface PointerGuideRow {
|
|||||||
export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
|
export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
|
||||||
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
|
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
|
||||||
{ label: "Drag", description: "Free scrub across the whole bar" },
|
{ 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 + click", description: "Jump to the nearest chapter line" },
|
||||||
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
||||||
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyDrag,
|
applyDrag,
|
||||||
|
applyGrab,
|
||||||
bufferedAheadEnd,
|
bufferedAheadEnd,
|
||||||
bufferedSpanPct,
|
bufferedSpanPct,
|
||||||
chapterIndexFor,
|
chapterIndexFor,
|
||||||
@@ -9,6 +10,9 @@ import {
|
|||||||
dragScale,
|
dragScale,
|
||||||
DRAG_REF_PX,
|
DRAG_REF_PX,
|
||||||
DRAG_SCALE_MIN,
|
DRAG_SCALE_MIN,
|
||||||
|
GRAB_FINE_SCALE,
|
||||||
|
GRAB_SLOP_PX,
|
||||||
|
isHandleGrab,
|
||||||
nearestChapterLine,
|
nearestChapterLine,
|
||||||
railLiftPx,
|
railLiftPx,
|
||||||
railSecondsAtPointer,
|
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)", () => {
|
describe("touch rail feedback (span shrink + lift)", () => {
|
||||||
it("starts at the full ±5-min span and shrinks with the zoom", () => {
|
it("starts at the full ±5-min span and shrinks with the zoom", () => {
|
||||||
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
|
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
|
||||||
|
|||||||
@@ -73,6 +73,50 @@ export function snapToChapterPx(
|
|||||||
return { seconds, snapped: false };
|
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 ----
|
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
|
||||||
|
|
||||||
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
|
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
|
||||||
|
|||||||
+43
-7
@@ -39,10 +39,13 @@ import {
|
|||||||
} from "./audio-sleep";
|
} from "./audio-sleep";
|
||||||
import {
|
import {
|
||||||
applyDrag,
|
applyDrag,
|
||||||
|
applyGrab,
|
||||||
bufferedAheadEnd,
|
bufferedAheadEnd,
|
||||||
bufferedSpanPct,
|
bufferedSpanPct,
|
||||||
chapterIndexFor,
|
chapterIndexFor,
|
||||||
denseTicks,
|
denseTicks,
|
||||||
|
GRAB_FINE_SCALE,
|
||||||
|
isHandleGrab,
|
||||||
MAGNET_RADIUS_PX,
|
MAGNET_RADIUS_PX,
|
||||||
nearestChapterLine,
|
nearestChapterLine,
|
||||||
railLiftPx,
|
railLiftPx,
|
||||||
@@ -233,11 +236,18 @@ Alpine.data("audioReader", () => ({
|
|||||||
railTickList: [] as Array<{ start: number; title: string }>,
|
railTickList: [] as Array<{ start: number; title: string }>,
|
||||||
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
||||||
// Shift+drag precision window, "touch" = incremental cursor with
|
// Shift+drag precision window, "touch" = incremental cursor with
|
||||||
// pull-away precision. The rail is only visible for the last two.
|
// pull-away precision, "grab" = the pointerdown landed ON the
|
||||||
dragMode: null as null | "free" | "rail" | "touch",
|
// 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,
|
railVisible: false,
|
||||||
railLiftY: 0,
|
railLiftY: 0,
|
||||||
touchLastX: 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
|
// Track manifest: one AudioTrack per playable file. Single-file books
|
||||||
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
|
// (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
|
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
|
||||||
// rail, never sticks at chapter lines); clicks land with pixel
|
// rail, never sticks at chapter lines); clicks land with pixel
|
||||||
// magnetism on nearby chapter lines; Shift+click/drag opens the
|
// magnetism on nearby chapter lines; Shift+click/drag opens the
|
||||||
// precision rail (±5 min, free mapping inside); touch drags are an
|
// precision rail (±5 min, free mapping inside); a pointerdown ON the
|
||||||
// incremental cursor whose scale tightens as the finger pulls off the
|
// playhead thumb is a GRAB instead — the position drags relative
|
||||||
// bar (progressive precision — the rail is its feedback). The native
|
// from where it is, teleporting nowhere (Shift = fine crawl); touch
|
||||||
// range stays underneath for keyboard/aria — its drag default is
|
// drags are an incremental cursor whose scale tightens as the finger
|
||||||
// prevented so the mappings never fight.
|
// 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) {
|
scrubPointerDown(e: PointerEvent) {
|
||||||
if (e.button !== 0 || this.scrubbing) return;
|
if (e.button !== 0 || this.scrubbing) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -930,6 +942,15 @@ Alpine.data("audioReader", () => ({
|
|||||||
this.dragMode = "touch";
|
this.dragMode = "touch";
|
||||||
this.touchLastX = e.clientX;
|
this.touchLastX = e.clientX;
|
||||||
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
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) {
|
} else if (e.shiftKey) {
|
||||||
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
||||||
// chapter line (Shift+click = snap regardless of radius).
|
// chapter line (Shift+click = snap regardless of radius).
|
||||||
@@ -974,6 +995,21 @@ Alpine.data("audioReader", () => ({
|
|||||||
});
|
});
|
||||||
return;
|
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") {
|
if (this.dragMode === "touch") {
|
||||||
const dx = e.clientX - this.touchLastX;
|
const dx = e.clientX - this.touchLastX;
|
||||||
this.touchLastX = e.clientX;
|
this.touchLastX = e.clientX;
|
||||||
|
|||||||
Reference in new issue
Block a user