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>
|
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="skip(-1)"
|
@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'"
|
:aria-label="'Skip back ' + skipSeconds + 's'"
|
||||||
|
:title="'Skip back ' + skipSeconds + 's'"
|
||||||
style="color: var(--text-primary);"
|
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>
|
||||||
<button
|
<button
|
||||||
id="audio-play"
|
id="audio-play"
|
||||||
@@ -141,61 +161,187 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="skip(1)"
|
@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'"
|
:aria-label="'Skip forward ' + skipSeconds + 's'"
|
||||||
|
:title="'Skip forward ' + skipSeconds + 's'"
|
||||||
style="color: var(--text-primary);"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="toggleBookmarkCapture()"
|
@click="stepChapter(1)"
|
||||||
class="rounded-full p-2.5 ml-2 hover:bg-surface-hover transition-colors"
|
class="rounded-full p-2.5 hover:bg-surface-hover transition-colors"
|
||||||
:class="bookmarkCaptureOpen ? 'opacity-100' : 'opacity-60'"
|
aria-label="Next chapter"
|
||||||
aria-label="Add bookmark at current position"
|
title="Next chapter"
|
||||||
title="Add bookmark"
|
|
||||||
style="color: var(--text-primary);"
|
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">
|
<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 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
<path d="M5 4l10 8-10 8V4z"></path>
|
||||||
<path d="M12 7v6M9 10h6"></path>
|
<path d="M19 5v14"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skip / Speed rows: one layout rule, labels left, chips right -->
|
<!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ -->
|
||||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
<div class="w-full max-w-xl flex items-center justify-center gap-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="relative" @click.outside="skipOpen = false">
|
||||||
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Skip</span>
|
<button
|
||||||
<div class="flex items-center gap-1.5">
|
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">
|
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSkip(secs)"
|
@click="setSkip(secs); skipOpen = false"
|
||||||
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
:class="Number(skipSeconds) === secs ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
:class="Number(skipSeconds) === secs ? 'font-semibold' : ''"
|
||||||
style="color: var(--text-secondary);"
|
:style="Number(skipSeconds) === secs ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||||
x-text="secs + 's'"
|
x-text="secs + ' seconds'"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="relative" @click.outside="speedOpen = false">
|
||||||
<span class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Speed</span>
|
<button
|
||||||
<div class="flex items-center gap-1.5">
|
type="button"
|
||||||
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="rate">
|
@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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="setSpeed(rate)"
|
@click="setSpeed(rate); speedOpen = false"
|
||||||
class="text-xs px-2.5 py-1.5 rounded-md transition-colors"
|
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||||
:class="Number(speed) === rate ? 'opacity-100 underline' : 'opacity-50 hover:opacity-80'"
|
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||||
style="color: var(--text-secondary);"
|
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||||
x-text="rate + '×'"
|
x-text="rate + '×'"
|
||||||
></button>
|
></button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Chapter drawer: slides top-to-bottom, floats above the page
|
<!-- Chapter drawer: slides top-to-bottom, floats above the page
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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-6\"><button type=\"button\" @click=\"skip(-1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">–<span x-text=\"skipSeconds\">10</span>s</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=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</span></button> <button type=\"button\" @click=\"skip(1)\" class=\"rounded-full px-4 py-2.5 hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><span class=\"text-base font-semibold\">+<span x-text=\"skipSeconds\">10</span>s</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\" 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></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\"><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'\"></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\"><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);\" x-text=\"rate + '×'\"></button></template></div></div></div><!-- Chapter drawer: slides top-to-bottom, floats above the page\n\t\t\t (backdrop overlay — the page never shifts) --><div x-show=\"chaptersOpen\" x-cloak class=\"fixed inset-0 z-40\" style=\"background-color: color-mix(in srgb, black 32%, transparent);\" @click=\"chaptersOpen = false\"></div><div x-show=\"chaptersOpen\" x-cloak x-transition:enter=\"transition ease-out duration-200\" x-transition:enter-start=\"-translate-y-4 opacity-0\" x-transition:enter-end=\"translate-y-0 opacity-100\" x-transition:leave=\"transition ease-in duration-150\" x-transition:leave-start=\"translate-y-0 opacity-100\" x-transition:leave-end=\"-translate-y-4 opacity-0\" class=\"fixed top-0 right-0 z-50 w-full sm:w-96 max-h-[70vh] overflow-y-auto rounded-b-2xl shadow-2xl p-3\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); border-top-width: 0;\" @click.outside=\"chaptersOpen = false\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Chapters</span> <button type=\"button\" @click=\"chaptersOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-labLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</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=\"text-3xl leading-none\">▶</span> <span x-show=\"playing\" class=\"text-3xl leading-none\" x-cloak>❚❚</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></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); skipOpen = false\" class=\"w-full textLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ Alpine.data("audioReader", () => ({
|
|||||||
editingBookmarkId: "",
|
editingBookmarkId: "",
|
||||||
editingNote: "",
|
editingNote: "",
|
||||||
|
|
||||||
|
// Settings strip dropdowns (open-state; panels close on outside click)
|
||||||
|
skipOpen: false,
|
||||||
|
speedOpen: false,
|
||||||
|
volumeOpen: false,
|
||||||
|
|
||||||
|
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
|
||||||
|
// on unmute (spec: icon shows level arcs, crossed when muted).
|
||||||
|
volume: Math.min(1, Math.max(0, Number(localStorage.getItem("audio_volume") ?? 1) || 1)),
|
||||||
|
muted: localStorage.getItem("audio_muted") === "1",
|
||||||
|
prevVolume: 1,
|
||||||
|
|
||||||
// chapters are absolute seconds into the book; the scrubber spans the
|
// chapters are absolute seconds into the book; the scrubber spans the
|
||||||
// whole book, not per-chapter
|
// whole book, not per-chapter
|
||||||
bookDuration: 0,
|
bookDuration: 0,
|
||||||
@@ -106,6 +117,9 @@ Alpine.data("audioReader", () => ({
|
|||||||
audio.src = fileUrl;
|
audio.src = fileUrl;
|
||||||
audio.playbackRate = Number(this.speed) || 1;
|
audio.playbackRate = Number(this.speed) || 1;
|
||||||
audio.preload = "auto";
|
audio.preload = "auto";
|
||||||
|
// Restored volume/mute from the settings strip's persisted state.
|
||||||
|
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
|
||||||
|
audio.muted = !!this.muted;
|
||||||
|
|
||||||
// Restore position: chapters + progress from the server.
|
// Restore position: chapters + progress from the server.
|
||||||
this.restorePosition();
|
this.restorePosition();
|
||||||
@@ -299,6 +313,45 @@ Alpine.data("audioReader", () => ({
|
|||||||
window.location.href = `/media/${this.mediaItemId}`;
|
window.location.href = `/media/${this.mediaItemId}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- volume (settings strip) ----
|
||||||
|
|
||||||
|
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
||||||
|
const v = this.muted ? 0 : Number(this.volume) || 0;
|
||||||
|
if (v <= 0) return "muted";
|
||||||
|
if (v < 0.34) return "low";
|
||||||
|
if (v < 0.67) return "mid";
|
||||||
|
return "high";
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolume(level: number) {
|
||||||
|
const v = Math.min(1, Math.max(0, Number(level)));
|
||||||
|
this.volume = v;
|
||||||
|
localStorage.setItem("audio_volume", String(v));
|
||||||
|
const audio = this.audio();
|
||||||
|
if (audio) audio.volume = v;
|
||||||
|
if (v > 0 && this.muted) this.toggleMute();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
const audio = this.audio();
|
||||||
|
if (this.muted) {
|
||||||
|
// Unmute restores the last real level; a zero level gets a floor
|
||||||
|
// so unmute is audible again.
|
||||||
|
if (Number(this.volume) <= 0) {
|
||||||
|
this.volume = 0.5;
|
||||||
|
localStorage.setItem("audio_volume", "0.5");
|
||||||
|
if (audio) audio.volume = 0.5;
|
||||||
|
}
|
||||||
|
this.muted = false;
|
||||||
|
if (audio) audio.muted = false;
|
||||||
|
} else {
|
||||||
|
this.prevVolume = Number(this.volume) || 0.5;
|
||||||
|
this.muted = true;
|
||||||
|
if (audio) audio.muted = true;
|
||||||
|
}
|
||||||
|
localStorage.setItem("audio_muted", this.muted ? "1" : "0");
|
||||||
|
},
|
||||||
|
|
||||||
// ---- bookmarks (A2d) ----
|
// ---- bookmarks (A2d) ----
|
||||||
|
|
||||||
async loadBookmarks() {
|
async loadBookmarks() {
|
||||||
|
|||||||
Reference in new issue
Block a user