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
+96
-20
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user