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
+22
-12
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user