// 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; }