feat(audio-player): per-book playback speed + user default (plan §8)

Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
This commit is contained in:
John O'Keefe committed 2026-10-22 15:01:41 -04:00
1 parent 3325b64463
commit 3576a4415a
16 files changed
+680 -148

No files matched your search

+47
View File
@@ -196,6 +196,9 @@ Alpine.data("audioReader", () => ({
currentTime: 0,
playing: false,
speed: normalizeSpeed(localStorage.getItem("audio_speed")),
// User-level default speed (plan §8): powers the dropdown's
// "(default)" marker; hydrated from the chapters payload.
defaultSpeed: 1,
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
@@ -501,6 +504,17 @@ Alpine.data("audioReader", () => ({
const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
// §8: the server owns speed — per-book row, else the user's
// default. localStorage stays only as a first-paint cache; the
// payload value wins over it BEFORE the rate is (re)applied to
// the element below.
const serverSpeed = Number(data.playback_speed);
if (SPEEDS.includes(serverSpeed)) {
this.speed = serverSpeed;
localStorage.setItem("audio_speed", String(serverSpeed));
}
const serverDefault = Number(data.default_playback_speed);
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
this.buildTracks(data);
this.recomputeTicks();
const audio = this.audio();
@@ -624,6 +638,39 @@ Alpine.data("audioReader", () => ({
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
// §8: the per-book speed lives server-side (localStorage is only a
// first-paint cache). Fire-and-forget; a failure toasts — the rate
// still applies to this session.
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
})
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
},
// setDefaultSpeed pins a user-level default (plan §8): books without
// their own per-book speed pick it up. Rides the profile endpoint,
// same as the profile-page select.
setDefaultSpeed(rate: number) {
fetch("/api/auth/profile", {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ default_audio_speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
this.defaultSpeed = rate;
showToast(`Default playback speed set to ${rate}×`, "success");
})
.catch(() => showToast("Default speed not saved", "error"));
},
isDefaultSpeed(rate: number): boolean {
return Number(this.defaultSpeed) === rate;
},
setSkip(seconds: number) {