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.
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
// 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;
|
||
}
|