mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,player,app): subtitle size, background and colour settings
Plain-text subtitles (SRT) were drawn in mpv's bare default, thin white text that bright scenes swallow. Settings → Playback now picks a size, an outline or a dark box, and white or yellow text. Styled (ASS) tracks keep their own look and take only the size. The box options are mpv 0.38's, so they are set best effort: an older libmpv logs and plays on. The style syncs with the other playback settings, and the Android bridge keeps the stored style instead of resetting it on save.
This commit is contained in:
1 parent
658c620c82
commit
65a669e231
9 files changed
+265
-3
No files matched your search
@@ -1909,7 +1909,11 @@ impl AndroidEngine {
|
||||
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
|
||||
// Sanitized rather than trusted: the volume arrives from a slider, and
|
||||
// a language from a text field that can hold spaces and nothing else.
|
||||
let prefs = playback_prefs(prefs).sanitized();
|
||||
// Onto what is stored, not the defaults: the record has no subtitle
|
||||
// style yet, and saving one without it would reset the desktop's and,
|
||||
// through sync, every other installation's.
|
||||
let stored = pstr_core::prefs::load(&self.dirs).unwrap_or_default();
|
||||
let prefs = playback_prefs(prefs, stored).sanitized();
|
||||
pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
@@ -3580,7 +3584,7 @@ fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord {
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
||||
fn playback_prefs(record: PlaybackPrefsRecord, stored: PlaybackPrefs) -> PlaybackPrefs {
|
||||
PlaybackPrefs {
|
||||
volume: record.volume,
|
||||
muted: record.muted,
|
||||
@@ -3590,6 +3594,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
||||
autoplay_next: record.autoplay_next,
|
||||
auto_skip: record.auto_skip,
|
||||
speed: record.speed,
|
||||
..stored
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ pub enum Action {
|
||||
subtitles: bool,
|
||||
subtitle: Option<String>,
|
||||
},
|
||||
/// How plain subtitles look, from the next file.
|
||||
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
|
||||
/// Play faster or slower, and keep doing so for the next file.
|
||||
SetSpeed(f64),
|
||||
/// Seek past openings and credits without being asked.
|
||||
@@ -1938,6 +1940,11 @@ impl App {
|
||||
prefs.subtitle_language = subtitle;
|
||||
self.engine.set_playback_prefs(prefs, true);
|
||||
}
|
||||
Action::SetSubtitleStyle(style) => {
|
||||
let mut prefs = self.engine.playback_prefs();
|
||||
prefs.subtitle_style = style;
|
||||
self.engine.set_playback_prefs(prefs, true);
|
||||
}
|
||||
Action::SetAppearance(appearance) => {
|
||||
self.engine.set_appearance(appearance);
|
||||
theme::apply(ctx, appearance);
|
||||
|
||||
@@ -494,6 +494,7 @@ fn from_prefs(
|
||||
.and_then(|p| p.subtitle_language.clone())
|
||||
.or_else(|| prefs.subtitle_language.clone()),
|
||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||
subtitle_style: prefs.subtitle_style,
|
||||
// Carried from file to file, like the volume: somebody who watches at
|
||||
// 1.25 watches the next episode at 1.25 too.
|
||||
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_core::prefs::{
|
||||
PlaybackPrefs, SubtitleBackdrop, SubtitleColour, SubtitleSize, SubtitleStyle,
|
||||
};
|
||||
use pstr_player::LANGUAGES;
|
||||
|
||||
use crate::app::Action;
|
||||
@@ -315,6 +317,7 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<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,
|
||||
@@ -428,6 +431,52 @@ 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;
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Subtitle size",
|
||||
Some("Styled subtitles are scaled too. From the next file."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("subtitle-size")
|
||||
.selected_text(chosen.size.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for size in SubtitleSize::ALL {
|
||||
ui.selectable_value(&mut chosen.size, size, size.label());
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(
|
||||
ui,
|
||||
"Subtitle background",
|
||||
Some("A box reads on bright scenes. Plain subtitles only; styled ones keep their own."),
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("subtitle-backdrop")
|
||||
.selected_text(chosen.backdrop.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for backdrop in SubtitleBackdrop::ALL {
|
||||
ui.selectable_value(&mut chosen.backdrop, backdrop, backdrop.label());
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui::widgets::settings_row(ui, "Subtitle colour", Some("Plain subtitles only."), |ui| {
|
||||
egui::ComboBox::from_id_salt("subtitle-colour")
|
||||
.selected_text(chosen.colour.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for colour in SubtitleColour::ALL {
|
||||
ui.selectable_value(&mut chosen.colour, colour, colour.label());
|
||||
}
|
||||
});
|
||||
});
|
||||
if chosen != style {
|
||||
actions.push(Action::SetSubtitleStyle(chosen));
|
||||
}
|
||||
}
|
||||
|
||||
/// The streaming cache: how big it may get, and a way to empty it.
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
|
||||
@@ -60,6 +60,100 @@ pub struct PlaybackPrefs {
|
||||
/// everything at 1.25, and a speed that reset every episode would be a
|
||||
/// setting nobody could use.
|
||||
pub speed: f64,
|
||||
/// How plain subtitles look. Kept with the rest because it is the same
|
||||
/// kind of choice: made once, for every file after.
|
||||
pub subtitle_style: SubtitleStyle,
|
||||
}
|
||||
|
||||
/// How plain-text subtitles are drawn.
|
||||
///
|
||||
/// Plain only, except for the size: an SRT track has no look of its own and
|
||||
/// gets mpv's, which is thin white text that a bright scene swallows. A styled
|
||||
/// (ASS) track was typeset by somebody, its signs placed and coloured by hand,
|
||||
/// and repainting it in one colour on one box undoes that work — so the
|
||||
/// backdrop and colour leave it alone, and only the scale reaches it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SubtitleStyle {
|
||||
pub size: SubtitleSize,
|
||||
pub backdrop: SubtitleBackdrop,
|
||||
pub colour: SubtitleColour,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SubtitleSize {
|
||||
Small,
|
||||
#[default]
|
||||
Normal,
|
||||
Large,
|
||||
Larger,
|
||||
}
|
||||
|
||||
impl SubtitleSize {
|
||||
pub const ALL: [Self; 4] = [Self::Small, Self::Normal, Self::Large, Self::Larger];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Small => "Small",
|
||||
Self::Normal => "Normal",
|
||||
Self::Large => "Large",
|
||||
Self::Larger => "Larger",
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative to mpv's own size, which is itself relative to the video's
|
||||
/// height — so one setting reads the same on a phone and a television.
|
||||
pub fn scale(self) -> f64 {
|
||||
match self {
|
||||
Self::Small => 0.8,
|
||||
Self::Normal => 1.0,
|
||||
Self::Large => 1.25,
|
||||
Self::Larger => 1.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What keeps the text readable against the picture.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SubtitleBackdrop {
|
||||
/// A dark outline round each letter: mpv's own, and the default.
|
||||
#[default]
|
||||
Outline,
|
||||
/// A dark, part-transparent box behind each line. Uglier, and the only
|
||||
/// thing that holds up on snow, sky and white walls.
|
||||
Box,
|
||||
}
|
||||
|
||||
impl SubtitleBackdrop {
|
||||
pub const ALL: [Self; 2] = [Self::Outline, Self::Box];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Outline => "Outline",
|
||||
Self::Box => "Dark box",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SubtitleColour {
|
||||
#[default]
|
||||
White,
|
||||
Yellow,
|
||||
}
|
||||
|
||||
impl SubtitleColour {
|
||||
pub const ALL: [Self; 2] = [Self::White, Self::Yellow];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::White => "White",
|
||||
Self::Yellow => "Yellow",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlaybackPrefs {
|
||||
@@ -73,6 +167,7 @@ impl Default for PlaybackPrefs {
|
||||
autoplay_next: true,
|
||||
auto_skip: false,
|
||||
speed: 1.0,
|
||||
subtitle_style: SubtitleStyle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +244,11 @@ mod tests {
|
||||
autoplay_next: false,
|
||||
auto_skip: true,
|
||||
speed: 1.5,
|
||||
subtitle_style: SubtitleStyle {
|
||||
size: SubtitleSize::Larger,
|
||||
backdrop: SubtitleBackdrop::Box,
|
||||
colour: SubtitleColour::Yellow,
|
||||
},
|
||||
};
|
||||
save(&dirs, &prefs).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||
@@ -156,6 +256,14 @@ mod tests {
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_subtitle_styles_loads_with_the_default_style() {
|
||||
let prefs: PlaybackPrefs =
|
||||
serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap();
|
||||
assert_eq!(prefs.subtitle_style, SubtitleStyle::default());
|
||||
assert_eq!(prefs.volume, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
|
||||
let prefs = PlaybackPrefs {
|
||||
|
||||
@@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start,
|
||||
pub use error::{Error, Result};
|
||||
pub use player::{
|
||||
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
|
||||
subtitle_options,
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use libmpv2::events::{Event, PropertyData};
|
||||
use libmpv2::{Format, Mpv};
|
||||
use pstr_core::prefs::{SubtitleBackdrop, SubtitleColour, SubtitleStyle};
|
||||
use pstr_stream::VideoStream;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
@@ -181,6 +182,8 @@ pub struct PlayerConfig {
|
||||
/// Whether subtitles are shown at all. Off is a choice a viewer makes once
|
||||
/// and expects to keep, and no language preference can express it.
|
||||
pub subtitles: bool,
|
||||
/// How plain subtitles look. See [`subtitle_options`].
|
||||
pub subtitle_style: SubtitleStyle,
|
||||
/// Raw mpv options, applied last, so anything here wins.
|
||||
pub options: Vec<(String, String)>,
|
||||
}
|
||||
@@ -206,6 +209,7 @@ impl Default for PlayerConfig {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
subtitle_style: SubtitleStyle::default(),
|
||||
options: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -293,6 +297,17 @@ impl Player {
|
||||
if !config.subtitles {
|
||||
init.set_property("sid", "no")?;
|
||||
}
|
||||
// Best effort: `sub-border-style` is mpv 0.38's, and an older
|
||||
// libmpv refusing it must cost the box, not the whole player. The
|
||||
// first refusal ends the run, because what follows it only makes
|
||||
// sense on top of it: a box's colour on a plain outline is a fat
|
||||
// grey smear.
|
||||
for (name, value) in subtitle_options(&config.subtitle_style) {
|
||||
if let Err(error) = init.set_property(name, value.as_str()) {
|
||||
tracing::warn!("subtitle style {name}={value}: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (name, value) in &config.options {
|
||||
init.set_property(name.as_str(), value.as_str())?;
|
||||
}
|
||||
@@ -593,6 +608,32 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mpv options that draw `style`.
|
||||
///
|
||||
/// Leaves `sub-ass-override` at mpv's default, `scale`: a styled track takes
|
||||
/// the size and keeps its own fonts, colours and placement. See
|
||||
/// [`SubtitleStyle`] for why.
|
||||
pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> {
|
||||
let mut options = vec![("sub-scale", format!("{:.2}", style.size.scale()))];
|
||||
match style.colour {
|
||||
// mpv's own default; stated so a live change back from yellow lands.
|
||||
SubtitleColour::White => options.push(("sub-color", "#FFFFFFFF".into())),
|
||||
SubtitleColour::Yellow => options.push(("sub-color", "#FFFFE600".into())),
|
||||
}
|
||||
if style.backdrop == SubtitleBackdrop::Box {
|
||||
// `opaque-box` draws the outline as a box hugging each line, so the
|
||||
// outline's colour and size become the box's colour and margin.
|
||||
// Ordered so the style comes first: see the caller.
|
||||
options.extend([
|
||||
("sub-border-style", "opaque-box".into()),
|
||||
// ARGB: about seventy percent black.
|
||||
("sub-outline-color", "#B0000000".into()),
|
||||
("sub-outline-size", "6".into()),
|
||||
]);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// mpv's borrowed event to ours.
|
||||
fn translate(event: Event<'_>) -> PlayerEvent {
|
||||
match event {
|
||||
@@ -620,6 +661,47 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_default_subtitle_style_changes_nothing_mpv_would_not_do_anyway() {
|
||||
let options = subtitle_options(&SubtitleStyle::default());
|
||||
assert_eq!(
|
||||
options,
|
||||
vec![
|
||||
("sub-scale", "1.00".to_string()),
|
||||
("sub-color", "#FFFFFFFF".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boxed_subtitle_style_turns_the_outline_into_a_box() {
|
||||
let style = SubtitleStyle {
|
||||
size: pstr_core::prefs::SubtitleSize::Larger,
|
||||
backdrop: SubtitleBackdrop::Box,
|
||||
colour: SubtitleColour::Yellow,
|
||||
};
|
||||
let options = subtitle_options(&style);
|
||||
let value = |name: &str| {
|
||||
options
|
||||
.iter()
|
||||
.find(|(option, _)| *option == name)
|
||||
.map(|(_, value)| value.as_str())
|
||||
};
|
||||
assert_eq!(value("sub-scale"), Some("1.50"));
|
||||
assert_eq!(value("sub-color"), Some("#FFFFE600"));
|
||||
assert_eq!(value("sub-border-style"), Some("opaque-box"));
|
||||
assert_eq!(value("sub-outline-color"), Some("#B0000000"));
|
||||
assert_eq!(
|
||||
options[2].0, "sub-border-style",
|
||||
"the style before what depends on it"
|
||||
);
|
||||
assert_eq!(
|
||||
value("sub-ass-override"),
|
||||
None,
|
||||
"a styled track keeps its own look"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_end_reason_maps_to_something_the_ui_can_act_on() {
|
||||
use libmpv2::mpv_end_file_reason as raw;
|
||||
|
||||
Reference in new issue
Block a user