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:
1 parent
86f0af632e
commit
5cf34102d6
3 files changed
+231
-32
No files matched your search
+177
-31
@@ -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
|
||||
|
||||
Reference in new issue
Block a user