Procházet zdrojové kódy

feat: Media player: Conrol playback speed
fix #1845

Romain de Laage před 2 roky
rodič
revize
00dabc1d3c

+ 24 - 22
client/model.go

@@ -41,6 +41,7 @@ type User struct {
 	DefaultHomePage        string     `json:"default_home_page"`
 	CategoriesSortingOrder string     `json:"categories_sorting_order"`
 	MarkReadOnView         bool       `json:"mark_read_on_view"`
+	MediaPlaybackRate      float64    `json:"media_playback_rate"`
 }
 
 func (u User) String() string {
@@ -58,28 +59,29 @@ type UserCreationRequest struct {
 
 // UserModificationRequest represents the request to update a user.
 type UserModificationRequest struct {
-	Username               *string `json:"username"`
-	Password               *string `json:"password"`
-	IsAdmin                *bool   `json:"is_admin"`
-	Theme                  *string `json:"theme"`
-	Language               *string `json:"language"`
-	Timezone               *string `json:"timezone"`
-	EntryDirection         *string `json:"entry_sorting_direction"`
-	EntryOrder             *string `json:"entry_sorting_order"`
-	Stylesheet             *string `json:"stylesheet"`
-	GoogleID               *string `json:"google_id"`
-	OpenIDConnectID        *string `json:"openid_connect_id"`
-	EntriesPerPage         *int    `json:"entries_per_page"`
-	KeyboardShortcuts      *bool   `json:"keyboard_shortcuts"`
-	ShowReadingTime        *bool   `json:"show_reading_time"`
-	EntrySwipe             *bool   `json:"entry_swipe"`
-	GestureNav             *string `json:"gesture_nav"`
-	DisplayMode            *string `json:"display_mode"`
-	DefaultReadingSpeed    *int    `json:"default_reading_speed"`
-	CJKReadingSpeed        *int    `json:"cjk_reading_speed"`
-	DefaultHomePage        *string `json:"default_home_page"`
-	CategoriesSortingOrder *string `json:"categories_sorting_order"`
-	MarkReadOnView         *bool   `json:"mark_read_on_view"`
+	Username               *string  `json:"username"`
+	Password               *string  `json:"password"`
+	IsAdmin                *bool    `json:"is_admin"`
+	Theme                  *string  `json:"theme"`
+	Language               *string  `json:"language"`
+	Timezone               *string  `json:"timezone"`
+	EntryDirection         *string  `json:"entry_sorting_direction"`
+	EntryOrder             *string  `json:"entry_sorting_order"`
+	Stylesheet             *string  `json:"stylesheet"`
+	GoogleID               *string  `json:"google_id"`
+	OpenIDConnectID        *string  `json:"openid_connect_id"`
+	EntriesPerPage         *int     `json:"entries_per_page"`
+	KeyboardShortcuts      *bool    `json:"keyboard_shortcuts"`
+	ShowReadingTime        *bool    `json:"show_reading_time"`
+	EntrySwipe             *bool    `json:"entry_swipe"`
+	GestureNav             *string  `json:"gesture_nav"`
+	DisplayMode            *string  `json:"display_mode"`
+	DefaultReadingSpeed    *int     `json:"default_reading_speed"`
+	CJKReadingSpeed        *int     `json:"cjk_reading_speed"`
+	DefaultHomePage        *string  `json:"default_home_page"`
+	CategoriesSortingOrder *string  `json:"categories_sorting_order"`
+	MarkReadOnView         *bool    `json:"mark_read_on_view"`
+	MediaPlaybackRate      *float64 `json:"media_playback_rate"`
 }
 
 // Users represents a list of users.

+ 5 - 0
internal/database/migrations.go

@@ -871,4 +871,9 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `ALTER TABLE users ADD COLUMN media_playback_rate numeric default 1;`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 3 - 1
internal/locale/translations/de_DE.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Dieses Abonnement kann nicht gelesen werden: %v.",
     "error.feed_not_found": "Dieses Abonnement existiert nicht oder gehört nicht zu diesem Benutzer.",
     "error.unable_to_detect_rssbridge": "Abonnement kann nicht durch RSS-Bridge erkannt werden: %v.",
-    "error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v."
+    "error.feed_format_not_detected": "Das Format des Abonnements kann nicht erkannt werden: %v.",
+    "form.prefs.label.media_playback_rate": "Wiedergabegeschwindigkeit von Audio/Video",
+    "error.settings_media_playback_rate_range": "Die Wiedergabegeschwindigkeit liegt außerhalb des Bereichs"
 }

+ 3 - 1
internal/locale/translations/el_EL.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Ταχύτητα αναπαραγωγής του ήχου/βίντεο",
+    "error.settings_media_playback_rate_range": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους"
 }

