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
+137
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user