refactor(audio-player): one source for the speed list (plan §5)

The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
This commit is contained in:
John O'Keefe committed 2026-10-21 21:08:27 -04:00
1 parent 408a972665
commit db96ea91cd
4 files changed
+91 -5

No files matched your search

+1 -1
View File
@@ -249,7 +249,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]" class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;" style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
> >
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate"> <template x-for="rate in speeds" :key="'speed' + rate">
<button <button
type="button" type="button"
@click="setSpeed(rate); speedOpen = false" @click="setSpeed(rate); speedOpen = false"
+1 -1
View File
@@ -194,7 +194,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" 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></Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" 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></Line truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { SPEEDS, normalizeSpeed, stepSpeed } from "./audio";
describe("SPEEDS", () => {
it("is the agreed list from the player plan (0.5 through 3)", () => {
expect(SPEEDS).toEqual([0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]);
});
it("contains 1× exactly once and stays ascending", () => {
expect(SPEEDS.filter((r) => r === 1)).toHaveLength(1);
expect([...SPEEDS].sort((a, b) => a - b)).toEqual([...SPEEDS]);
});
});
describe("normalizeSpeed", () => {
it("passes through every listed rate", () => {
for (const rate of SPEEDS) {
expect(normalizeSpeed(String(rate))).toBe(rate);
}
});
it("falls back to 1× for null, garbage and out-of-list values", () => {
expect(normalizeSpeed(null)).toBe(1);
expect(normalizeSpeed("")).toBe(1);
expect(normalizeSpeed("not-a-number")).toBe(1);
expect(normalizeSpeed("0")).toBe(1);
expect(normalizeSpeed("2.2")).toBe(1);
expect(normalizeSpeed("10")).toBe(1);
});
it("accepts a stale pre-plan entry of 0.75 or 2", () => {
expect(normalizeSpeed("0.75")).toBe(0.75);
expect(normalizeSpeed("2")).toBe(2);
});
});
describe("stepSpeed", () => {
it("steps forward and backward through the list", () => {
expect(stepSpeed(1, 1)).toBe(1.25);
expect(stepSpeed(1, -1)).toBe(0.75);
expect(stepSpeed(0.5, 1)).toBe(0.75);
expect(stepSpeed(2, 1)).toBe(2.5);
});
it("clamps at both ends instead of wrapping", () => {
expect(stepSpeed(0.5, -1)).toBe(0.5);
expect(stepSpeed(3, 1)).toBe(3);
});
it("anchors unknown currents at 1× before stepping", () => {
expect(stepSpeed(2.2, 1)).toBe(1.25);
expect(stepSpeed(2.2, -1)).toBe(0.75);
});
});
+34 -3
View File
@@ -52,6 +52,28 @@ export function globalSecondsFor(
return (track ? track.start : 0) + Math.max(0, localSeconds); return (track ? track.start : 0) + Math.max(0, localSeconds);
} }
// Playback speeds — ONE source. The templ dropdown iterates it through
// the component's `speeds` property, and keyboard stepping (audio-keys,
// player plan §2) walks the same array through stepSpeed. Never hardcode
// a rate list elsewhere.
export const SPEEDS: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
// normalizeSpeed maps a persisted speed onto the SPEEDS list: missing,
// malformed or out-of-list values (e.g. a stale localStorage entry from
// before the list changed) fall back to 1×.
export function normalizeSpeed(value: string | null): number {
const n = Number(value);
return SPEEDS.includes(n) ? n : 1;
}
// stepSpeed moves through SPEEDS by delta, clamping at both ends; an
// unknown current anchors at 1× before stepping.
export function stepSpeed(current: number, delta: 1 | -1): number {
const idx = SPEEDS.indexOf(current);
const base = idx === -1 ? SPEEDS.indexOf(1) : idx;
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
}
const PROGRESS_INTERVAL_MS = 30_000; const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig { interface AudioConfig {
@@ -85,7 +107,10 @@ Alpine.data("audioReader", () => ({
chaptersOpen: false, chaptersOpen: false,
currentTime: 0, currentTime: 0,
playing: false, playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1), speed: normalizeSpeed(localStorage.getItem("audio_speed")),
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
skipSeconds: Number(localStorage.getItem("audio_skip") || 10), skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// Scrub guard: while dragging, timeupdate must not fight the handle // Scrub guard: while dragging, timeupdate must not fight the handle
scrubbing: false, scrubbing: false,
@@ -143,6 +168,12 @@ Alpine.data("audioReader", () => ({
init() { init() {
if (this.initialized) return; // Alpine can re-init the body tree if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true; this.initialized = true;
// Heal a stale/out-of-list persisted speed so the storage matches
// what the player actually uses (normalizeSpeed already fixed the
// in-memory value at state creation).
if (localStorage.getItem("audio_speed") !== String(this.speed)) {
localStorage.setItem("audio_speed", String(this.speed));
}
window.addEventListener("error", (e) => { window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown"); document.title = "ERR: " + (e.message || "unknown");
}); });
@@ -163,7 +194,7 @@ Alpine.data("audioReader", () => ({
// src is set by restorePosition once the track manifest is known — // src is set by restorePosition once the track manifest is known —
// for multi-file books data-file-url (a directory or a zip root) is // for multi-file books data-file-url (a directory or a zip root) is
// NOT directly playable. The catch branch below falls back to it. // NOT directly playable. The catch branch below falls back to it.
audio.playbackRate = Number(this.speed) || 1; audio.playbackRate = this.speed;
audio.preload = "auto"; audio.preload = "auto";
// Restored volume/mute from the settings strip's persisted state. // Restored volume/mute from the settings strip's persisted state.
audio.volume = Math.min(1, Math.max(0, Number(this.volume))); audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
@@ -303,7 +334,7 @@ Alpine.data("audioReader", () => ({
} else { } else {
this.pendingSeek = Math.max(0, localSeconds); this.pendingSeek = Math.max(0, localSeconds);
audio.setAttribute("src", track.url); audio.setAttribute("src", track.url);
audio.playbackRate = Number(this.speed) || 1; audio.playbackRate = this.speed;
audio.load(); audio.load();
} }
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error")); if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));