feat(core,player,app): full subtitle styling, applied live, with a preview

The subtitle style was three fixed choices. It is now size, position,
font, bold and italic, free colours with opacity for the text, outline,
box and shadow, outline thickness, shadow distance and blur, and an
opt-in "Restyle styled subtitles" that repaints ASS dialogue too
(sub-ass-override=force). The defaults are mpv's own, so a viewer who
never opens the section sees no change.

Settings has a section of its own with a sample line drawn on a bright
and a dark picture. Every change also reaches the playing file at once,
and is written when the mouse is let go rather than once per frame of a
drag. The player's subtitle menu has size, position and a per-file
delay; Z and X shift subtitles by 0.1 s, as in mpv.

Every option is sent every time, so a change back to a default lands on
a live player. A refused sub-border-style still skips the border options
that depend on it, so an older libmpv loses the box rather than drawing
a smear.
This commit is contained in:
nvrl committed 2026-10-27 22:25:53 +01:00
1 parent 238ecd8220
commit 69d961d7b8
11 files changed
+933 -168

No files matched your search

+53 -2
View File
@@ -235,8 +235,14 @@ pub enum Action {
subtitles: bool,
subtitle: Option<String>,
},
/// 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<i64>),
/// 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<Action> = 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),
+45 -3
View File
@@ -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<Track>,
/// 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),
+71
View File
@@ -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<Vec<String>> = 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<String> {
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<String> {
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<String> {
let mut families: Vec<String> = 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);
+19
View File
@@ -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]
+322 -33
View File
@@ -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<Action>)
),
|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,50 +429,341 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Acti
);
}
/// How plain subtitles are drawn. A styled track keeps its own look apart
/// from the size; see [`pstr_core::prefs::SubtitleStyle`].
fn subtitle_style(ui: &mut egui::Ui, style: SubtitleStyle, actions: &mut Vec<Action>) {
let mut chosen = style;
/// 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<Action>) {
ui::widgets::settings_group(
ui,
"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| {
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,
"Subtitle size",
Some("Styled subtitles are scaled too. From the next file."),
"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| {
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::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;
}
},
);
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,
"Subtitle background",
Some("A box reads on bright scenes. Plain subtitles only; styled ones keep their own."),
"Restyle styled subtitles",
Some(
"Repaint styled (ASS) dialogue in this style too. Signs, songs and karaoke can come out wrong.",
),
|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::toggle(ui, &mut chosen.restyle_styled, "");
},
);
ui::widgets::settings_row(ui, "Subtitle colour", Some("Plain subtitles only."), |ui| {
egui::ComboBox::from_id_salt("subtitle-colour")
.selected_text(chosen.colour.label())
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.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<f64>,
) -> 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<String>) {
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| {
for colour in SubtitleColour::ALL {
ui.selectable_value(&mut chosen.colour, colour, colour.label());
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();
}
});
});
if chosen != style {
actions.push(Action::SetSubtitleStyle(chosen));
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.
+1
View File
@@ -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"),
+46
View File
@@ -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<Action>
}
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<Action>
}
}
/// 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<Action>) {
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<Action>) {
for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() {
+203 -59
View File
@@ -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<String>,
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];
pub fn label(self) -> &'static str {
match self {
Self::White => "White",
Self::Yellow => "Yellow",
impl Colour {
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
/// `#RRGGBB` or `#RRGGBBAA`, the `#` optional. Six digits are opaque.
pub fn parse(text: &str) -> Option<Self> {
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<Colour> for String {
fn from(colour: Colour) -> Self {
colour.to_string()
}
}
impl TryFrom<String> for Colour {
type Error = String;
fn try_from(text: String) -> std::result::Result<Self, Self::Error> {
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 {
+144 -64
View File
@@ -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())),
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"
}
if style.backdrop == SubtitleBackdrop::Box {
.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()),
]
);
}
#[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| {
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 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 {
backdrop: SubtitleBackdrop::Box,
box_colour: pstr_core::prefs::Colour::rgba(0x20, 0x10, 0x00, 0x80),
..SubtitleStyle::default()
};
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"
);
let options = subtitle_options(&style);
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]
+9 -3
View File
@@ -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?
+20 -4
View File
@@ -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,