From 65a669e23148bd4ae76c0f1dd33b626d302917c9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 27 Oct 2026 19:50:21 +0100 Subject: [PATCH] feat(core,player,app): subtitle size, background and colour settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain-text subtitles (SRT) were drawn in mpv's bare default, thin white text that bright scenes swallow. Settings → Playback now picks a size, an outline or a dark box, and white or yellow text. Styled (ASS) tracks keep their own look and take only the size. The box options are mpv 0.38's, so they are set best effort: an older libmpv logs and plays on. The style syncs with the other playback settings, and the Android bridge keeps the stored style instead of resetting it on save. --- crates/pstr-android/src/lib.rs | 9 ++- crates/pstr-app/src/app.rs | 7 ++ crates/pstr-app/src/playback.rs | 1 + crates/pstr-app/src/ui/settings.rs | 51 +++++++++++++- crates/pstr-core/src/prefs.rs | 108 +++++++++++++++++++++++++++++ crates/pstr-player/src/lib.rs | 1 + crates/pstr-player/src/player.rs | 82 ++++++++++++++++++++++ docs/FAQ.md | 6 ++ docs/USER_GUIDE.md | 3 + 9 files changed, 265 insertions(+), 3 deletions(-) diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 00089fc..eb82583 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -1909,7 +1909,11 @@ impl AndroidEngine { pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> { // Sanitized rather than trusted: the volume arrives from a slider, and // a language from a text field that can hold spaces and nothing else. - let prefs = playback_prefs(prefs).sanitized(); + // Onto what is stored, not the defaults: the record has no subtitle + // style yet, and saving one without it would reset the desktop's and, + // through sync, every other installation's. + let stored = pstr_core::prefs::load(&self.dirs).unwrap_or_default(); + let prefs = playback_prefs(prefs, stored).sanitized(); pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display) } @@ -3580,7 +3584,7 @@ fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord { } } -fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs { +fn playback_prefs(record: PlaybackPrefsRecord, stored: PlaybackPrefs) -> PlaybackPrefs { PlaybackPrefs { volume: record.volume, muted: record.muted, @@ -3590,6 +3594,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs { autoplay_next: record.autoplay_next, auto_skip: record.auto_skip, speed: record.speed, + ..stored } } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index e083de9..04b1c88 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -235,6 +235,8 @@ pub enum Action { subtitles: bool, subtitle: Option, }, + /// How plain subtitles look, from the next file. + SetSubtitleStyle(pstr_core::prefs::SubtitleStyle), /// Play faster or slower, and keep doing so for the next file. SetSpeed(f64), /// Seek past openings and credits without being asked. @@ -1938,6 +1940,11 @@ impl App { prefs.subtitle_language = subtitle; self.engine.set_playback_prefs(prefs, true); } + Action::SetSubtitleStyle(style) => { + let mut prefs = self.engine.playback_prefs(); + prefs.subtitle_style = style; + self.engine.set_playback_prefs(prefs, true); + } Action::SetAppearance(appearance) => { self.engine.set_appearance(appearance); theme::apply(ctx, appearance); diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 70edb6e..62fa6a4 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -494,6 +494,7 @@ fn from_prefs( .and_then(|p| p.subtitle_language.clone()) .or_else(|| prefs.subtitle_language.clone()), subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), + subtitle_style: prefs.subtitle_style, // Carried from file to file, like the volume: somebody who watches at // 1.25 watches the next episode at 1.25 too. options: vec![("speed".into(), format!("{:.2}", prefs.speed))], diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index c589c57..06445e0 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -7,7 +7,9 @@ use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; -use pstr_core::prefs::PlaybackPrefs; +use pstr_core::prefs::{ + PlaybackPrefs, SubtitleBackdrop, SubtitleColour, SubtitleSize, SubtitleStyle, +}; use pstr_player::LANGUAGES; use crate::app::Action; @@ -315,6 +317,7 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec) ), |ui| { languages(ui, prefs.playback, actions); + subtitle_style(ui, prefs.playback.subtitle_style, actions); let mut autoplay = prefs.autoplay; ui::widgets::settings_row( ui, @@ -428,6 +431,52 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec) { + let mut chosen = style; + ui::widgets::settings_row( + ui, + "Subtitle size", + Some("Styled subtitles are scaled too. From the next file."), + |ui| { + egui::ComboBox::from_id_salt("subtitle-size") + .selected_text(chosen.size.label()) + .show_ui(ui, |ui| { + for size in SubtitleSize::ALL { + ui.selectable_value(&mut chosen.size, size, size.label()); + } + }); + }, + ); + ui::widgets::settings_row( + ui, + "Subtitle background", + Some("A box reads on bright scenes. Plain subtitles only; styled ones keep their own."), + |ui| { + egui::ComboBox::from_id_salt("subtitle-backdrop") + .selected_text(chosen.backdrop.label()) + .show_ui(ui, |ui| { + for backdrop in SubtitleBackdrop::ALL { + ui.selectable_value(&mut chosen.backdrop, backdrop, backdrop.label()); + } + }); + }, + ); + ui::widgets::settings_row(ui, "Subtitle colour", Some("Plain subtitles only."), |ui| { + egui::ComboBox::from_id_salt("subtitle-colour") + .selected_text(chosen.colour.label()) + .show_ui(ui, |ui| { + for colour in SubtitleColour::ALL { + ui.selectable_value(&mut chosen.colour, colour, colour.label()); + } + }); + }); + if chosen != style { + actions.push(Action::SetSubtitleStyle(chosen)); + } +} + /// The streaming cache: how big it may get, and a way to empty it. fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec) { ui::widgets::settings_group( diff --git a/crates/pstr-core/src/prefs.rs b/crates/pstr-core/src/prefs.rs index a208944..2f49e52 100644 --- a/crates/pstr-core/src/prefs.rs +++ b/crates/pstr-core/src/prefs.rs @@ -60,6 +60,100 @@ pub struct PlaybackPrefs { /// everything at 1.25, and a speed that reset every episode would be a /// setting nobody could use. pub speed: f64, + /// How plain subtitles look. Kept with the rest because it is the same + /// kind of choice: made once, for every file after. + pub subtitle_style: SubtitleStyle, +} + +/// How plain-text subtitles are drawn. +/// +/// Plain only, except for the size: an SRT track has no look of its own and +/// gets mpv's, which is thin white text that a bright scene swallows. A styled +/// (ASS) track was typeset by somebody, its signs placed and coloured by hand, +/// and repainting it in one colour on one box undoes that work — so the +/// backdrop and colour leave it alone, and only the scale reaches it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SubtitleStyle { + pub size: SubtitleSize, + pub backdrop: SubtitleBackdrop, + pub colour: SubtitleColour, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SubtitleSize { + Small, + #[default] + Normal, + Large, + Larger, +} + +impl SubtitleSize { + pub const ALL: [Self; 4] = [Self::Small, Self::Normal, Self::Large, Self::Larger]; + + pub fn label(self) -> &'static str { + match self { + Self::Small => "Small", + Self::Normal => "Normal", + Self::Large => "Large", + Self::Larger => "Larger", + } + } + + /// Relative to mpv's own size, which is itself relative to the video's + /// height — so one setting reads the same on a phone and a television. + pub fn scale(self) -> f64 { + match self { + Self::Small => 0.8, + Self::Normal => 1.0, + Self::Large => 1.25, + Self::Larger => 1.5, + } + } +} + +/// What keeps the text readable against the picture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SubtitleBackdrop { + /// A dark outline round each letter: mpv's own, and the default. + #[default] + Outline, + /// A dark, part-transparent box behind each line. Uglier, and the only + /// thing that holds up on snow, sky and white walls. + Box, +} + +impl SubtitleBackdrop { + pub const ALL: [Self; 2] = [Self::Outline, Self::Box]; + + pub fn label(self) -> &'static str { + match self { + Self::Outline => "Outline", + Self::Box => "Dark box", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SubtitleColour { + #[default] + White, + Yellow, +} + +impl SubtitleColour { + pub const ALL: [Self; 2] = [Self::White, Self::Yellow]; + + pub fn label(self) -> &'static str { + match self { + Self::White => "White", + Self::Yellow => "Yellow", + } + } } impl Default for PlaybackPrefs { @@ -73,6 +167,7 @@ impl Default for PlaybackPrefs { autoplay_next: true, auto_skip: false, speed: 1.0, + subtitle_style: SubtitleStyle::default(), } } } @@ -149,6 +244,11 @@ mod tests { autoplay_next: false, auto_skip: true, speed: 1.5, + subtitle_style: SubtitleStyle { + size: SubtitleSize::Larger, + backdrop: SubtitleBackdrop::Box, + colour: SubtitleColour::Yellow, + }, }; save(&dirs, &prefs).unwrap(); assert_eq!(load(&dirs).unwrap(), prefs); @@ -156,6 +256,14 @@ mod tests { std::fs::remove_dir_all(&temp).ok(); } + #[test] + fn a_file_from_before_subtitle_styles_loads_with_the_default_style() { + let prefs: PlaybackPrefs = + serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap(); + assert_eq!(prefs.subtitle_style, SubtitleStyle::default()); + assert_eq!(prefs.volume, 60.0); + } + #[test] fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() { let prefs = PlaybackPrefs { diff --git a/crates/pstr-player/src/lib.rs b/crates/pstr-player/src/lib.rs index 0b85a6a..98b8fbf 100644 --- a/crates/pstr-player/src/lib.rs +++ b/crates/pstr-player/src/lib.rs @@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start, pub use error::{Error, Result}; pub use player::{ EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput, + subtitle_options, }; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use render::VideoRenderer; diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs index 6e1c611..a6482fc 100644 --- a/crates/pstr-player/src/player.rs +++ b/crates/pstr-player/src/player.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use libmpv2::events::{Event, PropertyData}; use libmpv2::{Format, Mpv}; +use pstr_core::prefs::{SubtitleBackdrop, SubtitleColour, SubtitleStyle}; use pstr_stream::VideoStream; use tokio::runtime::Runtime; @@ -181,6 +182,8 @@ pub struct PlayerConfig { /// Whether subtitles are shown at all. Off is a choice a viewer makes once /// and expects to keep, and no language preference can express it. pub subtitles: bool, + /// How plain subtitles look. See [`subtitle_options`]. + pub subtitle_style: SubtitleStyle, /// Raw mpv options, applied last, so anything here wins. pub options: Vec<(String, String)>, } @@ -206,6 +209,7 @@ impl Default for PlayerConfig { audio_language: None, subtitle_language: None, subtitles: true, + subtitle_style: SubtitleStyle::default(), options: Vec::new(), } } @@ -293,6 +297,17 @@ impl Player { if !config.subtitles { init.set_property("sid", "no")?; } + // Best effort: `sub-border-style` is mpv 0.38's, and an older + // libmpv refusing it must cost the box, not the whole player. The + // first refusal ends the run, because what follows it only makes + // sense on top of it: a box's colour on a plain outline is a fat + // grey smear. + for (name, value) in subtitle_options(&config.subtitle_style) { + if let Err(error) = init.set_property(name, value.as_str()) { + tracing::warn!("subtitle style {name}={value}: {error}"); + break; + } + } for (name, value) in &config.options { init.set_property(name.as_str(), value.as_str())?; } @@ -593,6 +608,32 @@ impl Player { } } +/// The mpv options that draw `style`. +/// +/// Leaves `sub-ass-override` at mpv's default, `scale`: a styled track takes +/// the size and keeps its own fonts, colours and placement. See +/// [`SubtitleStyle`] for why. +pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> { + let mut options = vec![("sub-scale", format!("{:.2}", style.size.scale()))]; + match style.colour { + // mpv's own default; stated so a live change back from yellow lands. + SubtitleColour::White => options.push(("sub-color", "#FFFFFFFF".into())), + SubtitleColour::Yellow => options.push(("sub-color", "#FFFFE600".into())), + } + if style.backdrop == SubtitleBackdrop::Box { + // `opaque-box` draws the outline as a box hugging each line, so the + // outline's colour and size become the box's colour and margin. + // Ordered so the style comes first: see the caller. + options.extend([ + ("sub-border-style", "opaque-box".into()), + // ARGB: about seventy percent black. + ("sub-outline-color", "#B0000000".into()), + ("sub-outline-size", "6".into()), + ]); + } + options +} + /// mpv's borrowed event to ours. fn translate(event: Event<'_>) -> PlayerEvent { match event { @@ -620,6 +661,47 @@ fn translate(event: Event<'_>) -> PlayerEvent { mod tests { use super::*; + #[test] + fn the_default_subtitle_style_changes_nothing_mpv_would_not_do_anyway() { + let options = subtitle_options(&SubtitleStyle::default()); + assert_eq!( + options, + vec![ + ("sub-scale", "1.00".to_string()), + ("sub-color", "#FFFFFFFF".to_string()), + ] + ); + } + + #[test] + fn a_boxed_subtitle_style_turns_the_outline_into_a_box() { + let style = SubtitleStyle { + size: pstr_core::prefs::SubtitleSize::Larger, + backdrop: SubtitleBackdrop::Box, + colour: SubtitleColour::Yellow, + }; + let options = subtitle_options(&style); + let value = |name: &str| { + options + .iter() + .find(|(option, _)| *option == name) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(value("sub-scale"), Some("1.50")); + assert_eq!(value("sub-color"), Some("#FFFFE600")); + assert_eq!(value("sub-border-style"), Some("opaque-box")); + assert_eq!(value("sub-outline-color"), Some("#B0000000")); + assert_eq!( + options[2].0, "sub-border-style", + "the style before what depends on it" + ); + assert_eq!( + value("sub-ass-override"), + None, + "a styled track keeps its own look" + ); + } + #[test] fn every_end_reason_maps_to_something_the_ui_can_act_on() { use libmpv2::mpv_end_file_reason as raw; diff --git a/docs/FAQ.md b/docs/FAQ.md index 79ea1ce..5600eb7 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -21,6 +21,12 @@ the blocks around the point you are watching. A seek fetches the block it lands No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work, with hardware decoding where the device has it. +## Subtitles are hard to read. Can I change them? + +Yes, for plain subtitles (SRT). In **Settings → Playback**, set **Subtitle size**, **Subtitle +background** and **Subtitle colour**. A **Dark box** background is easiest to read on bright +scenes. Styled subtitles (ASS) keep the look their makers gave them and change only in size. + ## Does it provide any videos? No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue, diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6dafbf9..5c21fa6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -182,6 +182,9 @@ The **Proton account** section on this page is where you sign in. See **Playback** - **Audio language** and **Subtitles:** the language to start in, or the file's own default. +- **Subtitle size**, **Subtitle background** and **Subtitle colour:** how plain subtitles (SRT) + look. Choose **Dark box** if text is hard to read on bright scenes. Styled subtitles (ASS) keep + their own look and only change size. These apply from the next file. - **Play the next episode** (on by default) and **Skip openings and credits** (off). - **Hardware decoding** (on). Turn it off if the picture shows green blocks or stays black.