feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with window from/to edge labels); clicks land with pixel magnetism (MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on long books and never fired, which was the 'chapter scrub isn't accurate' bug. Hover inside the radius locks the tooltip to the line with a highlight. Shift+click jumps to the nearest line from anywhere. Touch: drag is an incremental cursor (applyDrag) whose scale tightens continuously as the finger pulls off the bar (dragScale, exponential 10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the zoom (touchRailSpan) and lifts toward the finger (railLiftPx). Dialog retitled 'Shortcuts & interactions' with a Mouse & touch section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold snapToChapter deleted. 133 unit tests; 19 Playwright checks on the KJV (1189ch) and Gardens (34ch) books.
This commit is contained in:
1 parent
1f30602c23
commit
96fd2aad42
6 files changed
+386
-69
No files matched your search
+101
-24
@@ -15,7 +15,7 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { renderNoteHtml } from "./markdown";
|
||||
import { showToast } from "./toast";
|
||||
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
import { POINTER_GUIDE, resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
@@ -29,15 +29,21 @@ import {
|
||||
type SleepState,
|
||||
} from "./audio-sleep";
|
||||
import {
|
||||
applyDrag,
|
||||
bufferedAheadEnd,
|
||||
bufferedSpanPct,
|
||||
chapterIndexFor,
|
||||
denseTicks,
|
||||
MAGNET_RADIUS_PX,
|
||||
nearestChapterLine,
|
||||
railLiftPx,
|
||||
railSecondsAtPointer,
|
||||
railTicks,
|
||||
railWindow,
|
||||
secondsAtPointer,
|
||||
snapToChapter,
|
||||
snapToChapterPx,
|
||||
TOUCH_RAIL_MIN_DY_PX,
|
||||
touchRailSpan,
|
||||
} from "./audio-scrubber";
|
||||
|
||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||
@@ -155,6 +161,8 @@ Alpine.data("audioReader", () => ({
|
||||
// Exposed for the templ dropdown's x-for — templ cannot import the
|
||||
// const directly; this property is the binding.
|
||||
speeds: SPEEDS,
|
||||
// Same binding pattern, for the dialog's Mouse & touch section.
|
||||
pointerGuide: POINTER_GUIDE,
|
||||
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
|
||||
// Scrub guard: while dragging, timeupdate must not fight the handle
|
||||
scrubbing: false,
|
||||
@@ -167,9 +175,19 @@ Alpine.data("audioReader", () => ({
|
||||
bufferedGlobal: null as number | null,
|
||||
hoverSeconds: null as number | null,
|
||||
hoverPct: 0,
|
||||
// §9: hover locks to a chapter line inside the magnet radius (the
|
||||
// highlight line + tooltip land where the click will).
|
||||
hoverSnapped: false,
|
||||
railFrom: 0,
|
||||
railTo: 0,
|
||||
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",
|
||||
railVisible: false,
|
||||
railLiftY: 0,
|
||||
touchLastX: 0,
|
||||
|
||||
// Track manifest: one AudioTrack per playable file. Single-file books
|
||||
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
|
||||
@@ -702,13 +720,16 @@ Alpine.data("audioReader", () => ({
|
||||
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
|
||||
},
|
||||
|
||||
// Click/drag on the bar: the FIRST press maps hours-per-pixel and
|
||||
// snaps to a chapter line within ~5s; while held, the precision rail
|
||||
// takes over at minutes-per-pixel (re-grab to travel far). The
|
||||
// native range stays underneath for keyboard/aria — its drag default
|
||||
// is prevented so the two mappings never fight.
|
||||
// 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.
|
||||
scrubPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.button !== 0 || this.scrubbing) return;
|
||||
e.preventDefault();
|
||||
const area = e.currentTarget as HTMLElement;
|
||||
const rect = area.getBoundingClientRect();
|
||||
@@ -720,13 +741,32 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
|
||||
const duration = this.chaptersDuration() || 1;
|
||||
const at = snapToChapter(secondsAtPointer(e.clientX, rect, duration), this.chapters, 5).seconds;
|
||||
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||
if (e.pointerType === "touch") {
|
||||
// Tap lands magnetically; the drag starts 1:1 and tightens as
|
||||
// the finger pulls away (scrubPointerMove's incremental cursor).
|
||||
this.dragMode = "touch";
|
||||
this.touchLastX = e.clientX;
|
||||
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||
} else if (e.shiftKey) {
|
||||
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
||||
// chapter line (Shift+click = snap regardless of radius).
|
||||
this.dragMode = "rail";
|
||||
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||
this.scrubValue = near ? near.seconds : at;
|
||||
const window = railWindow(this.scrubValue, duration);
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
this.railVisible = true;
|
||||
this.railLiftY = 0;
|
||||
} else {
|
||||
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
|
||||
// free-flow (scrubPointerMove overwrites with the raw position).
|
||||
this.dragMode = "free";
|
||||
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||
}
|
||||
this.scrubbing = true;
|
||||
this.scrubValue = at;
|
||||
const window = railWindow(at, duration);
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
},
|
||||
|
||||
scrubPointerMove(e: PointerEvent) {
|
||||
@@ -734,21 +774,54 @@ Alpine.data("audioReader", () => ({
|
||||
const rect = area.getBoundingClientRect();
|
||||
const duration = this.chaptersDuration() || 1;
|
||||
if (!this.scrubbing) {
|
||||
this.hoverSeconds = secondsAtPointer(e.clientX, rect, duration);
|
||||
this.hoverPct =
|
||||
rect.width > 0
|
||||
? Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100))
|
||||
: 0;
|
||||
// Hover: time + chapter, locking to a chapter line inside the
|
||||
// magnet radius so the tooltip shows the click's true landing.
|
||||
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
|
||||
const hoverAt = locked && near ? near.seconds : at;
|
||||
this.hoverSeconds = hoverAt;
|
||||
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
|
||||
this.hoverSnapped = locked;
|
||||
return;
|
||||
}
|
||||
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
|
||||
from: this.railFrom,
|
||||
to: this.railTo,
|
||||
});
|
||||
if (this.dragMode === "rail") {
|
||||
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
|
||||
from: this.railFrom,
|
||||
to: this.railTo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.dragMode === "touch") {
|
||||
const dx = e.clientX - this.touchLastX;
|
||||
this.touchLastX = e.clientX;
|
||||
const dy = rect.top + rect.height / 2 - e.clientY;
|
||||
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
|
||||
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
|
||||
// Feedback: the rail follows the cursor, its window shrinking
|
||||
// with the zoom, lifting toward the finger.
|
||||
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
this.railVisible = true;
|
||||
this.railLiftY = railLiftPx(dy);
|
||||
} else {
|
||||
this.railVisible = false;
|
||||
this.railLiftY = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Free flow: direct 1:1 mapping — dragging across chapter lines
|
||||
// never sticks.
|
||||
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
|
||||
},
|
||||
|
||||
scrubPointerLeave() {
|
||||
if (!this.scrubbing) this.hoverSeconds = null;
|
||||
if (!this.scrubbing) {
|
||||
this.hoverSeconds = null;
|
||||
this.hoverSnapped = false;
|
||||
}
|
||||
},
|
||||
|
||||
scrubPointerUp(e: PointerEvent) {
|
||||
@@ -758,7 +831,11 @@ Alpine.data("audioReader", () => ({
|
||||
area.releasePointerCapture(e.pointerId);
|
||||
this.seekTo(this.scrubValue);
|
||||
this.scrubbing = false;
|
||||
this.dragMode = null;
|
||||
this.railVisible = false;
|
||||
this.railLiftY = 0;
|
||||
this.hoverSeconds = null;
|
||||
this.hoverSnapped = false;
|
||||
},
|
||||
|
||||
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
|
||||
|
||||
Reference in new issue
Block a user