Files
bookhoard/web/src/audio-scrubber.ts
T
John O'Keefe e5d4744977 fix(audio): Shift+grab opens the precision rail anchored at the playhead
The first grab implementation degraded Shift+grab to a quarter-speed
relative crawl — losing the ±5-minute rail the user expects when they
grab the handle with Shift held. The rail is back: Shift+grab anchors
railWindow at the CURRENT position (not the nearest chapter line, and
never teleports the playhead on pointerdown); plain grab stays relative
1:1. GRAB_FINE_SCALE is deleted as dead code, and the dialog guide
documents the restored gesture.
2026-10-27 19:04:59 -04:00

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
title?: string;
}
// secondsAtPointer maps a pointer X within the scrub bar's rect to a
// global book position, clamped to [0, duration].
export function secondsAtPointer(clientX: number, rect: DOMRect, duration: number): number {
if (rect.width <= 0 || duration <= 0) return 0;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return fraction * duration;
}
// chapterIndexFor returns the index of the chapter containing seconds —
// the last chapter whose start is at or before it. Empty chapters → -1.
export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): number {
let found = -1;
for (let i = 0; i < chapters.length; i += 1) {
if (seconds >= chapters[i].start) found = i;
else break;
}
return found;
}
// ---- §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[],
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 handle grab: the thumb is not part of the track ----
// GRAB_SLOP_PX is how close a pointerdown must land to the playhead
// thumb, in bar pixels, for it to count as grabbing the HANDLE rather
// than clicking the track. A grab never teleports the position — not
// to the click X (free-drag behavior) and not to a chapter line
// (Shift+click behavior): a plain grab drags RELATIVE to where the
// handle is, and Shift+grab opens the precision rail anchored at the
// current position.
export const GRAB_SLOP_PX = 12;
// isHandleGrab reports whether a pointerdown at clientX lands on the
// playhead thumb (within slopPx) instead of the bare track.
export function isHandleGrab(
clientX: number,
rect: DOMRect,
position: number,
duration: number,
slopPx = GRAB_SLOP_PX,
): boolean {
if (rect.width <= 0 || duration <= 0) return false;
const clamped = Math.min(duration, Math.max(0, position));
const thumbX = rect.left + (clamped / duration) * rect.width;
return Math.abs(clientX - thumbX) <= slopPx;
}
// applyGrab advances a HANDLE drag by one pointermove: incremental
// (like applyDrag, so the cursor never teleports), 1:1 with pointer
// travel. (Shift+grab uses the precision rail instead of a scaled
// drag — see scrubPointerDown.)
export function applyGrab(
position: number,
dxPx: number,
pxPerSecond: number,
duration: number,
): 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));
}
// ---- §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.
// Returns chapter INDICES (into chapters), always including the first.
export function denseTicks(
chapters: ScrubChapter[],
duration: number,
widthPx: number,
minGapPx = 4,
): number[] {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return [];
const kept: number[] = [0];
let lastPx = (chapters[0].start / duration) * widthPx;
for (let i = 1; i < chapters.length; i += 1) {
const px = (chapters[i].start / duration) * widthPx;
if (px - lastPx >= minGapPx) {
kept.push(i);
lastPx = px;
}
}
return kept;
}
// bufferedAheadEnd returns the end of the buffered range containing
// the playhead — how much audio is actually fetched AHEAD of where you
// are. A huge m4b's moov index at the file tail shows up as its own
// buffered range; counting it would claim the whole book is buffered.
export function bufferedAheadEnd(
ranges: Array<{ start: number; end: number }>,
position: number,
): number | null {
for (const r of ranges) {
if (position >= r.start && position < r.end) return r.end;
}
return null;
}
// bufferedSpanPct maps the ACTIVE track's buffered-ahead end into the
// global bar. Buffers are per-track: the span is drawn only across the
// active track's segment of the bar, never the whole bar. Returns null
// when there is nothing buffered (or the track is unknown).
export function bufferedSpanPct(
track: { start: number; end: number } | undefined,
elementBufferedEnd: number | null,
duration: number,
): { left: number; width: number } | null {
if (!track || elementBufferedEnd === null || elementBufferedEnd <= 0 || duration <= 0)
return null;
const bufferedEnd = track.end > 0 ? Math.min(elementBufferedEnd, track.end) : elementBufferedEnd;
const span = Math.max(0, bufferedEnd - track.start);
const left = Math.min(100, Math.max(0, (track.start / duration) * 100));
const width = Math.min(100 - left, Math.max(0, (span / duration) * 100));
return { left, width };
}
// RAIL_SPAN is the precision rail's total width in seconds: ±5 minutes
// around the drag anchor.
export const RAIL_SPAN = 600;
// railWindow clamps a ±span/2 window around the anchor to the book.
export function railWindow(
anchorSeconds: number,
duration: number,
spanSeconds = RAIL_SPAN,
): { from: number; to: number } {
const from = Math.max(
0,
Math.min(anchorSeconds - spanSeconds / 2, Math.max(0, duration - spanSeconds)),
);
const to = Math.min(duration, from + spanSeconds);
return { from, to };
}
// railSecondsAtPointer maps a pointer X to a position INSIDE the rail
// window — minutes-per-pixel instead of hours-per-pixel.
export function railSecondsAtPointer(
clientX: number,
rect: DOMRect,
window: { from: number; to: number },
): number {
if (rect.width <= 0) return window.from;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return window.from + fraction * (window.to - window.from);
}
// railTicks returns the chapter starts (with titles) inside a rail
// window — readable density is the rail's whole point.
export function railTicks(
window: { from: number; to: number },
chapters: ScrubChapter[],
): Array<{ start: number; title: string }> {
const out: Array<{ start: number; title: string }> = [];
for (let i = 0; i < chapters.length; i += 1) {
const start = chapters[i].start;
if (start < window.from) continue;
if (start > window.to) break;
out.push({ start, title: chapters[i].title || `Chapter ${i + 1}` });
}
return out;
}