+ 3 - 1
internal/locale/translations/en_US.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Playback speed of the audio/video",
+    "error.settings_media_playback_rate_range": "Playback speed is out of range"
 }

+ 3 - 1
internal/locale/translations/es_ES.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Velocidad de reproducción del audio/vídeo",
+    "error.settings_media_playback_rate_range": "La velocidad de reproducción está fuera de rango"
 }

+ 3 - 1
internal/locale/translations/fi_FI.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Äänen/videon toistonopeus",
+    "error.settings_media_playback_rate_range": "Toistonopeus on alueen ulkopuolella"
 }

+ 3 - 1
internal/locale/translations/fr_FR.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Impossible d'analyser ce flux : %v.",
     "error.feed_not_found": "Impossible de trouver ce flux.",
     "error.unable_to_detect_rssbridge": "Impossible de détecter un flux RSS en utilisant RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Impossible de détecter le format du flux : %v."
+    "error.feed_format_not_detected": "Impossible de détecter le format du flux : %v.",
+    "form.prefs.label.media_playback_rate": "Vitesse de lecture de l'audio/vidéo",
+    "error.settings_media_playback_rate_range": "La vitesse de lecture est hors limites"
 }

+ 3 - 1
internal/locale/translations/hi_IN.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "ऑडियो/वीडियो की प्लेबैक गति",
+    "error.settings_media_playback_rate_range": "प्लेबैक गति सीमा से बाहर है"
 }

+ 3 - 1
internal/locale/translations/id_ID.json

@@ -507,5 +507,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Kecepatan pemutaran audio/video",
+    "error.settings_media_playback_rate_range": "Kecepatan pemutaran di luar jangkauan"
 }

+ 3 - 1
internal/locale/translations/it_IT.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Velocità di riproduzione dell'audio/video",
+    "error.settings_media_playback_rate_range": "La velocità di riproduzione non rientra nell'intervallo"
 }

+ 3 - 1
internal/locale/translations/ja_JP.json

@@ -507,5 +507,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "オーディオ/ビデオの再生速度",
+    "error.settings_media_playback_rate_range": "再生速度が範囲外"
 }

+ 3 - 1
internal/locale/translations/nl_NL.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Afspeelsnelheid van de audio/video",
+    "error.settings_media_playback_rate_range": "Afspeelsnelheid is buiten bereik"
 }

+ 3 - 1
internal/locale/translations/pl_PL.json

@@ -541,5 +541,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
+    "error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem"
 }

+ 3 - 1
internal/locale/translations/pt_BR.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Velocidade de reprodução do áudio/vídeo",
+    "error.settings_media_playback_rate_range": "A velocidade de reprodução está fora do intervalo"
 }

+ 3 - 1
internal/locale/translations/ru_RU.json

@@ -541,5 +541,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Скорость воспроизведения аудио/видео",
+    "error.settings_media_playback_rate_range": "Скорость воспроизведения выходит за пределы диапазона"
 }

+ 3 - 1
internal/locale/translations/tr_TR.json

@@ -524,5 +524,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Ses/video oynatma hızı",
+    "error.settings_media_playback_rate_range": "Oynatma hızı aralık dışında"
 }

+ 3 - 1
internal/locale/translations/uk_UA.json

