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:
1 parent
3325b64463
commit
3576a4415a
16 files changed
+680
-148
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user