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:
1 parent
408a972665
commit
db96ea91cd
4 files changed
+91
-5
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"));
|
||||||
|
|||||||
Reference in new issue
Block a user