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

+22 -12
View File
@@ -8,6 +8,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AudioPlaybackSpeed struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type CollectionItems struct {
ID pgtype.UUID `db:"id" json:"id"`
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
@@ -575,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
+4
View File
@@ -202,6 +202,8 @@ type Querier interface {
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
// Per-book playback speed preference (player plan §8).
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
// Lifetime session rows for ONE book (the book-detail stats card).
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
@@ -696,12 +698,14 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
+71 -13
View File
@@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
return items, nil
}
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2
`
type GetAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
// Per-book playback speed preference (player plan §8).
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
SELECT
rh.id, rh.device_id, rh.progress_percentage,
@@ -9922,24 +9941,26 @@ SELECT
u.created_at,
u.updated_at,
u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
`
type GetUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DeviceCount int64 `db:"device_count" json:"device_count"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
Theme pgtype.Text `db:"theme" json:"theme"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.CreatedAt,
&i.UpdatedAt,
&i.Timezone,
&i.DefaultAudioSpeed,
&i.DeviceCount,
)
return i, err
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err
}
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserDefaultAudioSpeedParams struct {
ID pgtype.UUID `db:"id" json:"id"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
return err
}
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
`
type UpdateUserMaxDevicesParams struct {
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.CreatedAt,
&i.Timezone,
&i.UpdatedAt,
&i.DefaultAudioSpeed,
)
return i, err
}
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
return err
}
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed
`
type UpsertAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
}
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
+17
View File
@@ -28,6 +28,7 @@ SELECT
u.created_at,
u.updated_at,
u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2;
-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';