From 18bed79d3aab43c3a06d15e22ed59a67d8254a72 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 22 Oct 2026 09:04:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(audio-player):=20settings=20popover=20+=20?= =?UTF-8?q?autoplay-on-open=20(plan=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header gear (beside the keyboard icon) opens a 'Player settings' popover — one option row today, structured for more: 'Autoplay on open', a role=switch toggle persisted to localStorage audio_autoplay ('1'/'0', default off) via the normalizeAutoplay one-source helper. maybeAutoplay() runs strictly AFTER restorePosition resolves: the ?loc=audio: deep link keeps winning on position; the setting only decides whether playback starts. A browser autoplay-policy rejection (or any play() failure) is swallowed silently — the user just taps play; the unhandled-rejection trap in init would otherwise shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so Esc closes the panel with the other surfaces. Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default off stays paused, toggle + persistence, reload autoplays with no gesture, deep link lands at 600s global while autoplay starts, off stays paused, and a stubbed NotAllowedError proves the silent swallow; probe user created and deleted. --- templates/audio_reader.templ | 48 +++++++++++++++++++++++++++++++++ templates/audio_reader_templ.go | 8 +++--- web/src/audio.test.ts | 24 ++++++++++++++++- web/src/audio.ts | 42 +++++++++++++++++++++++++++-- 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index e6b2ff81..239b5c20 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -88,6 +88,54 @@ templ AudioReader(user User, metadata ReaderMetadata) { +
+ +
+
+ Player settings + +
+ +
+ Autoplay on open + +
+
+
diff --git a/templates/audio_reader_templ.go b/templates/audio_reader_templ.go index d1964255..ba49fcd6 100644 --- a/templates/audio_reader_templ.go +++ b/templates/audio_reader_templ.go @@ -140,7 +140,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, 9, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">
Player settings
Autoplay on open
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -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: 99, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 147, 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: 100, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 148, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -188,7 +188,7 @@ 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: 109, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 157, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { diff --git a/web/src/audio.test.ts b/web/src/audio.test.ts index ba7c7443..fb6bae96 100644 --- a/web/src/audio.test.ts +++ b/web/src/audio.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { SPEEDS, normalizeSpeed, stepSpeed, SKIP_SECONDS, cycleSkip } from "./audio"; +import { + SPEEDS, + normalizeSpeed, + stepSpeed, + SKIP_SECONDS, + cycleSkip, + normalizeAutoplay, +} from "./audio"; describe("SPEEDS", () => { it("is the agreed list from the player plan (0.5 through 3)", () => { @@ -71,3 +78,18 @@ describe("SKIP_SECONDS + cycleSkip", () => { expect(cycleSkip(7, -1)).toBe(5); }); }); + +describe("normalizeAutoplay", () => { + it("is on only for explicitly-on values", () => { + expect(normalizeAutoplay("1")).toBe(true); + expect(normalizeAutoplay("true")).toBe(true); + }); + + it("defaults to off for null, off, and garbage", () => { + expect(normalizeAutoplay(null)).toBe(false); + expect(normalizeAutoplay("0")).toBe(false); + expect(normalizeAutoplay("")).toBe(false); + expect(normalizeAutoplay("on")).toBe(false); + expect(normalizeAutoplay("2")).toBe(false); + }); +}); diff --git a/web/src/audio.ts b/web/src/audio.ts index be2b2945..f30c1204 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -100,6 +100,13 @@ export function cycleSkip(current: number, delta: 1 | -1): number { return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))]; } +// Autoplay-on-open setting — persisted as "1"/"0" (default off). The +// browser's autoplay policy decides whether the attempt succeeds; a +// rejection is swallowed silently (maybeAutoplay). +export function normalizeAutoplay(value: string | null): boolean { + return value === "1" || value === "true"; +} + const PROGRESS_INTERVAL_MS = 30_000; interface AudioConfig { @@ -187,6 +194,11 @@ Alpine.data("audioReader", () => ({ sleepTicker: null as ReturnType | null, sleepFadeTimer: null as ReturnType | null, + // Settings popover (plan §4): header gear → panel. Autoplay-on-open + // is the first option; the panel is structured for more. + settingsOpen: false, + autoplayOn: normalizeAutoplay(localStorage.getItem("audio_autoplay")), + // 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)), @@ -245,8 +257,11 @@ Alpine.data("audioReader", () => ({ audio.volume = Math.min(1, Math.max(0, Number(this.volume))); audio.muted = !!this.muted; - // Restore position: chapters + progress from the server. - this.restorePosition(); + // Restore position: chapters + progress from the server. Autoplay + // (if enabled) starts AFTER the position is restored — the ?loc= + // deep link wins on position; autoplay only decides whether + // playback starts. + this.restorePosition().then(() => this.maybeAutoplay()); // Periodic progress push (playing-only; pause/hide push too) this.startPushTimer(); // Bookmarks list (time-located; notes optional) @@ -634,6 +649,10 @@ Alpine.data("audioReader", () => ({ this.editingBookmarkId = ""; return true; } + if (this.settingsOpen) { + this.settingsOpen = false; + return true; + } if (this.chaptersOpen) { this.chaptersOpen = false; return true; @@ -848,6 +867,25 @@ Alpine.data("audioReader", () => ({ this.sleepLabelStr = "Off"; }, + // ---- settings popover (plan §4) ---- + + toggleAutoplay() { + this.autoplayOn = !this.autoplayOn; + localStorage.setItem("audio_autoplay", this.autoplayOn ? "1" : "0"); + }, + + // Attempt playback after restorePosition when the user opted in. A + // browser autoplay-policy block (NotAllowedError) — or any transient + // play rejection — is swallowed silently; the user just taps play. + maybeAutoplay() { + if (!this.autoplayOn) return; + const audio = this.audio(); + if (!audio || !audio.paused) return; + audio.play().catch(() => { + /* autoplay blocked — stay silent */ + }); + }, + async saveBookmark() { const audio = this.audio(); if (!audio) return;