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