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:
John O'Keefe committed 2026-10-22 09:49:02 -04:00
1 parent 18bed79d3a
commit 1f30602c23
5 files changed
+614 -25

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import {
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
railSecondsAtPointer,
railTicks,
railWindow,
RAIL_SPAN,
secondsAtPointer,
snapToChapter,
} from "./audio-scrubber";
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
// single-file book (Gardens-shaped: 34 chapters).
const duration86h = 310_320;
const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 }));
const duration26h = 93_807;
const chapters34 = Array.from({ length: 34 }, (_, i) => ({
start: (i * duration26h) / 34,
title: `Chapter ${i + 1}`,
}));
const rect = (width: number): DOMRect =>
({
left: 100,
right: 100 + width,
width,
top: 0,
bottom: 10,
height: 10,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
describe("secondsAtPointer", () => {
it("maps the bar linearly and clamps outside it", () => {
const r = rect(800);
expect(secondsAtPointer(100, r, duration86h)).toBe(0);
expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5);
expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h);
expect(secondsAtPointer(50, r, duration86h)).toBe(0);
expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h);
});
it("is safe with degenerate rects and durations", () => {
expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0);
expect(secondsAtPointer(150, rect(800), 0)).toBe(0);
});
});
describe("chapterIndexFor", () => {
it("finds the containing chapter in both shapes", () => {
expect(chapterIndexFor(chapters1189, 0)).toBe(0);
expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500);
expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33);
expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33);
});
it("returns -1 before the first chapter and for none at all", () => {
expect(chapterIndexFor(chapters1189, -1)).toBe(-1);
expect(chapterIndexFor([], 100)).toBe(-1);
});
});
describe("snapToChapter", () => {
it("snaps within the threshold and not beyond it", () => {
const start = chapters1189[10].start;
expect(snapToChapter(start + 3, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
expect(snapToChapter(start + 4.9, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
expect(snapToChapter(start + 5.1, chapters1189, 5).snapped).toBe(false);
expect(snapToChapter(start - 5, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
});
it("picks the NEAREST line when two are in range", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Chapter gap (~2759s) means both lines sit ~1380s from the
// midpoint — the threshold must reach past it.
const result = snapToChapter(mid + 1, chapters34, 1500);
expect(result.snapped).toBe(true);
expect(result.seconds).toBe(b);
});
it("leaves the position alone with no chapters", () => {
expect(snapToChapter(42, [], 5)).toEqual({ seconds: 42, snapped: false });
});
});
describe("denseTicks", () => {
it("decimates an 1189-chapter book to a readable number of marks", () => {
const ticks = denseTicks(chapters1189, duration86h, 800, 4);
expect(ticks.length).toBeGreaterThan(50);
expect(ticks.length).toBeLessThan(300);
expect(ticks[0]).toBe(0);
// kept marks are strictly increasing and spaced by the gap rule
for (let i = 1; i < ticks.length; i += 1) {
const gapPx =
((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800;
expect(gapPx).toBeGreaterThanOrEqual(4);
}
});
it("keeps every chapter for a sparse 34-chapter book", () => {
const ticks = denseTicks(chapters34, duration26h, 800, 4);
expect(ticks).toHaveLength(34);
});
it("handles degenerate inputs", () => {
expect(denseTicks([], duration86h, 800)).toEqual([]);
expect(denseTicks(chapters34, 0, 800)).toEqual([]);
expect(denseTicks(chapters34, duration26h, 0)).toEqual([]);
});
});
describe("bufferedAheadEnd", () => {
it("returns the end of the range containing the playhead", () => {
const ranges = [
{ start: 0, end: 2 },
{ start: 92_719, end: 93_808 },
];
// A huge m4b's moov index at the tail is its own range — it must
// NOT read as "the whole book is buffered".
expect(bufferedAheadEnd(ranges, 0)).toBe(2);
expect(bufferedAheadEnd(ranges, 1.5)).toBe(2);
expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808);
});
it("walks into the next range when the playhead sits at a boundary", () => {
expect(
bufferedAheadEnd(
[
{ start: 0, end: 10 },
{ start: 10, end: 30 },
],
10,
),
).toBe(30);
});
it("returns null when the playhead is in a gap or ranges are empty", () => {
expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull();
expect(bufferedAheadEnd([], 10)).toBeNull();
});
});
describe("bufferedSpanPct", () => {
it("maps only the active track's segment on a multi-track book", () => {
// Track 8 of the KJV shape spans ~2088s..2344s of 310320s.
const track = { start: 2088, end: 2344 };
const span = bufferedSpanPct(track, 2344, duration86h);
expect(span).not.toBeNull();
expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5);
expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
// Element buffer beyond the track end is clamped to the track.
const clamped = bufferedSpanPct(track, 99999, duration86h);
expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
});
it("spans the whole bar for a single-file book (start 0, end unknown)", () => {
const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h);
expect(span!.left).toBe(0);
expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5);
});
it("returns null with no track or no buffer", () => {
expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull();
});
});
describe("railWindow / railSecondsAtPointer / railTicks", () => {
it("centers a ±5min window and clamps at the book edges", () => {
const mid = railWindow(duration26h / 2, duration26h);
expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5);
expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5);
const atStart = railWindow(0, duration26h);
expect(atStart.from).toBe(0);
expect(atStart.to).toBe(RAIL_SPAN);
const atEnd = railWindow(duration26h, duration26h);
expect(atEnd.to).toBe(duration26h);
expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN);
// A book shorter than the window clamps to the whole book.
expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 });
});
it("maps pointer X at minutes-per-pixel inside the window", () => {
const r = rect(600);
const window = { from: 3000, to: 3600 };
expect(railSecondsAtPointer(100, r, window)).toBe(3000);
expect(railSecondsAtPointer(400, r, window)).toBe(3300);
expect(railSecondsAtPointer(700, r, window)).toBe(3600);
});
it("lists the chapter starts inside the window with titles", () => {
// from is 1s past chapter 4's start, so chapter 4 is outside.
const ticks = railTicks(
{ from: chapters34[3].start + 1, to: chapters34[6].start + 1 },
chapters34,
);
expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]);
});
});
+151
View File
@@ -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;
}
+162
View File
@@ -28,6 +28,17 @@ import {
type SleepChoice,
type SleepState,
} from "./audio-sleep";
import {
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
railSecondsAtPointer,
railTicks,
railWindow,
secondsAtPointer,
snapToChapter,
} from "./audio-scrubber";
type AudioChapter = { index: number; title: string; start: number; end: number };
@@ -149,6 +160,17 @@ Alpine.data("audioReader", () => ({
scrubbing: false,
scrubValue: 0,
// Progress-bar overhaul (plan §1): chapter ticks, per-track buffer
// span, hover tooltip and the precision rail. Math lives in
// audio-scrubber.ts (pure, unit-tested).
tickIndices: [] as number[],
bufferedGlobal: null as number | null,
hoverSeconds: null as number | null,
hoverPct: 0,
railFrom: 0,
railTo: 0,
railTickList: [] as Array<{ start: number; title: string }>,
// Track manifest: one AudioTrack per playable file. Single-file books
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
// every code path below can treat playback as (track, local seconds)
@@ -331,9 +353,24 @@ Alpine.data("audioReader", () => ({
this.updateMediaSessionState();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
window.addEventListener("resize", () => this.recomputeTicks());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Buffer readout for the scrubber's per-track buffer span.
// 'progress' alone does not fire reliably for Range-served files
// that go network-idle while paused — ride the events that
// demonstrably do, and re-read the (idempotent) ranges on each.
for (const ev of [
"progress",
"loadedmetadata",
"loadeddata",
"canplay",
"playing",
"timeupdate",
]) {
audio.addEventListener(ev, () => this.updateBuffered());
}
// Keyboard shortcuts (plan §2): one dispatcher resolving through
// the declarative audio-keys.ts keymap. Hardware media keys ride
@@ -381,6 +418,8 @@ Alpine.data("audioReader", () => ({
if (!audio || index < 0 || index >= this.tracks.length) return;
const track = this.tracks[index];
this.activeTrack = index;
this.bufferedGlobal = null; // buffers are per-track; the next
// 'progress' event re-reads the freshly loading element's ranges.
if (audio.getAttribute("src") === track.url) {
this.pendingSeek = null;
audio.currentTime = Math.max(0, localSeconds);
@@ -404,6 +443,7 @@ Alpine.data("audioReader", () => ({
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
this.buildTracks(data);
this.recomputeTicks();
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
audio.setAttribute("src", this.tracks[0].url);
@@ -609,6 +649,128 @@ Alpine.data("audioReader", () => ({
return Number.isFinite(n) ? n : 0;
},
// ---- progress bar (audio-scrubber.ts math) ----
recomputeTicks() {
const area = document.getElementById("scrub-area");
const width = area ? area.getBoundingClientRect().width : 0;
this.tickIndices = denseTicks(
this.chapters,
this.chaptersDuration() || this.bookDuration || 1,
width,
);
},
updateBuffered() {
const audio = this.audio();
if (!audio || audio.buffered.length === 0) {
this.bufferedGlobal = null;
return;
}
// Buffered-ahead of the playhead (element-local): the end of the
// range containing currentTime. The moov-index range at a huge
// m4b's tail must not read as "the whole book is buffered".
const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({
start: audio.buffered.start(i),
end: audio.buffered.end(i),
}));
this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime);
},
chapterTitleAt(seconds: number): string {
const idx = chapterIndexFor(this.chapters, seconds);
if (idx === -1) return this.title;
const ch = this.chapters[idx];
return ch.title || `Chapter ${idx + 1}`;
},
scrubFillPct(): number {
const value = this.scrubbing ? this.scrubValue : Number(this.currentTime);
return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));
},
bufferSpan(): { left: number; width: number } | null {
return bufferedSpanPct(
this.tracks[this.activeTrack],
this.bufferedGlobal,
this.chaptersDuration() || 1,
);
},
railMarkerPct(): number {
const span = Math.max(1, this.railTo - this.railFrom);
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
},
// Click/drag on the bar: the FIRST press maps hours-per-pixel and
// snaps to a chapter line within ~5s; while held, the precision rail
// takes over at minutes-per-pixel (re-grab to travel far). The
// native range stays underneath for keyboard/aria — its drag default
// is prevented so the two mappings never fight.
scrubPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
e.preventDefault();
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
try {
area.setPointerCapture(e.pointerId);
} catch {
/* synthetic or already-released pointer — drag still works via
the element-level listeners */
}
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
const duration = this.chaptersDuration() || 1;
const at = snapToChapter(secondsAtPointer(e.clientX, rect, duration), this.chapters, 5).seconds;
this.scrubbing = true;
this.scrubValue = at;
const window = railWindow(at, duration);
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
},
scrubPointerMove(e: PointerEvent) {
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
const duration = this.chaptersDuration() || 1;
if (!this.scrubbing) {
this.hoverSeconds = secondsAtPointer(e.clientX, rect, duration);
this.hoverPct =
rect.width > 0
? Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100))
: 0;
return;
}
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
from: this.railFrom,
to: this.railTo,
});
},
scrubPointerLeave() {
if (!this.scrubbing) this.hoverSeconds = null;
},
scrubPointerUp(e: PointerEvent) {
if (!this.scrubbing) return;
const area = e.currentTarget as HTMLElement;
if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId))
area.releasePointerCapture(e.pointerId);
this.seekTo(this.scrubValue);
this.scrubbing = false;
this.hoverSeconds = null;
},
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
onScrubWheel(e: WheelEvent) {
e.preventDefault();
const step = e.shiftKey ? 60 : 5;
const direction = e.deltaY > 0 ? -1 : 1;
const duration = this.chaptersDuration() || 1;
const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step));
this.seekTo(target);
},
toggleBookmarkCapture() {
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;