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
+43
-7
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user