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.