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

+32 -7
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"html/template"
"math"
"net/http"
"os"
"path/filepath"
@@ -120,6 +121,8 @@ type UserProfile struct {
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
}
type UpdateProfileRequest struct {
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
type AdminUpdateUserRequest struct {
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
// Register handles POST /api/auth/register
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
lastName = user.LastName.String
}
return c.JSON(http.StatusOK, UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
})
}
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Update the default audiobook playback speed (plan §8). The form
// select always sends it; JSON clients that omit it decode as 0 and
// leave the column untouched.
if req.DefaultAudioSpeed != 0 {
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
}
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
ID: targetUserUUID,
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as