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
+34
-3
@@ -52,6 +52,28 @@ export function globalSecondsFor(
|
||||
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;
|
||||
|
||||
interface AudioConfig {
|
||||
@@ -85,7 +107,10 @@ Alpine.data("audioReader", () => ({
|
||||
chaptersOpen: false,
|
||||
currentTime: 0,
|
||||
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),
|
||||
// Scrub guard: while dragging, timeupdate must not fight the handle
|
||||
scrubbing: false,
|
||||
@@ -143,6 +168,12 @@ Alpine.data("audioReader", () => ({
|
||||
init() {
|
||||
if (this.initialized) return; // Alpine can re-init the body tree
|
||||
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) => {
|
||||
document.title = "ERR: " + (e.message || "unknown");
|
||||
});
|
||||
@@ -163,7 +194,7 @@ Alpine.data("audioReader", () => ({
|
||||
// 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
|
||||
// NOT directly playable. The catch branch below falls back to it.
|
||||
audio.playbackRate = Number(this.speed) || 1;
|
||||
audio.playbackRate = this.speed;
|
||||
audio.preload = "auto";
|
||||
// Restored volume/mute from the settings strip's persisted state.
|
||||
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
|
||||
@@ -303,7 +334,7 @@ Alpine.data("audioReader", () => ({
|
||||
} else {
|
||||
this.pendingSeek = Math.max(0, localSeconds);
|
||||
audio.setAttribute("src", track.url);
|
||||
audio.playbackRate = Number(this.speed) || 1;
|
||||
audio.playbackRate = this.speed;
|
||||
audio.load();
|
||||
}
|
||||
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
|
||||
|
||||
Reference in new issue
Block a user