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

+48
View File
@@ -88,6 +88,54 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
</svg>
</button>
<div class="relative" @click.outside="settingsOpen = false">
<button
type="button"
@click="settingsOpen = !settingsOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Player settings"
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<div
x-show="settingsOpen"
x-cloak
x-transition
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
>
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<!-- Option rows: autoplay first, panel structured for more -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
<button
type="button"
role="switch"
:aria-checked="autoplayOn ? 'true' : 'false'"
@click="toggleAutoplay()"
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
aria-label="Autoplay on open"
>
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
</button>
</div>
</div>
</div>
</span>
</div>
+4 -4
View File
@@ -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, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" 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></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" 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> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Keyboard shortcuts\" title=\"Keyboard shortcuts (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" 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></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" 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> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Keyboard shortcuts\" title=\"Keyboard shortcuts (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" strokLine truncated
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 {
+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;