@@ -541,5 +541,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "Швидкість відтворення аудіо/відео",
+    "error.settings_media_playback_rate_range": "Швидкість відтворення виходить за межі діапазону"
 }

+ 3 - 1
internal/locale/translations/zh_CN.json

@@ -507,5 +507,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "音频/视频的播放速度",
+    "error.settings_media_playback_rate_range": "播放速度超出范围"
 }

+ 3 - 1
internal/locale/translations/zh_TW.json

@@ -507,5 +507,7 @@
     "error.unable_to_parse_feed": "Unable to parse this feed: %v.",
     "error.feed_not_found": "This feed does not exist or does not belong to this user.",
     "error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
-    "error.feed_format_not_detected": "Unable to detect feed format: %v."
+    "error.feed_format_not_detected": "Unable to detect feed format: %v.",
+    "form.prefs.label.media_playback_rate": "音訊/視訊的播放速度",
+    "error.settings_media_playback_rate_range": "播放速度超出範圍"
 }

+ 8 - 0
internal/model/model.go

@@ -26,3 +26,11 @@ func OptionalInt64(value int64) *int64 {
 	}
 	return nil
 }
+
+// OptionalFloat populates an optional float64 field.
+func OptionalFloat(value float64) *float64 {
+	if value > 0 {
+		return &value
+	}
+	return nil
+}

+ 28 - 22
internal/model/user.go

@@ -35,6 +35,7 @@ type User struct {
 	DefaultHomePage        string     `json:"default_home_page"`
 	CategoriesSortingOrder string     `json:"categories_sorting_order"`
 	MarkReadOnView         bool       `json:"mark_read_on_view"`
+	MediaPlaybackRate      float64    `json:"media_playback_rate"`
 }
 
 // UserCreationRequest represents the request to create a user.
