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

+83 -15
View File
@@ -18,6 +18,17 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="/static/style.css" rel="stylesheet"/>
<script type="module" src="/static/main.js"></script>
<style>
/* Layered scrubber (plan §1): the native range stays for
a11y (focus, arrow keys, aria) but its track is
transparent so the tick/buffer/fill layers beneath show
through. Static page CSS — templ owns the look. */
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
#audio-scrub::-moz-range-track { background: transparent; border: none; }
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
</style>
</head>
<body
x-data="audioReader"
@@ -162,21 +173,78 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<!-- Scrubber + transports -->
<div class="w-full max-w-xl flex flex-col gap-2">
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
<span x-text="fmtTime(currentTime)">0:00</span>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="flex-1 accent-current"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@pointerup="seekTo(scrubValue); scrubbing = false"
@change="seekTo(scrubValue); scrubbing = false"
style="z-index: 20;"
/>
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
<span class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
<div
id="scrub-area"
class="relative flex-1 h-6 flex items-center cursor-pointer"
style="touch-action: none;"
@pointerdown="scrubPointerDown($event)"
@pointermove="scrubPointerMove($event)"
@pointerup="scrubPointerUp($event)"
@pointercancel="scrubPointerUp($event)"
@pointerleave="scrubPointerLeave()"
@wheel.prevent="onScrubWheel($event)"
>
<!-- Base track -->
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
<!-- Elapsed fill -->
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
<!-- Buffered span: the ACTIVE track's element buffer only -->
<template x-if="bufferSpan()">
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
</template>
<!-- Chapter ticks: density-decimated, current chapter tinted -->
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
<template x-for="idx in tickIndices" :key="'tick' + idx">
<div
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
></div>
</template>
</div>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="relative w-full"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@change="seekTo(scrubValue); scrubbing = false"
aria-label="Seek position"
/>
<!-- Hover tooltip: time + chapter under the pointer -->
<div
x-show="hoverSeconds !== null && !scrubbing"
x-cloak
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
>
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
</div>
<!-- Precision rail: while dragging, ±5 minutes at
minutes-per-pixel (floats over the transports,
page never shifts) -->
<div
x-show="scrubbing"
x-cloak
class="absolute top-full mt-1 inset-x-0 h-10 rounded-lg shadow-lg p-1.5 pointer-events-none"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<div class="relative h-full">
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
</template>
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
<div class="absolute left-1 -bottom-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-secondary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
</div>
</div>
</div>
<span class="inline-block min-w-[4.75rem] text-right" x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
</div>
<div class="flex items-center justify-center gap-3 md:gap-5">
<button