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:
John O'Keefe committed 2026-10-22 13:05:10 -04:00
1 parent 1f30602c23
commit 96fd2aad42
6 files changed
+386 -69

No files matched your search

+20
View File
@@ -213,6 +213,26 @@ export function visibleKeymap(): KeyActionSpec[] {
return KEYMAP.filter((spec) => !spec.hidden);
}
// Pointer/wheel/touch interactions (plan §9) are not key events — the
// dispatcher never sees them — but they belong in the same dialog so
// every scrub gesture is discoverable in one place. templ x-fors this
// table through the component's `pointerGuide` property (same binding
// pattern as speeds/skipChoices).
export interface PointerGuideRow {
// Badge label, e.g. "Shift + drag".
label: string;
description: string;
}
export const POINTER_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: "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)" },
{ label: "Touch + pull away", description: "Scrub, then drag off the bar for fine control" },
];
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
// when nothing claims it. Single characters match case-insensitively;
// Alt-qualified bindings take precedence order for free: a plain key
+137 -14
View File
@@ -1,16 +1,25 @@
import { describe, expect, it } from "vitest";
import {
applyDrag,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
dragScale,
DRAG_REF_PX,
DRAG_SCALE_MIN,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
railTicks,
railWindow,
RAIL_SPAN,
secondsAtPointer,
snapToChapter,
snapToChapterPx,
TOUCH_RAIL_LIFT_MAX_PX,
TOUCH_RAIL_MIN_DY_PX,
touchRailSpan,
} from "./audio-scrubber";
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
@@ -66,28 +75,142 @@ describe("chapterIndexFor", () => {
});
});
describe("snapToChapter", () => {
it("snaps within the threshold and not beyond it", () => {
const start = chapters1189[10].start;
expect(snapToChapter(start + 3, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
expect(snapToChapter(start + 4.9, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
expect(snapToChapter(start + 5.1, chapters1189, 5).snapped).toBe(false);
expect(snapToChapter(start - 5, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
// 34-chapter book on an 800px bar: ~23.5px between lines — the
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
// is correct for a book that dense.)
const pxSec = 800 / duration26h; // bar px per second
it("reports the closest line and its distance in bar pixels", () => {
const start = chapters34[5].start;
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
expect(near!.index).toBe(5);
expect(near!.seconds).toBe(start);
expect(near!.distancePx).toBeCloseTo(5, 5);
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
});
it("picks the NEAREST line when two are in range", () => {
it("finds sub-pixel lines on a book as dense as the KJV", () => {
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
// radius always can. This is the §9 root-cause fix.
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
expect(dense).not.toBeNull();
expect(dense!.distancePx).toBeLessThan(0.25);
});
it("snaps within the pixel radius and not beyond it", () => {
const start = chapters34[5].start;
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
});
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Chapter gap (~2759s) means both lines sit ~1380s from the
// midpoint — the threshold must reach past it.
const result = snapToChapter(mid + 1, chapters34, 1500);
// Radius 0 + force must land where a wide radius lands: distance
// is ignored, nearest wins (mid+1 → the later line).
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: b,
snapped: true,
});
// Past the book's last line, the nearest line is still reachable.
const last = chapters34[33].start;
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: last,
snapped: true,
});
});
it("picks the NEAREST line when two are in radius", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
// the closer (later) one must win.
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
expect(result.snapped).toBe(true);
expect(result.seconds).toBe(b);
});
it("leaves the position alone with no chapters", () => {
expect(snapToChapter(42, [], 5)).toEqual({ seconds: 42, snapped: false });
it("leaves the position alone with no chapters or a dead bar", () => {
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
});
});
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
it("is 1:1 at the bar and decays an order per reference distance", () => {
expect(dragScale(0)).toBe(1);
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
});
it("is symmetric in pull direction and monotonic in |dy|", () => {
expect(dragScale(-60)).toBe(dragScale(60));
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
});
it("degenerates to plain travel with a dead reference", () => {
expect(dragScale(500, 0)).toBe(1);
});
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
// 800px bar over 26h: 100px ≈ 11726s.
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
(100 / 800) * duration26h,
5,
);
});
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
const pxPerSecond = 800 / duration26h;
const onePxSeconds = 1 / pxPerSecond;
// One reference-distance of pull = one order of magnitude finer.
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
100 * onePxSeconds * 0.1,
5,
);
// Scale changes only affect SUBSEQUENT travel: the cursor never
// teleports when the finger pulls away mid-drag.
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
});
it("clamps to the book in both directions", () => {
const pxPerSecond = 800 / duration26h;
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
expect(applyDrag(50, 100, 0, 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);
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
});
it("shows the rail only past the pull threshold", () => {
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
});
it("lifts toward the finger, capped", () => {
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
});
});
+96 -20
View File
@@ -1,9 +1,9 @@
// Scrubber math (player plan §1 — progress bar overhaul). Pure and
// DOM-free except for passing rects in; the Alpine component owns the
// element, the pointer events and the state. Everything here is
// unit-tested — the snap threshold, the buffer→global mapping and the
// precision-rail mapping are exactly the parts that must hold for an
// 86-hour, 1189-track book AND a 2-hour single-file m4b.
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
// passing rects in; the Alpine component owns the element, the pointer
// events and the state. Everything here is unit-tested — the pixel
// magnetism, the pull-away drag scaling and the precision-rail mapping
// are exactly the parts that must hold for an 86-hour, 1189-track book
// AND a 26-hour single-file m4b.
export interface ScrubChapter {
start: number;
@@ -29,26 +29,102 @@ export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): numb
return found;
}
// snapToChapter returns the chapter start when the position falls
// within thresholdSeconds of one (click-to-snap), otherwise the
// position unchanged.
export function snapToChapter(
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
// in bar pixels, for it to take over the landing position.
export const MAGNET_RADIUS_PX = 10;
// nearestChapterLine finds the chapter start closest to a position and
// reports its distance in bar pixels. Empty chapters → null.
export function nearestChapterLine(
seconds: number,
chapters: ScrubChapter[],
thresholdSeconds = 5,
): { seconds: number; snapped: boolean } {
let best: { seconds: number; snapped: boolean } = { seconds, snapped: false };
let bestDistance = thresholdSeconds;
for (const ch of chapters) {
const distance = Math.abs(ch.start - seconds);
if (distance <= bestDistance) {
bestDistance = distance;
best = { seconds: ch.start, snapped: true };
}
duration: number,
widthPx: number,
): { index: number; seconds: number; distancePx: number } | null {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
const pxPerSecond = widthPx / duration;
let best: { index: number; seconds: number; distancePx: number } | null = null;
for (let i = 0; i < chapters.length; i += 1) {
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
if (best === null || distancePx < best.distancePx)
best = { index: i, seconds: chapters[i].start, distancePx };
}
return best;
}
// snapToChapterPx applies the magnet: within radiusPx of a line the
// position lands ON the line; force (Shift+click) extends the pull to
// the nearest line regardless of distance. Otherwise unchanged.
export function snapToChapterPx(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
radiusPx = MAGNET_RADIUS_PX,
force = false,
): { seconds: number; snapped: boolean } {
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
if (near === null) return { seconds, snapped: false };
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
return { seconds, snapped: false };
}
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
// away, ~0.01× further. Exponential keeps the zoom continuous — any
// precision level is one gesture (plan §9 option C).
export const DRAG_REF_PX = 80;
export const DRAG_SCALE_MIN = 0.001;
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
if (refPx <= 0) return 1;
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
}
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
// horizontal travel (px) converts at the bar's scale, then shrinks by
// the current vertical offset. Incremental, not absolute — changing the
// scale mid-drag only affects subsequent travel, so pulling away never
// teleports the position. Clamped to the book.
export function applyDrag(
position: number,
dxPx: number,
dyPx: number,
pxPerSecond: number,
duration: number,
refPx: number = DRAG_REF_PX,
): 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) * dragScale(dyPx, refPx)));
}
// Touch feedback thresholds: the rail appears once the finger is at
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
export const TOUCH_RAIL_MIN_DY_PX = 12;
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
// keeps the edge labels readable.
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
}
// railLiftPx converts the vertical offset into the rail's translateY:
// toward the finger (negative = up), capped so the rail never chases
// the finger off the player.
export function railLiftPx(dyPx: number): number {
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
}
// denseTicks decides which chapter starts get a rendered tick: a
// chapter earns a mark only when it is at least minGapPx from the last
// kept one — an 1189-chapter book would otherwise paint the bar solid.
+101 -24
View File
@@ -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.