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

+21 -1
View File
@@ -1,5 +1,15 @@
package templates
import "fmt"
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
func effectiveAudioSpeed(user User) float64 {
if user.DefaultAudioSpeed == 0 {
return 1
}
return user.DefaultAudioSpeed
}
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
<form
hx-put={ actionURL }
@@ -66,6 +76,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Default Playback Speed</label>
<select
name="default_audio_speed"
class="input"
>
for _, rate := range SpeedOptions {
<option value={ fmt.Sprintf("%g", rate) } selected?={ effectiveAudioSpeed(user) == rate }>{ fmt.Sprintf("%g", rate) }×</option>
}
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
<select
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
type="password"
name="current_password"
class="input"
required
/>
</div>
<div>