diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e5980384..0a702213 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -16,6 +16,7 @@ import ( "math" "net/http" "os" + "strconv" "strings" "time" @@ -126,6 +127,10 @@ type UserProfile struct { DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"` // Whether the web reader/player should hold a Screen Wake Lock. KeepScreenAwake bool `json:"keep_screen_awake"` + // Resume rewind: seconds before the saved position the audio + // player starts (open-resume and every genuine unpause). 0 is a + // real value (exact resume) — never omitted. + AudioResumeRewind int `json:"audio_resume_rewind_seconds"` } type ProfileUpdateRequest struct { @@ -142,6 +147,11 @@ type ProfileUpdateRequest struct { // 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"` + // Resume rewind seconds, as the form select sends it ("0"/"5"/"10"). + // The string shape (keep_screen_awake precedent) lets the form + // select an explicit 0=Off while JSON clients that omit it decode + // as "" and leave the column untouched. + AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"` } // Register handles POST /api/auth/register @@ -551,6 +561,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error { Theme: user.Theme.String, DefaultAudioSpeed: float64(user.DefaultAudioSpeed), KeepScreenAwake: user.KeepScreenAwake, + AudioResumeRewind: int(user.AudioResumeRewindSeconds), }) } @@ -715,6 +726,24 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error { } } + // Resume-rewind preference. The form select always sends it + // ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave + // the column untouched. Strict allow-list — the three choices the + // UI offers are the only values the API accepts. + if req.AudioResumeRewind != "" { + rewind, err := strconv.Atoi(req.AudioResumeRewind) + if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"}) + } + err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{ + ID: targetUserUUID, + AudioResumeRewindSeconds: int32(rewind), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + } + // Keep-screen-awake preference. The form dropdown always sends it // ("true"/"false"); JSON clients that omit it decode as "" and leave // the column untouched. diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 9b484d88..080dc082 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -3101,13 +3101,18 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error { // 1.0 IN the payload so the player applies it with zero extra round // trips; default_playback_speed powers the dropdown's "(default)" // marker. Speed is a preference, so this rides the authenticated - // user — anonymous readers just get 1.0. + // user — anonymous readers just get 1.0. The resume rewind rides + // the same user row (0 = exact resume). playbackSpeed, defaultSpeed := 1.0, 1.0 + resumeRewind := 0 if userID, ok := c.Get("user_id").(string); ok { if userUUID, err := uuid.Parse(userID); err == nil { userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}) - if err == nil && userRow.DefaultAudioSpeed > 0 { - defaultSpeed = float64(userRow.DefaultAudioSpeed) + if err == nil { + if userRow.DefaultAudioSpeed > 0 { + defaultSpeed = float64(userRow.DefaultAudioSpeed) + } + resumeRewind = int(userRow.AudioResumeRewindSeconds) } bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, @@ -3130,6 +3135,7 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error { "tracks": tracks, "playback_speed": playbackSpeed, "default_playback_speed": defaultSpeed, + "resume_rewind_seconds": resumeRewind, }) } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 40b106f0..d31ed5a1 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -76,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err LastName: utils.PgTextString(userDB.LastName), CreatedAt: userDB.CreatedAt.Time, DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed), + AudioResumeRewind: int(userDB.AudioResumeRewindSeconds), KeepScreenAwake: userDB.KeepScreenAwake, ShowPodcastsNav: showPodcasts, }, nil diff --git a/templates/profile_form.templ b/templates/profile_form.templ index 5633adca..d56f0351 100644 --- a/templates/profile_form.templ +++ b/templates/profile_form.templ @@ -87,6 +87,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show } +
+ + +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">Japan/Korea (UTC+9) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showRoleField { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, ">Admin") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -526,12 +556,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

Change Password

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

Change Password

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if requireCurrentPassword { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "Update Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

As an admin, you can change this user's password without knowing their current password.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "Reset Password
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if showCancelButton { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "Save Changes
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index f86438a0..93a1fc0a 100644 --- a/templates/types.go +++ b/templates/types.go @@ -20,6 +20,9 @@ type User struct { // User-level default audiobook playback speed (plan §8); 0 means // "unset" and renders as the 1× option. DefaultAudioSpeed float64 + // Resume rewind: seconds before the saved position the audio + // player starts (0 = exact resume; allow-list 0/5/10). + AudioResumeRewind int // Whether the web reader/player should hold a Screen Wake Lock. KeepScreenAwake bool // Sidebar /podcasts entry gate (plan D13): the user can see >=1