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

+53
View File
@@ -66,6 +66,17 @@ Alpine.data("audioReader", () => ({
editingBookmarkId: "",
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
// whole book, not per-chapter
bookDuration: 0,
@@ -106,6 +117,9 @@ Alpine.data("audioReader", () => ({
audio.src = fileUrl;
audio.playbackRate = Number(this.speed) || 1;
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.
this.restorePosition();
@@ -299,6 +313,45 @@ Alpine.data("audioReader", () => ({
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) ----
async loadBookmarks() {