feat(audio player): canonical audiobook transports + dropdown settings + volume panel

Transport row is now the five-button audiobook shape the user asked
for: previous-chapter · circular-skip-back (seconds displayed inside
the ring) · play/pause · circular-skip-forward · next-chapter — the
ring arrows are stroke SVGs (rotate-ccw/rotate-cw) with the number as
an overlay so it stays upright and follows the skip setting.

Settings strip collapses to four controls: Skip dropdown, Speed
dropdown, Bookmark (capture popover unchanged), Volume. Volume opens
a panel with a vertical dragger (writing-mode:rtl slider) and a
mute/unmute button; the strip icon is dynamic — 1–3 arcs by level (low
<34%, mid <67%, high) and crossed when muted or zero. Volume/mute
persist in localStorage and restore with the element; unmute floors
zero-volume to 50% so unmuting is audible.

Verified on-device: dropdowns open upward with current value
highlighted, mute flips both icons to the crossed state, slider
drag changes arc count, chapter buttons jump (pill → 15/34) and the
caption tracks the drawer's chapter labels.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:35:39 -04:00
1 parent 86f0af632e
commit 5cf34102d6
3 files changed
+231 -32

No files matched your search

+177 -31
View File
@@ -117,15 +117,35 @@ templ AudioReader(user User, metadata ReaderMetadata) {
/>
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
</div>
<div class="flex items-center justify-center gap-6">
<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="rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors"
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);"
>
<span class="text-base font-semibold">–<span x-text="skipSeconds">10</span>s</span>
<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"
@@ -141,61 +161,187 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<button
type="button"
@click="skip(1)"
class="rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors"
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);"
>
<span class="text-base font-semibold">+<span x-text="skipSeconds">10</span>s</span>
<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="toggleBookmarkCapture()"
class="rounded-full p-2.5 ml-2 hover:bg-surface-hover transition-colors"
:class="bookmarkCaptureOpen ? 'opacity-100' : 'opacity-60'"
aria-label="Add bookmark at current position"
title="Add bookmark"
@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="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
<path d="M12 7v6M9 10h6"></path>
<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>
<!-- Skip / Speed rows: one layout rule, labels left, chips right -->
<div class="w-full max-w-xl flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Skip</span>
<div class="flex items-center gap-1.5">
<!-- 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></svg>
</button>
<div
x-show="skipOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs)"
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
:class="Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
style="color: var(--text-secondary);"
x-text="secs + 's'"
@click="setSkip(secs); skipOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(skipSeconds) === secs ? 'font-semibold' : ''"
:style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="secs + ' seconds'"
></button>
</template>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Speed</span>
<div class="flex items-center gap-1.5">
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="rate">
<div class="relative" @click.outside="speedOpen = false">
<button
type="button"
@click="speedOpen = !speedOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Playback speed, currently ' + speed + ' times'"
style="color: var(--text-secondary);"
>
<span>Speed</span>
<span class="font-semibold" style="color: var(--text-primary);" x-text="speed + '×'">1×</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></svg>
</button>
<div
x-show="speedOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
<button
type="button"
@click="setSpeed(rate)"
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
:class="Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
style="color: var(--text-secondary);"
@click="setSpeed(rate); speedOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="rate + '×'"
></button>
</template>
</div>
</div>
<button
type="button"
@click="toggleBookmarkCapture()"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:class="bookmarkCaptureOpen ? 'opacity-100' : 'opacity-70'"
aria-label="Add bookmark at current position"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
<path d="M12 7v6M9 10h6"></path>
</svg>
<span>Bookmark</span>
</button>
<div class="relative" @click.outside="volumeOpen = false">
<button
type="button"
@click="volumeOpen = !volumeOpen"
class="rounded-lg p-2 hover:bg-surface-hover transition-colors"
:aria-label="'Volume ' + Math.round(Number(volume) * 100) + '%'"
style="color: var(--text-secondary);"
>
<span x-show="volumeIcon() === 'muted'" x-cloak>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="m17 9 4 6M21 9l-4 6"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'low'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M15.5 9.5a3.5 3.5 0 0 1 0 5"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'mid'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
</svg>
</span>
<span x-show="volumeIcon() === 'high'">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
<path d="M20.5 3a13 13 0 0 1 0 18"></path>
</svg>
</span>
</button>
<div
x-show="volumeOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-3 flex flex-col items-center gap-2"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<button
type="button"
@click="toggleMute()"
class="rounded-full p-1.5 hover:bg-surface-hover transition-colors"
:aria-label="muted ? 'Unmute' : 'Mute'"
style="color: var(--text-secondary);"
>
<span x-show="muted" x-cloak class="block" style="color: var(--text-primary);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="m17 9 4 6M21 9l-4 6"></path>
</svg>
</span>
<span x-show="!muted" class="block">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4z"></path>
<path d="M14.5 8a5 5 0 0 1 0 8"></path>
<path d="M17.5 5.5a9 9 0 0 1 0 13"></path>
</svg>
</span>
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
:value="muted ? 0 : volume"
@input="setVolume(Number($event.target.value))"
class="accent-current"
style="writing-mode: vertical-lr; direction: rtl; width: 1.25rem; height: 6.5rem;"
aria-label="Volume"
/>
</div>
</div>
</div>
<!-- Chapter drawer: slides top-to-bottom, floats above the page