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
|
||||
|
||||
@@ -51,7 +51,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><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></head>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><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>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#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); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#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); }\n\t\t\t</style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 27, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 38, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 28, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 29, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 30, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -134,7 +134,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 33, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 44, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -152,7 +152,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 147, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 158, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -165,7 +165,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 148, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 159, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,13 +188,13 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 157, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- 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></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- 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 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\n\t\t\t\t\t\t minutes-per-pixel (floats over the transports,\n\t\t\t\t\t\t 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 type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-Line truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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