diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 04b1c88..b815cc6 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -235,8 +235,14 @@ pub enum Action { subtitles: bool, subtitle: Option, }, - /// How plain subtitles look, from the next file. + /// How subtitles look, for the file playing and every one after. Written + /// to the preferences once the mouse is let go; see + /// [`App::save_subtitle_style`]. SetSubtitleStyle(pstr_core::prefs::SubtitleStyle), + /// Shift the playing file's subtitles by this many seconds more. + NudgeSubtitleDelay(f64), + /// Put the playing file's subtitles back on the file's own timing. + ResetSubtitleDelay, /// Play faster or slower, and keep doing so for the next file. SetSpeed(f64), /// Seek past openings and credits without being asked. @@ -656,6 +662,8 @@ pub struct App { /// Chapters already skipped on their own in this player, so seeking back /// into an opening on purpose is not undone a frame later. auto_skipped: (u64, Vec), + /// The subtitle style changed and has not been written yet. + subtitle_style_unsaved: bool, /// Whether the episode list is out on the player page. pub episode_drawer: bool, /// Set while the window is fullscreen, so `F` can toggle rather than only @@ -762,6 +770,7 @@ impl App { up_next: UpNext::default(), warmed: 0, auto_skipped: (0, Vec::new()), + subtitle_style_unsaved: false, fullscreen: false, episode_drawer: false, opening: None, @@ -1439,6 +1448,21 @@ impl App { self.apply(ctx, Action::Play(target)); } + /// Write the subtitle style, once whatever is changing it has let go. + /// + /// A slider or a colour wheel changes the style every frame it is dragged, + /// and each change is shown on the playing file straight away. Writing + /// each one would be a file write per frame, so it waits for the mouse — + /// which also covers a menu closed mid-drag, where the control that would + /// have said "released" is no longer drawn. + fn save_subtitle_style(&mut self, ctx: &egui::Context) { + if self.subtitle_style_unsaved && !ctx.input(|input| input.pointer.any_down()) { + self.subtitle_style_unsaved = false; + self.engine + .set_playback_prefs(self.engine.playback_prefs(), true); + } + } + fn send_player(&self, command: crate::playback::Command) { if let Some(playback) = &self.playback { playback.send(command); @@ -1943,7 +1967,28 @@ impl App { Action::SetSubtitleStyle(style) => { let mut prefs = self.engine.playback_prefs(); prefs.subtitle_style = style; - self.engine.set_playback_prefs(prefs, true); + self.engine.set_playback_prefs(prefs, false); + self.subtitle_style_unsaved = true; + let style = self.engine.playback_prefs().subtitle_style; + if let Some(playback) = &mut self.playback { + playback.restyle_subtitles(&style); + } + } + Action::NudgeSubtitleDelay(delta) => { + if let Some(playback) = &mut self.playback { + playback.set_subtitle_delay(playback.subtitle_delay + delta); + let text = format!( + "Subtitle delay {}", + ui::format_delay(playback.subtitle_delay) + ); + self.flash(ctx, text); + } + } + Action::ResetSubtitleDelay => { + if let Some(playback) = &mut self.playback { + playback.set_subtitle_delay(0.0); + self.flash(ctx, "Subtitle delay 0 s"); + } } Action::SetAppearance(appearance) => { self.engine.set_appearance(appearance); @@ -2136,6 +2181,7 @@ impl eframe::App for App { } self.warm_next(); self.auto_skip(ctx); + self.save_subtitle_style(ctx); self.window.observe(ctx); let mut actions: Vec = Vec::new(); @@ -2916,6 +2962,8 @@ fn shortcuts( Key::OpenBracket, Key::CloseBracket, Key::E, + Key::Z, + Key::X, Key::Escape, ] .into_iter() @@ -2959,6 +3007,9 @@ fn shortcuts( // Escape leaves fullscreen first and the page second, which is what // it does everywhere else and what stops one press from both // un-maximising the window and hiding the film. + // mpv's own keys for it, and the step mpv uses. + Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)), + Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)), Key::E => actions.push(Action::ToggleEpisodes), // The episode list is the innermost thing open, so it goes first. Key::Escape if drawer => actions.push(Action::ToggleEpisodes), diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 62fa6a4..d2d0e8a 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -147,6 +147,10 @@ pub enum Command { /// Move subtitles, as a percentage of the window's height. See /// [`Playback::lift_subtitles`]. SetSubtitlePosition(f64), + /// Restyle subtitles, the line already showing included. + SetSubtitleStyle(pstr_core::prefs::SubtitleStyle), + /// Shift subtitles this many seconds later; negative is earlier. + SetSubtitleDelay(f64), /// 1.0 is normal speed. SetSpeed(f64), /// Save the current frame to this file. @@ -184,6 +188,14 @@ pub struct Playback { /// Where subtitles were last put, so they are only moved when that changes /// rather than once a frame. subtitle_position: f64, + /// How the viewer has subtitles drawn, as last handed to this player. + pub subtitle_style: pstr_core::prefs::SubtitleStyle, + /// Where the controls would lift subtitles to. The higher of this and the + /// style's own position wins. + lifted_position: f64, + /// How far subtitles are shifted, in seconds. Per file rather than a + /// preference: an offset belongs to one release, not to the viewer. + pub subtitle_delay: f64, /// What the file contains. Empty until mpv has demuxed it. pub tracks: Vec, /// The file's chapters, in order. Empty for a file muxed without them. @@ -259,7 +271,10 @@ impl Playback { paused: false, volume: prefs.volume, muted: prefs.muted, - subtitle_position: 100.0, + subtitle_position: prefs.subtitle_style.position, + subtitle_style: prefs.subtitle_style.clone(), + lifted_position: 100.0, + subtitle_delay: 0.0, speed: prefs.speed, tracks: Vec::new(), chapters: Vec::new(), @@ -282,17 +297,39 @@ impl Playback { /// the mouse is moving, which is exactly when someone is looking at the /// screen. pub fn lift_subtitles(&mut self, covered: f32, height: f32) { - let position = if covered > 0.0 && height > 0.0 { + self.lifted_position = if covered > 0.0 && height > 0.0 { (100.0 * (1.0 - f64::from(covered / height))).round() } else { 100.0 }; + let position = self.subtitle_position(); if position != self.subtitle_position { self.subtitle_position = position; self.send(Command::SetSubtitlePosition(position)); } } + /// Where subtitles go: where the style puts them, unless the controls + /// would cover that, in which case just above the controls. + fn subtitle_position(&self) -> f64 { + self.subtitle_style.position.min(self.lifted_position) + } + + /// Restyle the subtitles of this file, now. + pub fn restyle_subtitles(&mut self, style: &pstr_core::prefs::SubtitleStyle) { + self.subtitle_style = style.clone(); + self.subtitle_position = self.subtitle_position(); + let mut style = style.clone(); + style.position = self.subtitle_position; + self.send(Command::SetSubtitleStyle(style)); + } + + /// Shift subtitles to `seconds`, rounded to the tenth a keypress moves. + pub fn set_subtitle_delay(&mut self, seconds: f64) { + self.subtitle_delay = (seconds * 10.0).round() / 10.0; + self.send(Command::SetSubtitleDelay(self.subtitle_delay)); + } + /// Whether the picture is drawn in this window rather than mpv's own. pub fn is_embedded(&self) -> bool { self.video.is_some() @@ -494,7 +531,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, + subtitle_style: prefs.subtitle_style.clone(), // 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))], @@ -659,6 +696,11 @@ fn apply_command( Command::SetVolume(volume) => player.set_volume(volume), Command::SetMuted(muted) => player.set_muted(muted), Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), + Command::SetSubtitleStyle(style) => { + player.apply_subtitle_style(&style); + Ok(()) + } + Command::SetSubtitleDelay(seconds) => player.set_subtitle_delay(seconds), Command::SetSpeed(speed) => player.set_speed(speed), Command::Screenshot(path) => player.screenshot(&path), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 9a83a63..8478125 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -316,6 +316,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { None } +/// The font families installed here, by name, for the subtitle font picker. +/// +/// `None` until they have been read. The first call starts that on a thread +/// of its own and asks for a repaint when it is done: `fc-list` over a large +/// font collection takes long enough to be seen as a stalled frame. +pub fn font_families(ctx: &egui::Context) -> Option<&'static [String]> { + static FAMILIES: std::sync::OnceLock> = std::sync::OnceLock::new(); + static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if let Some(families) = FAMILIES.get() { + return Some(families); + } + if !STARTED.swap(true, std::sync::atomic::Ordering::Relaxed) { + let ctx = ctx.clone(); + let spawned = std::thread::Builder::new() + .name("font-families".into()) + .spawn(move || { + let _ = FAMILIES.set(system_font_families()); + ctx.request_repaint(); + }); + if let Err(error) = spawned { + tracing::debug!("font families thread: {error}"); + } + } + None +} + +#[cfg(target_os = "linux")] +fn system_font_families() -> Vec { + let Ok(output) = std::process::Command::new("fc-list") + .args([":", "family"]) + .output() + else { + return Vec::new(); + }; + font_families_from_fc_list(&String::from_utf8_lossy(&output.stdout)) +} + +/// Elsewhere the picker takes a typed name only. mpv's font lookup there is +/// the system's own, so any installed family's name works. +#[cfg(not(target_os = "linux"))] +fn system_font_families() -> Vec { + Vec::new() +} + +/// One family per line of `fc-list : family`, sorted and without repeats. +/// +/// A line lists a family's names in every language it has, comma-separated, +/// and one family appears once per style. The first name is the one libass +/// matches on. +#[cfg(any(target_os = "linux", test))] +fn font_families_from_fc_list(listing: &str) -> Vec { + let mut families: Vec = listing + .lines() + .filter_map(|line| line.split(',').next()) + .map(|family| family.trim().to_owned()) + .filter(|family| !family.is_empty() && !family.starts_with('.')) + .collect(); + families.sort_by_key(|family| family.to_lowercase()); + families.dedup(); + families +} + /// Every colour the app draws with, as egui wants them. /// /// The *choice* of colours is `pstr_core::appearance` — one resolution shared @@ -881,6 +943,15 @@ const RAMP_STOPS: usize = 64; mod tests { use super::*; + #[test] + fn each_font_family_is_listed_once_by_its_first_name() { + let listing = "Noto Sans,Noto Sans Regular\nDejaVu Sans\nnoto sans mono\nNoto Sans,Noto Sans Bold\n\n.LastResort\n"; + assert_eq!( + font_families_from_fc_list(listing), + ["DejaVu Sans", "Noto Sans", "noto sans mono"] + ); + } + #[test] fn a_variable_fonts_named_instance_is_not_taken_for_a_face() { assert_eq!(fontconfig_face(262_144), 0); diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index a4dfbe1..4c191ed 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -206,6 +206,18 @@ pub fn format_speed(speed: f64) -> String { format!("{}×", text.trim_end_matches('0').trim_end_matches('.')) } +/// `+0.3 s`, `−1.2 s` or `0 s`: a subtitle delay, signed so the direction +/// reads without knowing which way mpv counts. +pub fn format_delay(seconds: f64) -> String { + if seconds.abs() < 0.05 { + "0 s".to_owned() + } else if seconds > 0.0 { + format!("+{seconds:.1} s") + } else { + format!("−{:.1} s", -seconds) + } +} + /// `1.4 GiB`, for what a file costs to watch. pub fn format_size(bytes: i64) -> String { const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; @@ -917,6 +929,13 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid { #[cfg(test)] mod tests { + #[test] + fn a_subtitle_delay_reads_with_its_direction() { + assert_eq!(super::format_delay(0.3), "+0.3 s"); + assert_eq!(super::format_delay(-1.2), "−1.2 s"); + assert_eq!(super::format_delay(0.0000001), "0 s"); + } + use super::*; #[test] diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index 06445e0..ceb0035 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -7,9 +7,7 @@ use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; -use pstr_core::prefs::{ - PlaybackPrefs, SubtitleBackdrop, SubtitleColour, SubtitleSize, SubtitleStyle, -}; +use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle}; use pstr_player::LANGUAGES; use crate::app::Action; @@ -60,6 +58,7 @@ pub fn show( ui.add_space(theme::space::M); appearance_form(ui, prefs.appearance, prefs.desktop, actions); playback_form(ui, prefs, actions); + subtitle_form(ui, &prefs.playback.subtitle_style, actions); storage_form(ui, prefs, actions); metadata_form(ui, settings, api_key, actions); about(ui); @@ -317,7 +316,6 @@ 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, @@ -431,52 +429,343 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec) { - let mut chosen = style; - ui::widgets::settings_row( +/// How subtitles look, with a sample line to judge it by. +/// +/// Every change reaches a playing file as it is made, so the film itself is +/// the other preview. A styled track takes only the size and the position +/// unless it is asked to take the rest; see [`SubtitleStyle`]. +fn subtitle_form(ui: &mut egui::Ui, style: &SubtitleStyle, actions: &mut Vec) { + ui::widgets::settings_group( ui, - "Subtitle size", - Some("Styled subtitles are scaled too. From the next file."), + "Subtitles", + Some( + "Changes show on the playing video straight away. Plain subtitles (SRT) take all of this; styled ones (ASS) only the size and the position, unless you restyle them below.", + ), |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()); + let mut chosen = style.clone(); + ui.add_space(theme::space::L); + subtitle_preview(ui, &chosen); + ui.add_space(theme::space::L); + + ui::widgets::settings_row(ui, "Size", None, |ui| { + percent_slider( + ui, + &mut chosen.scale, + SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE, + ); + }); + ui::widgets::settings_row( + ui, + "Position", + Some("How far up the picture the line sits."), + |ui| subtitle_position_slider(ui, &mut chosen.position), + ); + ui::widgets::settings_row( + ui, + "Font", + Some("Any font installed here. The sample above is drawn in the app's own font."), + |ui| { + ui::widgets::toggle(ui, &mut chosen.italic, "Italic"); + ui::widgets::toggle(ui, &mut chosen.bold, "Bold"); + font_picker(ui, &mut chosen.font); + }, + ); + ui::widgets::settings_row(ui, "Text colour", None, |ui| { + colour_button(ui, &mut chosen.colour); + }); + ui::widgets::settings_row( + ui, + "Background", + Some("A box reads on bright scenes; an outline hides less of the picture."), + |ui| { + let choices = + SubtitleBackdrop::ALL.map(|backdrop| (backdrop, backdrop.label())); + if let Some(backdrop) = ui::widgets::segmented(ui, chosen.backdrop, &choices) { + chosen.backdrop = backdrop; } - }); - }, - ); - 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()); + }, + ); + match chosen.backdrop { + SubtitleBackdrop::Outline => { + ui::widgets::settings_row(ui, "Outline", None, |ui| { + colour_button(ui, &mut chosen.outline_colour); + ui.add( + egui::Slider::new( + &mut chosen.outline_size, + 0.0..=SubtitleStyle::MAX_OUTLINE, + ) + .step_by(0.05) + .fixed_decimals(1), + ); + }); + } + SubtitleBackdrop::Box => { + ui::widgets::settings_row( + ui, + "Box colour", + Some("Its opacity is how much of the picture shows through."), + |ui| colour_button(ui, &mut chosen.box_colour), + ); + } + } + ui::widgets::settings_row(ui, "Shadow", Some("How far it falls. 0 is none."), |ui| { + colour_button(ui, &mut chosen.shadow_colour); + ui.add( + egui::Slider::new(&mut chosen.shadow_offset, 0.0..=SubtitleStyle::MAX_SHADOW) + .step_by(0.1) + .fixed_decimals(1), + ); + }); + ui::widgets::settings_row( + ui, + "Blur", + Some("Softens the edges. Not shown in the sample."), + |ui| { + ui.add( + egui::Slider::new(&mut chosen.blur, 0.0..=SubtitleStyle::MAX_BLUR) + .step_by(0.1) + .fixed_decimals(1), + ); + }, + ); + ui::widgets::settings_row( + ui, + "Restyle styled subtitles", + Some( + "Repaint styled (ASS) dialogue in this style too. Signs, songs and karaoke can come out wrong.", + ), + |ui| { + ui::widgets::toggle(ui, &mut chosen.restyle_styled, ""); + }, + ); + ui::widgets::settings_row(ui, "Reset", Some("Back to mpv's own look."), |ui| { + if ui + .add_enabled( + *style != SubtitleStyle::default(), + egui::Button::new("Reset"), + ) + .clicked() + { + chosen = SubtitleStyle::default(); } }); - }); - if chosen != style { - actions.push(Action::SetSubtitleStyle(chosen)); + + if chosen != *style { + actions.push(Action::SetSubtitleStyle(chosen.sanitized())); + } + }, + ); +} + +/// A scale as a percentage slider, 100% being mpv's own size. +pub(crate) fn percent_slider( + ui: &mut egui::Ui, + scale: &mut f64, + range: std::ops::RangeInclusive, +) -> egui::Response { + let mut percent = *scale * 100.0; + let response = ui.add( + egui::Slider::new(&mut percent, range.start() * 100.0..=range.end() * 100.0) + .step_by(5.0) + .suffix("%") + .fixed_decimals(0), + ); + *scale = percent / 100.0; + response +} + +/// Where the line sits, 0 at the top of the picture and 100 at the bottom — +/// shown the other way up, as height above the bottom edge, which is how +/// anyone would describe moving subtitles up. +pub(crate) fn subtitle_position_slider(ui: &mut egui::Ui, position: &mut f64) -> egui::Response { + let mut height = 100.0 - *position; + let response = ui.add( + egui::Slider::new(&mut height, 0.0..=90.0) + .step_by(1.0) + .suffix("% up") + .fixed_decimals(0), + ); + *position = 100.0 - height; + response +} + +/// A swatch that opens a colour wheel with an opacity slider. +fn colour_button(ui: &mut egui::Ui, colour: &mut Colour) { + let [r, g, b, a] = colour.to_array(); + let mut picked = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + // The `Color32` form rather than `Hsva`, because egui remembers the hue + // behind it: rebuilt from RGB each frame, black and grey have none, and + // the wheel would snap back to red under the mouse. + let response = egui::color_picker::color_edit_button_srgba( + ui, + &mut picked, + egui::color_picker::Alpha::OnlyBlend, + ); + // Only on a change: a round trip through premultiplied alpha can nudge + // an untouched colour by a step. + if response.changed() { + *colour = Colour::from(picked.to_srgba_unmultiplied()); } } +/// The font: the installed families to pick from, and a box to type any name +/// into — the only way on a system whose families the app cannot list. +fn font_picker(ui: &mut egui::Ui, font: &mut Option) { + let filter_id = egui::Id::new("subtitle-font-filter"); + let mut filter: String = ui.data(|data| data.get_temp(filter_id)).unwrap_or_default(); + let families = theme::font_families(ui.ctx()); + egui::ComboBox::from_id_salt("subtitle-font") + .selected_text(font.as_deref().unwrap_or("Default")) + .width(200.0) + .height(320.0) + // Not on any click: the search box is clicked into. + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut filter) + .hint_text("Search or type a font name") + .desired_width(f32::INFINITY), + ) + .request_focus(); + let typed = filter.trim(); + if ui.selectable_label(font.is_none(), "Default").clicked() { + *font = None; + ui.close(); + } + let wanted = typed.to_lowercase(); + let mut exact = false; + for family in families.unwrap_or_default() { + if !family.to_lowercase().contains(&wanted) { + continue; + } + exact |= family.eq_ignore_ascii_case(typed); + let label = egui::RichText::new(family.as_str()); + if ui + .selectable_label(font.as_deref() == Some(family.as_str()), label) + .clicked() + { + *font = Some(family.clone()); + ui.close(); + } + } + if families.is_none() { + ui.label(ui::muted("Reading the installed fonts…")); + } + if !typed.is_empty() && !exact && ui.button(format!("Use “{typed}”")).clicked() { + *font = Some(typed.to_owned()); + ui.close(); + } + }); + ui.data_mut(|data| data.insert_temp(filter_id, filter)); +} + +/// A sample line, half on a bright sky and half on night, drawn in the +/// chosen style — so whether it reads on both is visible before a film is. +/// +/// An approximation: the app's own font rather than the chosen one, egui's +/// slant for italic, and no blur. Proportions follow mpv's, where sizes are +/// in pixels of a 38-pixel font. +fn subtitle_preview(ui: &mut egui::Ui, style: &SubtitleStyle) { + const HEIGHT: f32 = 132.0; + /// mpv's `sub-font-size`, the unit every other size is relative to. + const MPV_FONT: f32 = 38.0; + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, HEIGHT), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let radius = egui::CornerRadius::same(theme::radius::MD); + + // Sky above haze on the left, a night street on the right. + let half = egui::Rect::from_min_max(rect.min, egui::pos2(rect.center().x, rect.max.y)); + let mut sky = egui::Mesh::default(); + sky.colored_vertex(half.left_top(), egui::Color32::from_rgb(0x8E, 0xC5, 0xF0)); + sky.colored_vertex(half.right_top(), egui::Color32::from_rgb(0xB8, 0xDC, 0xF8)); + sky.colored_vertex( + half.right_bottom(), + egui::Color32::from_rgb(0xFA, 0xF7, 0xEE), + ); + sky.colored_vertex( + half.left_bottom(), + egui::Color32::from_rgb(0xF2, 0xF0, 0xE8), + ); + sky.add_triangle(0, 1, 2); + sky.add_triangle(0, 2, 3); + painter.rect_filled(rect, radius, egui::Color32::from_rgb(0x14, 0x16, 0x22)); + painter.add(sky); + + let font_px = 22.0 * style.scale as f32; + let unit = font_px / MPV_FONT; + let family = if style.bold { + theme::semibold() + } else { + egui::FontFamily::Proportional + }; + let job = egui::text::LayoutJob::single_section( + "Can you read this on both sides?".to_owned(), + egui::TextFormat { + font_id: egui::FontId::new(font_px, family), + color: egui::Color32::PLACEHOLDER, + italics: style.italic, + ..Default::default() + }, + ); + let galley = painter.layout_job(job); + let size = galley.size(); + // Bottom of the line at `position` percent down, as mpv places it, kept + // inside the strip. + let margin = 8.0; + let bottom = rect.top() + HEIGHT * style.position as f32 / 100.0 - margin; + let top = bottom + .min(rect.bottom() - margin) + .max(rect.top() + margin + size.y) + - size.y; + let at = egui::pos2(rect.center().x - size.x / 2.0, top); + let colour = |colour: Colour| { + let [r, g, b, a] = colour.to_array(); + egui::Color32::from_rgba_unmultiplied(r, g, b, a) + }; + + let shadow = style.shadow_offset as f32 * unit; + match style.backdrop { + SubtitleBackdrop::Box => { + let padding = 6.0 * unit; + let line = egui::Rect::from_min_size(at, size).expand(padding); + if shadow > 0.0 { + painter.rect_filled( + line.translate(egui::vec2(shadow, shadow)), + 0.0, + colour(style.shadow_colour), + ); + } + painter.rect_filled(line, 0.0, colour(style.box_colour)); + } + SubtitleBackdrop::Outline => { + let outline = style.outline_size as f32 * unit; + let rings = |offset: egui::Vec2, ink: egui::Color32| { + if outline <= 0.0 { + painter.galley_with_override_text_color(at + offset, galley.clone(), ink); + return; + } + for step in 0..8 { + let angle = step as f32 * std::f32::consts::TAU / 8.0; + let ring = egui::vec2(angle.cos(), angle.sin()) * outline; + painter.galley_with_override_text_color( + at + offset + ring, + galley.clone(), + ink, + ); + } + }; + if shadow > 0.0 { + rings(egui::vec2(shadow, shadow), colour(style.shadow_colour)); + } + if outline > 0.0 { + rings(egui::Vec2::ZERO, colour(style.outline_colour)); + } + } + } + painter.galley_with_override_text_color(at, galley, colour(style.colour)); +} + /// 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-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index e2696cd..fabe61d 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -36,6 +36,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[ ("N / P", "Next and previous episode"), ("[ / ]", "Slower and faster"), ("S", "Skip the opening or the credits"), + ("Z / X", "Subtitles 0.1 s earlier and later"), ("E", "The list of episodes"), ("F or double-click", "Fullscreen"), ("Esc", "Leave fullscreen, then the player"), diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index 4de41c2..3b096f3 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -11,6 +11,7 @@ //! and neither mutates: this module draws. use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2}; +use pstr_core::prefs::SubtitleStyle; use pstr_player::{MAX_VOLUME, Track, TrackKind}; use crate::app::{Action, Adjacent, Page}; @@ -470,6 +471,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec } ui.separator(); track_options(ui, &subtitles, TrackKind::Subtitle, actions); + ui.separator(); + subtitle_adjustments(ui, playback, actions); }) .response .on_hover_text("Subtitle track"); @@ -489,6 +492,49 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec } } +/// What is worth changing mid-episode without a trip to Settings: the size and +/// place of the line, and its timing for a release that is out of step. +fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) { + let mut style = playback.subtitle_style.clone(); + egui::Grid::new("subtitle-adjustments") + .num_columns(2) + .spacing([theme::space::M, theme::space::S]) + .show(ui, |ui| { + ui.label("Size"); + ui::settings::percent_slider( + ui, + &mut style.scale, + SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE, + ); + ui.end_row(); + + ui.label("Position"); + ui::settings::subtitle_position_slider(ui, &mut style.position); + ui.end_row(); + + ui.label("Delay"); + ui.horizontal(|ui| { + if ui.button("−").on_hover_text("Earlier (Z)").clicked() { + actions.push(Action::NudgeSubtitleDelay(-0.1)); + } + ui.label(ui::format_delay(playback.subtitle_delay)); + if ui.button("+").on_hover_text("Later (X)").clicked() { + actions.push(Action::NudgeSubtitleDelay(0.1)); + } + if ui + .add_enabled(playback.subtitle_delay != 0.0, egui::Button::new("Reset")) + .clicked() + { + actions.push(Action::ResetSubtitleDelay); + } + }); + ui.end_row(); + }); + if style != playback.subtitle_style { + actions.push(Action::SetSubtitleStyle(style.sanitized())); + } +} + fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec) { for track in tracks { if ui.selectable_label(track.selected, track.label()).clicked() { diff --git a/crates/pstr-core/src/prefs.rs b/crates/pstr-core/src/prefs.rs index 2f49e52..125d5c2 100644 --- a/crates/pstr-core/src/prefs.rs +++ b/crates/pstr-core/src/prefs.rs @@ -65,51 +65,107 @@ pub struct PlaybackPrefs { pub subtitle_style: SubtitleStyle, } -/// How plain-text subtitles are drawn. +/// How 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)] +/// Every default is mpv's own, so a viewer who never opens the section sees +/// what any mpv shows. Plain (SRT) tracks take all of it. A styled (ASS) track +/// was typeset by somebody, its signs placed and coloured by hand, and painting +/// over that undoes the work — so by default it takes only the size and the +/// position, and the rest reaches it only with [`Self::restyle_styled`]. +#[derive(Debug, Clone, PartialEq, 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, + pub scale: f64, + /// A font family name. `None` is mpv's `sans-serif`. A name this device + /// does not have falls back the same way, which is what makes it safe to + /// sync. + pub font: Option, + pub bold: bool, + pub italic: bool, + pub colour: Colour, + pub backdrop: SubtitleBackdrop, + pub outline_colour: Colour, + /// In mpv's scaled pixels, as `sub-outline-size`. + pub outline_size: f64, + /// The box's colour, its alpha the box's opacity. Only with + /// [`SubtitleBackdrop::Box`]. + pub box_colour: Colour, + pub shadow_colour: Colour, + /// How far the shadow falls; 0 is none, which is mpv's default. + pub shadow_offset: f64, + /// How soft the edges are, 0–20. + pub blur: f64, + /// Where the line sits, as a percentage of the picture's height from the + /// top: 100 is the bottom edge, mpv's usual place. + pub position: f64, + /// Whether styled (ASS) tracks are repainted in this style too. Off by + /// default: it turns signs and karaoke into dialogue-looking text. + pub restyle_styled: bool, +} + +impl SubtitleStyle { + pub const MIN_SCALE: f64 = 0.5; + pub const MAX_SCALE: f64 = 2.5; + pub const MAX_OUTLINE: f64 = 10.0; + pub const MAX_SHADOW: f64 = 10.0; + pub const MAX_BLUR: f64 = 20.0; + + /// The same style with every number brought back into range. + /// + /// A hand-edited or synced file can hold anything, and mpv refuses an + /// out-of-range value outright — which would cost the rest of the style. + pub fn sanitized(mut self) -> Self { + let defaults = Self::default(); + let clamp = |value: f64, default: f64, min: f64, max: f64| { + if value.is_finite() { + value.clamp(min, max) + } else { + default + } + }; + self.scale = clamp(self.scale, defaults.scale, Self::MIN_SCALE, Self::MAX_SCALE); + self.outline_size = clamp( + self.outline_size, + defaults.outline_size, + 0.0, + Self::MAX_OUTLINE, + ); + self.shadow_offset = clamp( + self.shadow_offset, + defaults.shadow_offset, + 0.0, + Self::MAX_SHADOW, + ); + self.blur = clamp(self.blur, defaults.blur, 0.0, Self::MAX_BLUR); + self.position = clamp(self.position, defaults.position, 0.0, 100.0); + self.font = self + .font + .map(|font| font.trim().to_owned()) + .filter(|font| !font.is_empty()); + self + } +} + +impl Default for SubtitleStyle { + fn default() -> Self { + Self { + scale: 1.0, + font: None, + bold: false, + italic: false, + colour: Colour::rgba(0xFF, 0xFF, 0xFF, 0xFF), + backdrop: SubtitleBackdrop::Outline, + outline_colour: Colour::rgba(0, 0, 0, 0xFF), + outline_size: 1.65, + // About seventy percent black. + box_colour: Colour::rgba(0, 0, 0, 0xB0), + shadow_colour: Colour::rgba(0, 0, 0, 0xAF), + shadow_offset: 0.0, + blur: 0.0, + position: 100.0, + restyle_styled: false, } } } @@ -118,11 +174,11 @@ impl SubtitleSize { #[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. + /// An 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. + /// A box behind each line. Uglier, and the only thing that holds up on + /// snow, sky and white walls. Box, } @@ -132,27 +188,77 @@ impl SubtitleBackdrop { pub fn label(self) -> &'static str { match self { Self::Outline => "Outline", - Self::Box => "Dark box", + Self::Box => "Box", } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum SubtitleColour { - #[default] - White, - Yellow, +/// A colour with opacity, written `#RRGGBBAA` the way CSS writes one. +/// +/// Not mpv's order: mpv reads `#AARRGGBB`, which nobody hand-editing a file +/// would guess. [`Self::to_mpv`] does the translation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Colour { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, } -impl SubtitleColour { - pub const ALL: [Self; 2] = [Self::White, Self::Yellow]; +impl Colour { + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } - pub fn label(self) -> &'static str { - match self { - Self::White => "White", - Self::Yellow => "Yellow", + /// `#RRGGBB` or `#RRGGBBAA`, the `#` optional. Six digits are opaque. + pub fn parse(text: &str) -> Option { + let hex = text.trim().strip_prefix('#').unwrap_or(text.trim()); + if !(hex.len() == 6 || hex.len() == 8) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; } + let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok(); + let a = if hex.len() == 8 { byte(6)? } else { 0xFF }; + Some(Self::rgba(byte(0)?, byte(2)?, byte(4)?, a)) + } + + /// As mpv's colour options take it. + pub fn to_mpv(self) -> String { + format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b) + } + + pub fn to_array(self) -> [u8; 4] { + [self.r, self.g, self.b, self.a] + } +} + +impl From<[u8; 4]> for Colour { + fn from([r, g, b, a]: [u8; 4]) -> Self { + Self::rgba(r, g, b, a) + } +} + +impl std::fmt::Display for Colour { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "#{:02X}{:02X}{:02X}{:02X}", + self.r, self.g, self.b, self.a + ) + } +} + +impl From for String { + fn from(colour: Colour) -> Self { + colour.to_string() + } +} + +impl TryFrom for Colour { + type Error = String; + + fn try_from(text: String) -> std::result::Result { + Self::parse(&text).ok_or_else(|| format!("not a colour: {text:?}")) } } @@ -188,6 +294,7 @@ impl PlaybackPrefs { self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED); self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty()); self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty()); + self.subtitle_style = self.subtitle_style.sanitized(); self } } @@ -245,9 +352,14 @@ mod tests { auto_skip: true, speed: 1.5, subtitle_style: SubtitleStyle { - size: SubtitleSize::Larger, + scale: 1.5, + font: Some("Noto Sans".into()), + italic: true, + colour: Colour::rgba(0xFF, 0xE6, 0x00, 0xFF), backdrop: SubtitleBackdrop::Box, - colour: SubtitleColour::Yellow, + box_colour: Colour::rgba(0x10, 0x10, 0x10, 0x80), + restyle_styled: true, + ..SubtitleStyle::default() }, }; save(&dirs, &prefs).unwrap(); @@ -264,6 +376,38 @@ mod tests { assert_eq!(prefs.volume, 60.0); } + #[test] + fn a_colour_is_written_rgba_and_handed_to_mpv_as_argb() { + let colour = Colour::parse("#FFE60080").unwrap(); + assert_eq!(colour, Colour::rgba(0xFF, 0xE6, 0x00, 0x80)); + assert_eq!(colour.to_string(), "#FFE60080"); + assert_eq!(colour.to_mpv(), "#80FFE600"); + assert_eq!( + Colour::parse("ffe600"), + Some(Colour::rgba(0xFF, 0xE6, 0, 0xFF)) + ); + assert_eq!(Colour::parse("#FFE6"), None); + assert_eq!(Colour::parse("#GGGGGG"), None); + } + + #[test] + fn a_subtitle_style_out_of_range_is_brought_back_into_it() { + let style = SubtitleStyle { + scale: 40.0, + outline_size: -3.0, + blur: f64::NAN, + position: 400.0, + font: Some(" ".into()), + ..SubtitleStyle::default() + } + .sanitized(); + assert_eq!(style.scale, SubtitleStyle::MAX_SCALE); + assert_eq!(style.outline_size, 0.0); + assert_eq!(style.blur, 0.0); + assert_eq!(style.position, 100.0); + assert_eq!(style.font, None); + } + #[test] fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() { let prefs = PlaybackPrefs { diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs index a6482fc..6764c93 100644 --- a/crates/pstr-player/src/player.rs +++ b/crates/pstr-player/src/player.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use libmpv2::events::{Event, PropertyData}; use libmpv2::{Format, Mpv}; -use pstr_core::prefs::{SubtitleBackdrop, SubtitleColour, SubtitleStyle}; +use pstr_core::prefs::{SubtitleBackdrop, SubtitleStyle}; use pstr_stream::VideoStream; use tokio::runtime::Runtime; @@ -182,7 +182,7 @@ 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`]. + /// How subtitles look. See [`subtitle_options`]. pub subtitle_style: SubtitleStyle, /// Raw mpv options, applied last, so anything here wins. pub options: Vec<(String, String)>, @@ -297,17 +297,9 @@ 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; - } - } + apply_subtitle_options(&config.subtitle_style, |name, value| { + init.set_property(name, value).map_err(Error::from) + }); for (name, value) in &config.options { init.set_property(name.as_str(), value.as_str())?; } @@ -552,6 +544,18 @@ impl Player { Ok(()) } + /// Restyle subtitles now, on the line already showing. + pub fn apply_subtitle_style(&self, style: &SubtitleStyle) { + apply_subtitle_options(style, |name, value| self.set_option(name, value)); + } + + /// Show subtitles `seconds` later (or, negative, earlier) than the file + /// says. For a release whose subtitles were timed against another cut. + pub fn set_subtitle_delay(&self, seconds: f64) -> Result<()> { + self.mpv.set_property("sub-delay", seconds)?; + Ok(()) + } + /// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's /// default and what keeps a voice a voice at 1.5×. pub fn set_speed(&self, speed: f64) -> Result<()> { @@ -608,32 +612,80 @@ impl Player { } } -/// The mpv options that draw `style`. +/// The first option of the block that only makes sense on top of it. See +/// [`apply_subtitle_options`]. +const BORDER_STYLE: &str = "sub-border-style"; + +/// The mpv options that draw `style`, every one of them every time, so that +/// changing a value back to its default while playing lands too. /// -/// 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. +/// `sub-ass-override` is `scale` unless the viewer asked otherwise: a styled +/// track then takes the size and keeps its own fonts, colours and placement. +/// `force` repaints it whole. See [`SubtitleStyle`] for why that is opt-in. +/// The border options come last, led by [`BORDER_STYLE`]. 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 { + let flag = |on: bool| if on { "yes" } else { "no" }.to_owned(); + let mut options = vec![ + ( + "sub-ass-override", + if style.restyle_styled { + "force" + } else { + "scale" + } + .to_owned(), + ), + ("sub-scale", format!("{:.2}", style.scale)), + ( + "sub-font", + style + .font + .clone() + .unwrap_or_else(|| "sans-serif".to_owned()), + ), + ("sub-bold", flag(style.bold)), + ("sub-italic", flag(style.italic)), + ("sub-color", style.colour.to_mpv()), + // mpv's name for the shadow's colour, older than the shadow alias. + ("sub-back-color", style.shadow_colour.to_mpv()), + ("sub-shadow-offset", format!("{:.2}", style.shadow_offset)), + ("sub-blur", format!("{:.2}", style.blur)), + ("sub-pos", format!("{:.0}", style.position)), + ]; + match style.backdrop { + SubtitleBackdrop::Outline => options.extend([ + (BORDER_STYLE, "outline-and-shadow".to_owned()), + ("sub-outline-color", style.outline_colour.to_mpv()), + ("sub-outline-size", format!("{:.2}", style.outline_size)), + ]), // `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()), - ]); + SubtitleBackdrop::Box => options.extend([ + (BORDER_STYLE, "opaque-box".to_owned()), + ("sub-outline-color", style.box_colour.to_mpv()), + ("sub-outline-size", "6".to_owned()), + ]), } options } +/// Hand `style` to `set` option by option, best effort. +/// +/// An option this libmpv refuses costs that option, not the player: +/// `sub-border-style` is mpv 0.38's, and Ubuntu 24.04 ships 0.37. A refused +/// border style ends the run, though, because what follows it only makes sense +/// on top of it: a box's colour and margin on a plain outline is a fat smear. +fn apply_subtitle_options(style: &SubtitleStyle, mut set: impl FnMut(&str, &str) -> Result<()>) { + for (name, value) in subtitle_options(style) { + if let Err(error) = set(name, &value) { + tracing::warn!("subtitle style {name}={value}: {error}"); + if name == BORDER_STYLE { + break; + } + } + } +} + /// mpv's borrowed event to ours. fn translate(event: Event<'_>) -> PlayerEvent { match event { @@ -661,45 +713,73 @@ 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()), - ] - ); + fn option<'a>(options: &'a [(&'static str, String)], name: &str) -> Option<&'a str> { + options + .iter() + .find(|(option, _)| *option == name) + .map(|(_, value)| value.as_str()) } #[test] - fn a_boxed_subtitle_style_turns_the_outline_into_a_box() { + fn the_default_subtitle_style_is_what_mpv_would_do_anyway() { + let options = subtitle_options(&SubtitleStyle::default()); + // mpv 0.41's own defaults, from `mpv --list-options`. + for (name, value) in [ + ("sub-ass-override", "scale"), + ("sub-scale", "1.00"), + ("sub-font", "sans-serif"), + ("sub-bold", "no"), + ("sub-italic", "no"), + ("sub-color", "#FFFFFFFF"), + ("sub-back-color", "#AF000000"), + ("sub-shadow-offset", "0.00"), + ("sub-blur", "0.00"), + ("sub-pos", "100"), + ("sub-border-style", "outline-and-shadow"), + ("sub-outline-color", "#FF000000"), + ("sub-outline-size", "1.65"), + ] { + assert_eq!(option(&options, name), Some(value), "{name}"); + } + } + + #[test] + fn a_boxed_subtitle_style_turns_the_outline_into_a_box_of_its_colour() { let style = SubtitleStyle { - size: pstr_core::prefs::SubtitleSize::Larger, backdrop: SubtitleBackdrop::Box, - colour: SubtitleColour::Yellow, + box_colour: pstr_core::prefs::Colour::rgba(0x20, 0x10, 0x00, 0x80), + ..SubtitleStyle::default() }; 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" - ); + assert_eq!(option(&options, "sub-border-style"), Some("opaque-box")); + assert_eq!(option(&options, "sub-outline-color"), Some("#80201000")); + } + + #[test] + fn styled_subtitles_are_repainted_only_when_asked() { + let kept = subtitle_options(&SubtitleStyle::default()); + assert_eq!(option(&kept, "sub-ass-override"), Some("scale")); + let repainted = subtitle_options(&SubtitleStyle { + restyle_styled: true, + ..SubtitleStyle::default() + }); + assert_eq!(option(&repainted, "sub-ass-override"), Some("force")); + } + + #[test] + fn a_refused_border_style_skips_what_depends_on_it_and_nothing_before() { + let mut set = Vec::new(); + apply_subtitle_options(&SubtitleStyle::default(), |name, _| { + set.push(name.to_owned()); + if name == BORDER_STYLE { + Err(Error::NulByte("test")) + } else { + Ok(()) + } + }); + assert_eq!(set.last().map(String::as_str), Some(BORDER_STYLE)); + assert!(set.iter().any(|name| name == "sub-color")); + assert!(!set.iter().any(|name| name == "sub-outline-color")); } #[test] diff --git a/docs/FAQ.md b/docs/FAQ.md index 5600eb7..7a1eee4 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -23,9 +23,15 @@ 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. +Yes. In **Settings → Subtitles** you can set the size, position, font, colours, outline, box, +shadow and blur. A **Box** background is easiest to read on bright scenes. Styled subtitles (ASS) +keep the look their makers gave them and take only the size and position, unless you turn on +**Restyle styled subtitles**. + +## The subtitles are early or late. + +Press **Z** to show them 0.1 s earlier and **X** to show them later, or use **Delay** in the +player's subtitle menu. The delay holds for the file you are watching only. ## Does it provide any videos? diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 51ddfab..ac313d7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -85,7 +85,8 @@ The picture plays inside the app's window. Move the mouse to show the controls. - **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose **Play now**, **Cancel** or **Watch till the end**. - **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your - choice for each show. + choice for each show. The subtitle menu also has **Size** and **Position**, which are saved like + the ones in Settings, and **Delay**, which moves subtitles earlier or later for this file only. - **Speed** goes from 0.5× to 3×, and stays for the next episode. ### Keyboard @@ -112,6 +113,7 @@ Press **?** anywhere to see this list in the app. | N / P | Next and previous episode | | [ / ] | Slower and faster | | S | Skip the opening or the credits | +| Z / X | Subtitles 0.1 s earlier and later | | E | The list of episodes | | F or double-click | Fullscreen | | Ctrl+S | Save the frame to `Pictures/proton-stream` | @@ -184,12 +186,26 @@ 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. +**Subtitles** + +How subtitles look. A sample line at the top shows the style on a bright and a dark picture, and +a video that is playing changes at once. + +- **Size** and **Position**: how big the line is and how far up the picture it sits. +- **Font**, **Bold** and **Italic**: any font installed on this computer. The sample line uses the + app's own font; the video uses the one you chose. +- **Text colour**, and a **Background** of **Outline** or **Box**, each with its own colour. A + colour's opacity sets how much of the picture shows through. Choose **Box** if text is hard to + read on bright scenes. +- **Shadow** and **Blur**. +- **Restyle styled subtitles** (off). Plain subtitles (SRT) always take the whole style. Styled + subtitles (ASS) keep the look their makers gave them and take only the size and position, + unless this is on. Turned on, signs and songs can come out wrong. +- **Reset** goes back to the default look. + **Storage** - **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,