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,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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user