@@ -48,28 +49,29 @@ type UserCreationRequest struct {
 
 // UserModificationRequest represents the request to update a user.
 type UserModificationRequest struct {
-	Username               *string `json:"username"`
-	Password               *string `json:"password"`
-	Theme                  *string `json:"theme"`
-	Language               *string `json:"language"`
-	Timezone               *string `json:"timezone"`
-	EntryDirection         *string `json:"entry_sorting_direction"`
-	EntryOrder             *string `json:"entry_sorting_order"`
-	Stylesheet             *string `json:"stylesheet"`
-	GoogleID               *string `json:"google_id"`
-	OpenIDConnectID        *string `json:"openid_connect_id"`
-	EntriesPerPage         *int    `json:"entries_per_page"`
-	IsAdmin                *bool   `json:"is_admin"`
-	KeyboardShortcuts      *bool   `json:"keyboard_shortcuts"`
-	ShowReadingTime        *bool   `json:"show_reading_time"`
-	EntrySwipe             *bool   `json:"entry_swipe"`
-	GestureNav             *string `json:"gesture_nav"`
-	DisplayMode            *string `json:"display_mode"`
-	DefaultReadingSpeed    *int    `json:"default_reading_speed"`
-	CJKReadingSpeed        *int    `json:"cjk_reading_speed"`
-	DefaultHomePage        *string `json:"default_home_page"`
-	CategoriesSortingOrder *string `json:"categories_sorting_order"`
-	MarkReadOnView         *bool   `json:"mark_read_on_view"`
+	Username               *string  `json:"username"`
+	Password               *string  `json:"password"`
+	Theme                  *string  `json:"theme"`
+	Language               *string  `json:"language"`
+	Timezone               *string  `json:"timezone"`
+	EntryDirection         *string  `json:"entry_sorting_direction"`
+	EntryOrder             *string  `json:"entry_sorting_order"`
+	Stylesheet             *string  `json:"stylesheet"`
+	GoogleID               *string  `json:"google_id"`
+	OpenIDConnectID        *string  `json:"openid_connect_id"`
+	EntriesPerPage         *int     `json:"entries_per_page"`
+	IsAdmin                *bool    `json:"is_admin"`
+	KeyboardShortcuts      *bool    `json:"keyboard_shortcuts"`
+	ShowReadingTime        *bool    `json:"show_reading_time"`
+	EntrySwipe             *bool    `json:"entry_swipe"`
+	GestureNav             *string  `json:"gesture_nav"`
+	DisplayMode            *string  `json:"display_mode"`
+	DefaultReadingSpeed    *int     `json:"default_reading_speed"`
+	CJKReadingSpeed        *int     `json:"cjk_reading_speed"`
+	DefaultHomePage        *string  `json:"default_home_page"`
+	CategoriesSortingOrder *string  `json:"categories_sorting_order"`
+	MarkReadOnView         *bool    `json:"mark_read_on_view"`
+	MediaPlaybackRate      *float64 `json:"media_playback_rate"`
 }
 
 // Patch updates the User object with the modification request.
@@ -161,6 +163,10 @@ func (u *UserModificationRequest) Patch(user *User) {
 	if u.MarkReadOnView != nil {
 		user.MarkReadOnView = *u.MarkReadOnView
 	}
+
+	if u.MediaPlaybackRate != nil {
+		user.MediaPlaybackRate = *u.MediaPlaybackRate
+	}
 }
 
 // UseTimezone converts last login date to the given timezone.

+ 23 - 10
internal/storage/user.go

@@ -91,7 +91,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 			cjk_reading_speed,
 			default_home_page,
 			categories_sorting_order,
-			mark_read_on_view
+			mark_read_on_view,
+			media_playback_rate
 	`
 
 	tx, err := s.db.Begin()
@@ -130,6 +131,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 		&user.DefaultHomePage,
 		&user.CategoriesSortingOrder,
 		&user.MarkReadOnView,
+		&user.MediaPlaybackRate,
 	)
 	if err != nil {
 		tx.Rollback()
@@ -186,9 +188,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				cjk_reading_speed=$19,
 				default_home_page=$20,
 				categories_sorting_order=$21,
-				mark_read_on_view=$22
+				mark_read_on_view=$22,
+				media_playback_rate=$23
 			WHERE
-				id=$23
+				id=$24
 		`
 
 		_, err = s.db.Exec(
@@ -215,6 +218,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.DefaultHomePage,
 			user.CategoriesSortingOrder,
 			user.MarkReadOnView,
+			user.MediaPlaybackRate,
 			user.ID,
 		)
 		if err != nil {
@@ -243,9 +247,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				cjk_reading_speed=$18,
 				default_home_page=$19,
 				categories_sorting_order=$20,
-				mark_read_on_view=$21
+				mark_read_on_view=$21,
+				media_playback_rate=$22
 			WHERE
-				id=$22
+				id=$23
 		`
 
 		_, err := s.db.Exec(
@@ -271,6 +276,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.DefaultHomePage,
 			user.CategoriesSortingOrder,
 			user.MarkReadOnView,
+			user.MediaPlaybackRate,
 			user.ID,
 		)
 
@@ -318,7 +324,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
 			cjk_reading_speed,
 			default_home_page,
 			categories_sorting_order,
-			mark_read_on_view
+			mark_read_on_view,
+			media_playback_rate
 		FROM
 			users
 		WHERE
@@ -353,7 +360,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
 			cjk_reading_speed,
 			default_home_page,
 			categories_sorting_order,
-			mark_read_on_view
+			mark_read_on_view,
+			media_playback_rate
 		FROM
 			users
 		WHERE
@@ -388,7 +396,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
 			cjk_reading_speed,
 			default_home_page,
 			categories_sorting_order,
-			mark_read_on_view
+			mark_read_on_view,
+			media_playback_rate
 		FROM
 			users
 		WHERE
@@ -430,7 +439,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
 			u.cjk_reading_speed,
 			u.default_home_page,
 			u.categories_sorting_order,
-			u.mark_read_on_view
+			u.mark_read_on_view,
+			media_playback_rate
 		FROM
 			users u
 		LEFT JOIN
@@ -467,6 +477,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
 		&user.DefaultHomePage,
 		&user.CategoriesSortingOrder,
 		&user.MarkReadOnView,
+		&user.MediaPlaybackRate,
 	)
 
 	if err == sql.ErrNoRows {
@@ -574,7 +585,8 @@ func (s *Storage) Users() (model.Users, error) {
 			cjk_reading_speed,
 			default_home_page,
 			categories_sorting_order,
-			mark_read_on_view
+			mark_read_on_view,
+			media_playback_rate
 		FROM
 			users
 		ORDER BY username ASC
@@ -612,6 +624,7 @@ func (s *Storage) Users() (model.Users, error) {
 			&user.DefaultHomePage,
 			&user.CategoriesSortingOrder,
 			&user.MarkReadOnView,
+			&user.MediaPlaybackRate,
 		)
 
 		if err != nil {

+ 4 - 0
internal/template/templates/views/entry.html

@@ -172,6 +172,7 @@
     <div class="enclosure-audio" >
         <audio controls preload="metadata"
             data-last-position="{{ .MediaProgression }}"
+            {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
             data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
             >
             {{ if (and $.user (mustBeProxyfied "audio")) }}
@@ -185,6 +186,7 @@
         <div class="enclosure-video">
             <video controls preload="metadata"
                 data-last-position="{{ .MediaProgression }}"
+                {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
                 data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
                 >
                 {{ if (and $.user (mustBeProxyfied "video")) }}
@@ -214,6 +216,7 @@
         <div class="enclosure-audio">
             <audio controls preload="metadata"
                 data-last-position="{{ .MediaProgression }}"
+                {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
                 data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
                 >
                 {{ if (and $.user (mustBeProxyfied "audio")) }}
@@ -227,6 +230,7 @@
         <div class="enclosure-video">
             <video controls preload="metadata"
                 data-last-position="{{ .MediaProgression }}"
+                {{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
                 data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
                 >
                 {{ if (and $.user (mustBeProxyfied "video")) }}

+ 3 - 0
internal/template/templates/views/settings.html

@@ -108,6 +108,9 @@
         <label for="form-default-reading-speed">{{ t "form.prefs.label.default_reading_speed" }}</label>
         <input type="number" name="default_reading_speed" id="form-default-reading-speed" value="{{ .form.DefaultReadingSpeed }}" min="1">
 
+        <label for="form-media-playback-rate">{{ t "form.prefs.label.media_playback_rate" }}</label>
+        <input type="number" name="media_playback_rate" id="form-media-playback-rate" value="{{ .form.MediaPlaybackRate }}" min="0.25" max="4" step="any" />
+
         <label><input type="checkbox" name="show_reading_time" value="1" {{ if .form.ShowReadingTime }}checked{{ end }}> {{ t "form.prefs.label.show_reading_time" }}</label>
 
         <label><input type="checkbox" name="mark_read_on_view" value="1" {{ if .form.MarkReadOnView }}checked{{ end }}> {{ t "form.prefs.label.mark_read_on_view" }}</label>

+ 11 - 0
internal/ui/form/settings.go

@@ -33,6 +33,7 @@ type SettingsForm struct {
 	DefaultHomePage        string
 	CategoriesSortingOrder string
 	MarkReadOnView         bool
+	MediaPlaybackRate      float64
 }
 
 // Merge updates the fields of the given user.
@@ -55,6 +56,7 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
 	user.DefaultHomePage = s.DefaultHomePage
 	user.CategoriesSortingOrder = s.CategoriesSortingOrder
 	user.MarkReadOnView = s.MarkReadOnView
+	user.MediaPlaybackRate = s.MediaPlaybackRate
 
 	if s.Password != "" {
 		user.Password = s.Password
@@ -84,6 +86,10 @@ func (s *SettingsForm) Validate() *locale.LocalizedError {
 		}
 	}
 
+	if s.MediaPlaybackRate < 0.25 || s.MediaPlaybackRate > 4 {
+		return locale.NewLocalizedError("error.settings_media_playback_rate_range")
+	}
+
 	return nil
 }
 
@@ -101,6 +107,10 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
 	if err != nil {
 		cjkReadingSpeed = 0
 	}
+	mediaPlaybackRate, err := strconv.ParseFloat(r.FormValue("media_playback_rate"), 64)
+	if err != nil {
+		mediaPlaybackRate = 1
+	}
 	return &SettingsForm{
 		Username:               r.FormValue("username"),
 		Password:               r.FormValue("password"),
@@ -122,5 +132,6 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
 		DefaultHomePage:        r.FormValue("default_home_page"),
 		CategoriesSortingOrder: r.FormValue("categories_sorting_order"),
 		MarkReadOnView:         r.FormValue("mark_read_on_view") == "1",
+		MediaPlaybackRate:      mediaPlaybackRate,
 	}
 }

+ 3 - 0
internal/ui/form/settings_test.go

@@ -22,6 +22,7 @@ func TestValid(t *testing.T) {
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",
+		MediaPlaybackRate:   1.25,
 	}
 
 	err := settings.Validate()
@@ -45,6 +46,7 @@ func TestConfirmationEmpty(t *testing.T) {
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",
+		MediaPlaybackRate:   1.25,
 	}
 
 	err := settings.Validate()
@@ -72,6 +74,7 @@ func TestConfirmationIncorrect(t *testing.T) {
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",
+		MediaPlaybackRate:   1.25,
 	}
 
 	err := settings.Validate()

+ 1 - 0
internal/ui/settings_show.go

@@ -41,6 +41,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
 		DefaultHomePage:        user.DefaultHomePage,
 		CategoriesSortingOrder: user.CategoriesSortingOrder,
 		MarkReadOnView:         user.MarkReadOnView,
+		MediaPlaybackRate:      user.MediaPlaybackRate,
 	}
 
 	timezones, err := h.store.Timezones()

+ 1 - 0
internal/ui/settings_update.go

@@ -62,6 +62,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 		DefaultReadingSpeed: model.OptionalInt(settingsForm.DefaultReadingSpeed),
 		CJKReadingSpeed:     model.OptionalInt(settingsForm.CJKReadingSpeed),
 		DefaultHomePage:     model.OptionalString(settingsForm.DefaultHomePage),
+		MediaPlaybackRate:   model.OptionalFloat(settingsForm.MediaPlaybackRate),
 	}
 
 	if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {

+ 10 - 2
internal/ui/static/js/bootstrap.js

@@ -152,11 +152,19 @@ document.addEventListener("DOMContentLoaded", () => {
     });
 
     // Save and resume media position
-    const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
-    elements.forEach((element) => {
+    const lastPositionElements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
+    lastPositionElements.forEach((element) => {
         if (element.dataset.lastPosition) {
             element.currentTime = element.dataset.lastPosition;
         }
         element.ontimeupdate = () => handlePlayerProgressionSave(element);
     });
+
+    // Set media playback rate
+    const playbackRateElements = document.querySelectorAll("audio[data-playback-rate],video[data-playback-rate]");
+    playbackRateElements.forEach((element) => {
+        if (element.dataset.playbackRate) {
+            element.playbackRate = element.dataset.playbackRate;
+        }
+    });
 });

+ 13 - 0
internal/validator/user.go

@@ -102,6 +102,12 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
 		}
 	}
 
+	if changes.MediaPlaybackRate != nil {
+		if err := validateMediaPlaybackRate(*changes.MediaPlaybackRate); err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -182,3 +188,10 @@ func validateDefaultHomePage(defaultHomePage string) *locale.LocalizedError {
 	}
 	return nil
 }
+
+func validateMediaPlaybackRate(mediaPlaybackRate float64) *locale.LocalizedError {
+	if mediaPlaybackRate < 0.25 || mediaPlaybackRate > 4 {
+		return locale.NewLocalizedError("error.settings_media_playback_rate_range")
+	}
+	return nil
+}