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

+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);
});
});