")
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;