aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Romain de Laage <romain.delaage@rdelaage.ovh> 2024-03-16 14:20:02 +0100
committerGravatar Frédéric Guillot <f@miniflux.net> 2024-03-17 11:53:30 -0700
commit00dabc1d3c86af88ece7a292ecc968f5d825749e (patch)
tree9369f3d606abf6e52257547631859bd772163e88
parentb68ada396a342ef6ab447c2bb98d7c96aa643178 (diff)
downloadv2-00dabc1d3c86af88ece7a292ecc968f5d825749e.tar.gz
v2-00dabc1d3c86af88ece7a292ecc968f5d825749e.tar.zst
v2-00dabc1d3c86af88ece7a292ecc968f5d825749e.zip
feat: Media player: Conrol playback speed
fix #1845
-rw-r--r--client/model.go46
-rw-r--r--internal/database/migrations.go5
-rw-r--r--internal/locale/translations/de_DE.json4
-rw-r--r--internal/locale/translations/el_EL.json4
-rw-r--r--internal/locale/translations/en_US.json4
-rw-r--r--internal/locale/translations/es_ES.json4
-rw-r--r--internal/locale/translations/fi_FI.json4
-rw-r--r--internal/locale/translations/fr_FR.json4
-rw-r--r--internal/locale/translations/hi_IN.json4
-rw-r--r--internal/locale/translations/id_ID.json4
-rw-r--r--internal/locale/translations/it_IT.json4
-rw-r--r--internal/locale/translations/ja_JP.json4
-rw-r--r--internal/locale/translations/nl_NL.json4
-rw-r--r--internal/locale/translations/pl_PL.json4
-rw-r--r--internal/locale/translations/pt_BR.json4
-rw-r--r--internal/locale/translations/ru_RU.json4
-rw-r--r--internal/locale/translations/tr_TR.json4
-rw-r--r--internal/locale/translations/uk_UA.json4
-rw-r--r--internal/locale/translations/zh_CN.json4
-rw-r--r--internal/locale/translations/zh_TW.json4
-rw-r--r--internal/model/model.go8
-rw-r--r--internal/model/user.go50
-rw-r--r--internal/storage/user.go33
-rw-r--r--internal/template/templates/views/entry.html4
-rw-r--r--internal/template/templates/views/settings.html3
-rw-r--r--internal/ui/form/settings.go11
-rw-r--r--internal/ui/form/settings_test.go3
-rw-r--r--internal/ui/settings_show.go1
-rw-r--r--internal/ui/settings_update.go1
-rw-r--r--internal/ui/static/js/bootstrap.js12
-rw-r--r--internal/validator/user.go13
31 files changed, 188 insertions, 74 deletions
diff --git a/client/model.go b/client/model.go
index 05bc0c2f..c0d42eb8 100644
--- a/client/model.go
+++ b/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.
diff --git a/internal/database/migrations.go b/internal/database/migrations.go
index 0721f218..cfc1159d 100644
--- a/internal/database/migrations.go
+++ b/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
+ },
}
diff --git a/internal/locale/translations/de_DE.json b/internal/locale/translations/de_DE.json
index 58650850..55e82a94 100644
--- a/internal/locale/translations/de_DE.json
+++ b/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"
}
diff --git a/internal/locale/translations/el_EL.json b/internal/locale/translations/el_EL.json
index 2351ee26..b2a71007 100644
--- a/internal/locale/translations/el_EL.json
+++ b/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": "Η ταχύτητα αναπαραγωγής είναι εκτός εύρους"
}
diff --git a/internal/locale/translations/en_US.json b/internal/locale/translations/en_US.json
index 50a43b9f..777b9818 100644
--- a/internal/locale/translations/en_US.json
+++ b/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"
}
diff --git a/internal/locale/translations/es_ES.json b/internal/locale/translations/es_ES.json
index e08c8438..981279e9 100644
--- a/internal/locale/translations/es_ES.json
+++ b/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"
}
diff --git a/internal/locale/translations/fi_FI.json b/internal/locale/translations/fi_FI.json
index 9da193a3..e81973e9 100644
--- a/internal/locale/translations/fi_FI.json
+++ b/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"
}
diff --git a/internal/locale/translations/fr_FR.json b/internal/locale/translations/fr_FR.json
index ca6c264b..70fe9ebd 100644
--- a/internal/locale/translations/fr_FR.json
+++ b/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"
}
diff --git a/internal/locale/translations/hi_IN.json b/internal/locale/translations/hi_IN.json
index 00c81287..bfb9a19d 100644
--- a/internal/locale/translations/hi_IN.json
+++ b/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": "प्लेबैक गति सीमा से बाहर है"
}
diff --git a/internal/locale/translations/id_ID.json b/internal/locale/translations/id_ID.json
index 70aa938a..a1e0ee3c 100644
--- a/internal/locale/translations/id_ID.json
+++ b/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"
}
diff --git a/internal/locale/translations/it_IT.json b/internal/locale/translations/it_IT.json
index ac1ce3c5..b218bc08 100644
--- a/internal/locale/translations/it_IT.json
+++ b/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"
}
diff --git a/internal/locale/translations/ja_JP.json b/internal/locale/translations/ja_JP.json
index 5363d89a..3e6b41f6 100644
--- a/internal/locale/translations/ja_JP.json
+++ b/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": "再生速度が範囲外"
}
diff --git a/internal/locale/translations/nl_NL.json b/internal/locale/translations/nl_NL.json
index 5b5b57db..a8c29c86 100644
--- a/internal/locale/translations/nl_NL.json
+++ b/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"
}
diff --git a/internal/locale/translations/pl_PL.json b/internal/locale/translations/pl_PL.json
index 3a7ad74d..4e0b7b92 100644
--- a/internal/locale/translations/pl_PL.json
+++ b/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"
}
diff --git a/internal/locale/translations/pt_BR.json b/internal/locale/translations/pt_BR.json
index 1e1c1b9b..3e0de941 100644
--- a/internal/locale/translations/pt_BR.json
+++ b/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"
}
diff --git a/internal/locale/translations/ru_RU.json b/internal/locale/translations/ru_RU.json
index 2c55537e..940380a4 100644
--- a/internal/locale/translations/ru_RU.json
+++ b/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": "Скорость воспроизведения выходит за пределы диапазона"
}
diff --git a/internal/locale/translations/tr_TR.json b/internal/locale/translations/tr_TR.json
index 3d8c8add..0d650c2a 100644
--- a/internal/locale/translations/tr_TR.json
+++ b/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"
}
diff --git a/internal/locale/translations/uk_UA.json b/internal/locale/translations/uk_UA.json
index 6bf94b09..76982ea1 100644
--- a/internal/locale/translations/uk_UA.json
+++ b/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": "Швидкість відтворення виходить за межі діапазону"
}
diff --git a/internal/locale/translations/zh_CN.json b/internal/locale/translations/zh_CN.json
index 1d7d2e32..f12ea8da 100644
--- a/internal/locale/translations/zh_CN.json
+++ b/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": "播放速度超出范围"
}
diff --git a/internal/locale/translations/zh_TW.json b/internal/locale/translations/zh_TW.json
index 8f748c01..5696491c 100644
--- a/internal/locale/translations/zh_TW.json
+++ b/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": "播放速度超出範圍"
}
diff --git a/internal/model/model.go b/internal/model/model.go
index 82d69b8a..aed0fe14 100644
--- a/internal/model/model.go
+++ b/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
+}
diff --git a/internal/model/user.go b/internal/model/user.go
index 61ff1065..62aff600 100644
--- a/internal/model/user.go
+++ b/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.
diff --git a/internal/storage/user.go b/internal/storage/user.go
index b3d50a17..4f30ac0d 100644
--- a/internal/storage/user.go
+++ b/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 {
diff --git a/internal/template/templates/views/entry.html b/internal/template/templates/views/entry.html
index 6a4ed9c5..4284f097 100644
--- a/internal/template/templates/views/entry.html
+++ b/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")) }}
diff --git a/internal/template/templates/views/settings.html b/internal/template/templates/views/settings.html
index 9846d893..0be77f62 100644
--- a/internal/template/templates/views/settings.html
+++ b/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>
diff --git a/internal/ui/form/settings.go b/internal/ui/form/settings.go
index d5442218..a46d9714 100644
--- a/internal/ui/form/settings.go
+++ b/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,
}
}
diff --git a/internal/ui/form/settings_test.go b/internal/ui/form/settings_test.go
index a8afdfb7..84bbd9b7 100644
--- a/internal/ui/form/settings_test.go
+++ b/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()
diff --git a/internal/ui/settings_show.go b/internal/ui/settings_show.go
index 23e6d401..3a96b29c 100644
--- a/internal/ui/settings_show.go
+++ b/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()
diff --git a/internal/ui/settings_update.go b/internal/ui/settings_update.go
index 122ad441..e0e3c0af 100644
--- a/internal/ui/settings_update.go
+++ b/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 {
diff --git a/internal/ui/static/js/bootstrap.js b/internal/ui/static/js/bootstrap.js
index 0acef71c..0a9c2b78 100644
--- a/internal/ui/static/js/bootstrap.js
+++ b/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;
+ }
+ });
});
diff --git a/internal/validator/user.go b/internal/validator/user.go
index 8c6cc9d2..a397167e 100644
--- a/internal/validator/user.go
+++ b/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
+}