feat(audio-player): progress bar overhaul — ticks, buffer, wheel, tooltip, precision rail (plan §1)
The scrubber becomes a layered bar: the native <input type=range> stays on top for a11y (focus, arrow keys, aria — its track goes transparent via static page CSS) while the layers beneath render the state: - Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px apart) so an 1189-chapter 86-hour book gets ~100 readable marks instead of a solid mass; the current chapter's tick is tinted. - Buffered span: the ACTIVE track's buffered-ahead end mapped into the global bar (buffers are per-track and never span the whole bar). bufferedAheadEnd reads the range containing the playhead — a huge m4b's moov index at the file tail is its own buffered range and must not read as 'the whole book is buffered'. Rides progress + the events that demonstrably fire for Range-served files that go network-idle while paused (timeupdate/canplay/loadedmetadata/playing). - Wheel over the bar: ±5s nudge, Shift = ±60s. - Hover tooltip: time + chapter title under the pointer. - Precision rail: while dragging, a ±5-minute window floats below the bar (fixed anchor; release and re-grab to travel far) and the drag maps at minutes-per-pixel instead of hours-per-pixel — the whole point for 80h+ books. Click snaps to a chapter line within ~5s (snapToChapter). Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel + snaps, captured moves remap through the rail, release seeks. The native drag default is prevented so the two mappings never fight; keyboard arrows keep their native step and seek via change. Time labels get reserved width — the bar no longer resizes when the elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail drag check measuring a 25% mapping drift). Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap, tick density, per-track buffer→global span, rail window/mapping/ticks. Unit: 16 new tests including both book shapes (86h/1189ch and 26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of the Moon (single-file m4b); zip books share the folder manifest shape (no zip fixture exists in the dev library). Probe user deleted.
This commit is contained in:
1 parent
18bed79d3a
commit
1f30602c23
5 files changed
+614
-25
No files matched your search
@@ -0,0 +1,151 @@
|
||||
// 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.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// snapToChapter returns the chapter start when the position falls
|
||||
// within thresholdSeconds of one (click-to-snap), otherwise the
|
||||
// position unchanged.
|
||||
export function snapToChapter(
|
||||
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 };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user