feat(audio-player): settings popover + autoplay-on-open (plan §4)

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:<seconds> 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.
This commit is contained in:
John O'Keefe committed 2026-10-22 09:04:03 -04:00
1 parent fe7eab91e8
commit 18bed79d3a
4 files changed
+115 -7

No files matched your search

+23 -1
View File
@@ -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);
});
});
+40 -2
View File
@@ -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<typeof setInterval> | null,
sleepFadeTimer: null as ReturnType<typeof setInterval> | 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;