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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user