Compare commits

6 Commits
Author SHA1 Message Date
nvrl 79f0d74e1a chore(release): bump version to 1.7.0 2026-10-28 02:43:25 +01:00
nvrl 648c35e92a fix(app): the subtitle preview is drawn in the chosen font
The preview drew every font in the app's own Inter, so picking one changed
nothing on screen. On Linux it now asks fontconfig for the face libass would
use — the family with the weight and slant wanted, or fontconfig's fallback
when it is not installed — loads it on a thread of its own and draws the
sample in it. A bold or italic the face lacks is made up, as libass does.
Windows and macOS have no fc-match and keep the app's font, as the hint there
still says.

The CJK fallback and the subtitle face are now both kept, so installing one
no longer drops the other.
2026-10-27 23:08:43 +01:00
nvrl ffd6e959bb fix(core,player,app): styled subtitles keep their size and place unless restyled
mpv's default sub-ass-override, `scale`, hands an ASS track sub-scale,
and sub-pos reaches it under every mode but `no`. So with "Restyle
styled subtitles" off, the subtitle size and position settings still
resized and moved typeset dialogue and signs.

The override is now `yes` when restyling is off, which keeps sub-scale
off ASS. A styled track also ignores the style's position, and moves
only to stay clear of the player's controls. A frame from an ASS track
rendered with every other option changed now matches the untouched one
pixel for pixel. Plain (SRT) tracks still take the whole style.
2026-10-27 22:44:35 +01:00
nvrl 69d961d7b8 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.
2026-10-27 22:25:53 +01:00
nvrl 238ecd8220 fix(core,meta): a folder of films is one title per film
The four Rebuild of Evangelion films in one folder were one series:
`- 1.11` read as episode 1, and every file took the folder's name, so
the whole folder matched a single film. A dash followed by a decimal is
now part of the film's name, and a folder whose files are all
unnumbered, outside any grouping folder and each named differently
gives each file its own title.

AniList needs every query word in a title, and a film's version number
is not in it, so a search that finds nothing is retried without it.
All four films now match their own entries.
2026-10-27 19:50:26 +01:00
nvrl 65a669e231 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.
2026-10-27 19:50:21 +01:00
20 changed files with 1676 additions and 40 deletions

No files matched your search

Generated
+7 -7
View File
@@ -5279,7 +5279,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures", "futures",
@@ -5299,7 +5299,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5333,7 +5333,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5352,7 +5352,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -5385,7 +5385,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -5400,7 +5400,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "1.6.2" version = "1.7.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.6.2" version = "1.7.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
+7 -2
View File
@@ -1909,7 +1909,11 @@ impl AndroidEngine {
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> { pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
// Sanitized rather than trusted: the volume arrives from a slider, and // Sanitized rather than trusted: the volume arrives from a slider, and
// a language from a text field that can hold spaces and nothing else. // 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) 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 { PlaybackPrefs {
volume: record.volume, volume: record.volume,
muted: record.muted, muted: record.muted,
@@ -3590,6 +3594,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
autoplay_next: record.autoplay_next, autoplay_next: record.autoplay_next,
auto_skip: record.auto_skip, auto_skip: record.auto_skip,
speed: record.speed, speed: record.speed,
..stored
} }
} }
+58
View File
@@ -235,6 +235,14 @@ pub enum Action {
subtitles: bool, subtitles: bool,
subtitle: Option<String>, subtitle: Option<String>,
}, },
/// 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. /// Play faster or slower, and keep doing so for the next file.
SetSpeed(f64), SetSpeed(f64),
/// Seek past openings and credits without being asked. /// Seek past openings and credits without being asked.
@@ -654,6 +662,8 @@ pub struct App {
/// Chapters already skipped on their own in this player, so seeking back /// Chapters already skipped on their own in this player, so seeking back
/// into an opening on purpose is not undone a frame later. /// into an opening on purpose is not undone a frame later.
auto_skipped: (u64, Vec<i64>), 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. /// Whether the episode list is out on the player page.
pub episode_drawer: bool, pub episode_drawer: bool,
/// Set while the window is fullscreen, so `F` can toggle rather than only /// Set while the window is fullscreen, so `F` can toggle rather than only
@@ -760,6 +770,7 @@ impl App {
up_next: UpNext::default(), up_next: UpNext::default(),
warmed: 0, warmed: 0,
auto_skipped: (0, Vec::new()), auto_skipped: (0, Vec::new()),
subtitle_style_unsaved: false,
fullscreen: false, fullscreen: false,
episode_drawer: false, episode_drawer: false,
opening: None, opening: None,
@@ -1437,6 +1448,21 @@ impl App {
self.apply(ctx, Action::Play(target)); 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) { fn send_player(&self, command: crate::playback::Command) {
if let Some(playback) = &self.playback { if let Some(playback) = &self.playback {
playback.send(command); playback.send(command);
@@ -1938,6 +1964,32 @@ impl App {
prefs.subtitle_language = subtitle; prefs.subtitle_language = subtitle;
self.engine.set_playback_prefs(prefs, true); 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, 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) => { Action::SetAppearance(appearance) => {
self.engine.set_appearance(appearance); self.engine.set_appearance(appearance);
theme::apply(ctx, appearance); theme::apply(ctx, appearance);
@@ -2129,6 +2181,7 @@ impl eframe::App for App {
} }
self.warm_next(); self.warm_next();
self.auto_skip(ctx); self.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx); self.window.observe(ctx);
let mut actions: Vec<Action> = Vec::new(); let mut actions: Vec<Action> = Vec::new();
@@ -2909,6 +2962,8 @@ fn shortcuts(
Key::OpenBracket, Key::OpenBracket,
Key::CloseBracket, Key::CloseBracket,
Key::E, Key::E,
Key::Z,
Key::X,
Key::Escape, Key::Escape,
] ]
.into_iter() .into_iter()
@@ -2952,6 +3007,9 @@ fn shortcuts(
// Escape leaves fullscreen first and the page second, which is what // Escape leaves fullscreen first and the page second, which is what
// it does everywhere else and what stops one press from both // it does everywhere else and what stops one press from both
// un-maximising the window and hiding the film. // 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), Key::E => actions.push(Action::ToggleEpisodes),
// The episode list is the innermost thing open, so it goes first. // The episode list is the innermost thing open, so it goes first.
Key::Escape if drawer => actions.push(Action::ToggleEpisodes), Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
+87 -3
View File
@@ -147,6 +147,10 @@ pub enum Command {
/// Move subtitles, as a percentage of the window's height. See /// Move subtitles, as a percentage of the window's height. See
/// [`Playback::lift_subtitles`]. /// [`Playback::lift_subtitles`].
SetSubtitlePosition(f64), 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. /// 1.0 is normal speed.
SetSpeed(f64), SetSpeed(f64),
/// Save the current frame to this file. /// 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 /// Where subtitles were last put, so they are only moved when that changes
/// rather than once a frame. /// rather than once a frame.
subtitle_position: f64, 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. /// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>, pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them. /// The file's chapters, in order. Empty for a file muxed without them.
@@ -259,7 +271,10 @@ impl Playback {
paused: false, paused: false,
volume: prefs.volume, volume: prefs.volume,
muted: prefs.muted, 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, speed: prefs.speed,
tracks: Vec::new(), tracks: Vec::new(),
chapters: Vec::new(), chapters: Vec::new(),
@@ -282,17 +297,57 @@ impl Playback {
/// the mouse is moving, which is exactly when someone is looking at the /// the mouse is moving, which is exactly when someone is looking at the
/// screen. /// screen.
pub fn lift_subtitles(&mut self, covered: f32, height: f32) { 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() (100.0 * (1.0 - f64::from(covered / height))).round()
} else { } else {
100.0 100.0
}; };
self.place_subtitles();
}
/// Move subtitles to [`Self::subtitle_position`], when that has changed.
fn place_subtitles(&mut self) {
let position = self.subtitle_position();
if position != self.subtitle_position { if position != self.subtitle_position {
self.subtitle_position = position; self.subtitle_position = position;
self.send(Command::SetSubtitlePosition(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.
///
/// A styled track ignores the style's position unless it is restyled:
/// mpv applies `sub-pos` to it either way, and its signs were placed by
/// hand. The lift still applies, because a line under the seek bar cannot
/// be read however carefully it was placed.
fn subtitle_position(&self) -> f64 {
let styled = self
.selected_track(TrackKind::Subtitle)
.is_some_and(|track| is_styled(track.codec.as_deref()));
let position = if styled && !self.subtitle_style.restyle_styled {
100.0
} else {
self.subtitle_style.position
};
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. /// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool { pub fn is_embedded(&self) -> bool {
self.video.is_some() self.video.is_some()
@@ -372,7 +427,11 @@ impl Playback {
PlayerEvent::FileLoaded => self.loaded = true, PlayerEvent::FileLoaded => self.loaded = true,
PlayerEvent::Volume(volume) => self.volume = *volume, PlayerEvent::Volume(volume) => self.volume = *volume,
PlayerEvent::Muted(muted) => self.muted = *muted, PlayerEvent::Muted(muted) => self.muted = *muted,
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(), PlayerEvent::Tracks(tracks) => {
self.tracks = tracks.clone();
// A switch between a plain and a styled track moves the line.
self.place_subtitles();
}
PlayerEvent::Chapters(chapters) => { PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone(); self.chapters = chapters.clone();
self.reclassify(); self.reclassify();
@@ -479,6 +538,11 @@ fn build_player(
Ok((Arc::new(player), None)) Ok((Arc::new(player), None))
} }
/// Whether a subtitle codec carries its own styling, as mpv names them.
fn is_styled(codec: Option<&str>) -> bool {
matches!(codec, Some("ass" | "ssa"))
}
/// The player defaults with the viewer's preferences folded in. /// The player defaults with the viewer's preferences folded in.
fn from_prefs( fn from_prefs(
prefs: &PlaybackPrefs, prefs: &PlaybackPrefs,
@@ -494,6 +558,7 @@ fn from_prefs(
.and_then(|p| p.subtitle_language.clone()) .and_then(|p| p.subtitle_language.clone())
.or_else(|| prefs.subtitle_language.clone()), .or_else(|| prefs.subtitle_language.clone()),
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
subtitle_style: prefs.subtitle_style.clone(),
// Carried from file to file, like the volume: somebody who watches at // Carried from file to file, like the volume: somebody who watches at
// 1.25 watches the next episode at 1.25 too. // 1.25 watches the next episode at 1.25 too.
options: vec![("speed".into(), format!("{:.2}", prefs.speed))], options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
@@ -658,6 +723,11 @@ fn apply_command(
Command::SetVolume(volume) => player.set_volume(volume), Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted), Command::SetMuted(muted) => player.set_muted(muted),
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), 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::SetSpeed(speed) => player.set_speed(speed),
Command::Screenshot(path) => player.screenshot(&path), Command::Screenshot(path) => player.screenshot(&path),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
@@ -820,3 +890,17 @@ fn save(
watch_state(position, duration, watched), watch_state(position, duration, watched),
); );
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_ass_and_ssa_tracks_count_as_styled() {
assert!(is_styled(Some("ass")));
assert!(is_styled(Some("ssa")));
assert!(!is_styled(Some("subrip")));
assert!(!is_styled(Some("hdmv_pgs_subtitle")));
assert!(!is_styled(None));
}
}
+285 -15
View File
@@ -186,9 +186,48 @@ pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into()) egui::FontFamily::Name("semibold".into())
} }
/// The family the subtitle preview is drawn in: the face the chosen subtitle
/// font resolves to, ahead of the app's own fonts, which stand in until it has
/// loaded and for any glyph it lacks.
pub fn subtitle_family() -> egui::FontFamily {
egui::FontFamily::Name("subtitle".into())
}
/// The system fonts loaded so far. Each install builds the definitions from
/// all of them, so loading one does not drop the other.
#[derive(Default)]
struct SystemFonts {
cjk: Option<Arc<egui::FontData>>,
subtitle: Option<(SubtitleRequest, Arc<egui::FontData>, SubtitleFace)>,
/// The subtitle face last asked for, loading or loaded.
wanted: Option<SubtitleRequest>,
}
static SYSTEM_FONTS: parking_lot::Mutex<SystemFonts> = parking_lot::Mutex::new(SystemFonts {
cjk: None,
subtitle: None,
wanted: None,
});
#[derive(Debug, Clone, PartialEq, Eq)]
struct SubtitleRequest {
family: String,
bold: bool,
italic: bool,
}
/// What the loaded subtitle face draws by itself. A style it lacks is one
/// libass makes up, and so must the preview.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubtitleFace {
pub bold: bool,
pub italic: bool,
}
/// Inter, the icon font, and egui's own fonts behind them for the emoji and /// Inter, the icon font, and egui's own fonts behind them for the emoji and
/// symbols Inter does not carry. `cjk` goes last, when there is one. /// symbols Inter does not carry. The CJK fallback goes last, when there is
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions { /// one.
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
use egui::FontFamily::{Monospace, Proportional}; use egui::FontFamily::{Monospace, Proportional};
let mut fonts = egui::FontDefinitions::default(); let mut fonts = egui::FontDefinitions::default();
@@ -207,21 +246,29 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
// Right behind Inter. The icons are in the private use area, which the // Right behind Inter. The icons are in the private use area, which the
// bundled Inter no longer maps, so neither font shadows the other. // bundled Inter no longer maps, so neither font shadows the other.
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
if let Some(cjk) = cjk { if let Some(cjk) = &system.cjk {
let name = "system-cjk-fallback".to_owned(); let name = "system-cjk-fallback".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(cjk)); fonts.font_data.insert(name.clone(), cjk.clone());
for family in [Proportional, Monospace] { for family in [Proportional, Monospace] {
fonts.families.entry(family).or_default().push(name.clone()); fonts.families.entry(family).or_default().push(name.clone());
} }
} }
let fallbacks = fonts.families[&Proportional] let fallbacks: Vec<String> = fonts.families[&Proportional]
.iter() .iter()
.filter(|name| *name != "inter") .filter(|name| *name != "inter")
.cloned(); .cloned()
.collect();
let semibold_chain = std::iter::once("inter-semibold".to_owned()) let semibold_chain = std::iter::once("inter-semibold".to_owned())
.chain(fallbacks) .chain(fallbacks)
.collect(); .collect();
fonts.families.insert(semibold(), semibold_chain); fonts.families.insert(semibold(), semibold_chain);
let mut subtitle_chain = fonts.families[&Proportional].clone();
if let Some((_, face, _)) = &system.subtitle {
let name = "system-subtitle".to_owned();
fonts.font_data.insert(name.clone(), face.clone());
subtitle_chain.insert(0, name);
}
fonts.families.insert(subtitle_family(), subtitle_chain);
fonts fonts
} }
@@ -234,7 +281,7 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families, /// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
/// and native desktops already provide one. /// and native desktops already provide one.
pub fn install_fonts(ctx: &egui::Context) { pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(None)); ctx.set_fonts(font_definitions(&SYSTEM_FONTS.lock()));
let ctx = ctx.clone(); let ctx = ctx.clone();
let spawned = std::thread::Builder::new() let spawned = std::thread::Builder::new()
.name("fonts".into()) .name("fonts".into())
@@ -251,20 +298,80 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Ok(bytes) = std::fs::read(&path) else { let Ok(bytes) = std::fs::read(&path) else {
return; return;
}; };
let Some(data) = cjk_font_data(bytes, index) else { let Some(data) = system_font_data(bytes, index) else {
tracing::debug!("CJK fallback {}#{index} does not parse", path.display()); tracing::debug!("CJK fallback {}#{index} does not parse", path.display());
return; return;
}; };
ctx.set_fonts(font_definitions(Some(data))); let mut system = SYSTEM_FONTS.lock();
system.cjk = Some(Arc::new(data));
ctx.set_fonts(font_definitions(&system));
ctx.request_repaint(); ctx.request_repaint();
} }
/// The fallback font, if egui can read it. /// The face the subtitle preview should be drawn in for this font, bold and
/// italic, once [`subtitle_family`] holds it.
///
/// `None` while it loads, and for good where the system cannot say which file
/// a family is — everywhere but Linux. The first call for a font starts the
/// load on a thread of its own, since `fc-match` and reading the file are too
/// slow for a frame, and asks for a repaint when it is in.
pub fn subtitle_face(
ctx: &egui::Context,
family: &str,
bold: bool,
italic: bool,
) -> Option<SubtitleFace> {
let request = SubtitleRequest {
family: family.to_owned(),
bold,
italic,
};
let mut system = SYSTEM_FONTS.lock();
if let Some((loaded, _, face)) = &system.subtitle
&& *loaded == request
{
return Some(*face);
}
if system.wanted.as_ref() != Some(&request) {
system.wanted = Some(request.clone());
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("subtitle-font".into())
.spawn(move || install_subtitle_face(&ctx, request));
if let Err(error) = spawned {
tracing::debug!("subtitle font thread: {error}");
}
}
None
}
fn install_subtitle_face(ctx: &egui::Context, request: SubtitleRequest) {
let Some((path, index, face)) = system_subtitle_font(&request) else {
return;
};
let Ok(bytes) = std::fs::read(&path) else {
return;
};
let Some(data) = system_font_data(bytes, index) else {
tracing::debug!("subtitle font {}#{index} does not parse", path.display());
return;
};
let mut system = SYSTEM_FONTS.lock();
// Another font was picked while this one loaded.
if system.wanted.as_ref() != Some(&request) {
return;
}
system.subtitle = Some((request, Arc::new(data), face));
ctx.set_fonts(font_definitions(&system));
ctx.request_repaint();
}
/// A system font, if egui can read it.
/// ///
/// egui panics on a font it cannot parse, and this one comes from whatever the /// egui panics on a font it cannot parse, and this one comes from whatever the
/// system has installed. So it is parsed here first, the same way egui does, /// system has installed. So it is parsed here first, the same way egui does,
/// and a font that fails is left out rather than taking the app down. /// and a font that fails is left out rather than taking the app down.
fn cjk_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> { fn system_font_data(bytes: Vec<u8>, index: u32) -> Option<egui::FontData> {
skrifa::FontRef::from_index(&bytes, index).ok()?; skrifa::FontRef::from_index(&bytes, index).ok()?;
let mut data = egui::FontData::from_owned(bytes); let mut data = egui::FontData::from_owned(bytes);
data.index = index; data.index = index;
@@ -299,6 +406,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
path.is_file().then_some((path, index)) path.is_file().then_some((path, index))
} }
/// The file fontconfig picks for a subtitle font, as libass asks for it: the
/// family with the weight and slant wanted, falling back as libass would to
/// whatever fontconfig likes best when the family is not installed.
#[cfg(target_os = "linux")]
fn system_subtitle_font(
request: &SubtitleRequest,
) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
let output = std::process::Command::new("fc-match")
.args([
"-f",
"%{file}\\n%{index}\\n%{weight}\\n%{slant}",
&fontconfig_pattern(request),
])
.output()
.ok()?;
let text = std::str::from_utf8(&output.stdout).ok()?;
let mut lines = text.lines();
let path = std::path::PathBuf::from(lines.next()?);
let mut number = || lines.next().and_then(|value| value.parse::<u32>().ok());
let index = number().unwrap_or(0);
let (weight, slant) = (number().unwrap_or(0), number().unwrap_or(0));
// A variable font's named instance is drawn by egui as the default one,
// which is upright and regular, whatever fontconfig says it matched.
let instance = index != fontconfig_face(index);
let face = SubtitleFace {
bold: !instance && weight >= FC_WEIGHT_BOLD,
italic: !instance && slant > 0,
};
path.is_file()
.then_some((path, fontconfig_face(index), face))
}
#[cfg(any(target_os = "linux", test))]
const FC_WEIGHT_BOLD: u32 = 200;
/// A fontconfig pattern for the request. A family name is free text, and `-`,
/// `:`, `,` and `\\` mean something in a pattern, so those are escaped.
#[cfg(any(target_os = "linux", test))]
fn fontconfig_pattern(request: &SubtitleRequest) -> String {
let mut pattern = String::new();
for character in request.family.chars() {
if matches!(character, '-' | ':' | ',' | '\\') {
pattern.push('\\');
}
pattern.push(character);
}
if request.bold {
pattern.push_str(&format!(":weight={FC_WEIGHT_BOLD}"));
}
if request.italic {
pattern.push_str(":slant=100");
}
pattern
}
/// Elsewhere there is no asking which file a family is, and the preview
/// stays in the app's own font.
#[cfg(not(target_os = "linux"))]
fn system_subtitle_font(_: &SubtitleRequest) -> Option<(std::path::PathBuf, u32, SubtitleFace)> {
None
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> { fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc"); let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
@@ -316,6 +485,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
None 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. /// Every colour the app draws with, as egui wants them.
/// ///
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared /// The *choice* of colours is `pstr_core::appearance` — one resolution shared
@@ -881,6 +1112,15 @@ const RAMP_STOPS: usize = 64;
mod tests { mod tests {
use super::*; 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] #[test]
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() { fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
assert_eq!(fontconfig_face(262_144), 0); assert_eq!(fontconfig_face(262_144), 0);
@@ -889,10 +1129,40 @@ mod tests {
} }
#[test] #[test]
fn a_fallback_font_egui_cannot_read_is_left_out() { fn a_system_font_egui_cannot_read_is_left_out() {
assert!(cjk_font_data(INTER.to_vec(), 0).is_some()); assert!(system_font_data(INTER.to_vec(), 0).is_some());
assert!(cjk_font_data(INTER.to_vec(), 4).is_none()); assert!(system_font_data(INTER.to_vec(), 4).is_none());
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none()); assert!(system_font_data(b"not a font".to_vec(), 0).is_none());
}
#[test]
fn a_subtitle_font_is_asked_of_fontconfig_with_its_style_and_escaped() {
let request = |family: &str, bold, italic| SubtitleRequest {
family: family.to_owned(),
bold,
italic,
};
assert_eq!(
fontconfig_pattern(&request("Noto Sans", false, false)),
"Noto Sans"
);
assert_eq!(
fontconfig_pattern(&request("Noto Sans", true, true)),
"Noto Sans:weight=200:slant=100"
);
assert_eq!(
fontconfig_pattern(&request("A-B:C,D\\E", false, true)),
"A\\-B\\:C\\,D\\\\E:slant=100"
);
}
#[test]
fn the_subtitle_preview_family_exists_before_any_font_has_loaded() {
let fonts = font_definitions(&SystemFonts::default());
assert_eq!(
fonts.families[&subtitle_family()],
fonts.families[&egui::FontFamily::Proportional]
);
} }
#[test] #[test]
+19
View File
@@ -206,6 +206,18 @@ pub fn format_speed(speed: f64) -> String {
format!("{}×", text.trim_end_matches('0').trim_end_matches('.')) 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. /// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String { pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
@@ -917,6 +929,13 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
+364 -1
View File
@@ -7,7 +7,7 @@
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::{Colour, PlaybackPrefs, SubtitleBackdrop, SubtitleStyle};
use pstr_player::LANGUAGES; use pstr_player::LANGUAGES;
use crate::app::Action; use crate::app::Action;
@@ -58,6 +58,7 @@ pub fn show(
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
appearance_form(ui, prefs.appearance, prefs.desktop, actions); appearance_form(ui, prefs.appearance, prefs.desktop, actions);
playback_form(ui, prefs, actions); playback_form(ui, prefs, actions);
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
storage_form(ui, prefs, actions); storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions); metadata_form(ui, settings, api_key, actions);
about(ui); about(ui);
@@ -428,6 +429,368 @@ fn languages(ui: &mut egui::Ui, playback: &PlaybackPrefs, actions: &mut Vec<Acti
); );
} }
/// 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 none of it unless it is asked to;
/// 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) keep their own look 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,
"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(if cfg!(target_os = "linux") {
"Any font installed here."
} else {
"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;
}
},
);
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(
"Draw styled (ASS) dialogue in this style too, size and position included. 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.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| {
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 font is the face fontconfig picks for the chosen one,
/// as libass's is, but only on Linux; elsewhere, and while it loads, it is the
/// app's own. A bold or italic the face lacks is made up, as libass does, and
/// there is 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 face = theme::subtitle_face(
ui.ctx(),
style.font.as_deref().unwrap_or(SubtitleStyle::DEFAULT_FONT),
style.bold,
style.italic,
);
let (family, made_up_bold, made_up_italic) = match face {
Some(face) => (
theme::subtitle_family(),
style.bold && !face.bold,
style.italic && !face.italic,
),
None if style.bold => (theme::semibold(), false, style.italic),
None => (egui::FontFamily::Proportional, false, style.italic),
};
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: made_up_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));
}
}
}
if made_up_bold {
// libass thickens a face with no bold by a stroke of its own colour.
let thicken = (font_px / 40.0).max(0.5);
painter.galley_with_override_text_color(
at + egui::vec2(thicken, 0.0),
galley.clone(),
colour(style.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. /// 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>) { fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
ui::widgets::settings_group( ui::widgets::settings_group(
+1
View File
@@ -36,6 +36,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("N / P", "Next and previous episode"), ("N / P", "Next and previous episode"),
("[ / ]", "Slower and faster"), ("[ / ]", "Slower and faster"),
("S", "Skip the opening or the credits"), ("S", "Skip the opening or the credits"),
("Z / X", "Subtitles 0.1 s earlier and later"),
("E", "The list of episodes"), ("E", "The list of episodes"),
("F or double-click", "Fullscreen"), ("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"), ("Esc", "Leave fullscreen, then the player"),
+46
View File
@@ -11,6 +11,7 @@
//! and neither mutates: this module draws. //! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2}; use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
use pstr_core::prefs::SubtitleStyle;
use pstr_player::{MAX_VOLUME, Track, TrackKind}; use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page}; 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(); ui.separator();
track_options(ui, &subtitles, TrackKind::Subtitle, actions); track_options(ui, &subtitles, TrackKind::Subtitle, actions);
ui.separator();
subtitle_adjustments(ui, playback, actions);
}) })
.response .response
.on_hover_text("Subtitle track"); .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>) { fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
for track in tracks { for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() { if ui.selectable_label(track.selected, track.label()).clicked() {
+207 -4
View File
@@ -1549,7 +1549,9 @@ pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode {
/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their /// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
/// contents group under the series above them; /// contents group under the series above them;
/// - whatever the filename claimed as a title, when it differs, is kept as the /// - whatever the filename claimed as a title, when it differs, is kept as the
/// episode's own name — with the redundant series prefix stripped. /// episode's own name — with the redundant series prefix stripped;
/// - **a folder of films is one title per film**, not one title: see
/// [`split_films`].
/// ///
/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg` /// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg`
/// alongside the video, and a catalog that offers those as episodes is worse /// alongside the video, and a catalog that offers those as episodes is worse
@@ -1562,12 +1564,18 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
.map(|node| (node.uid.link_id.as_str(), node)) .map(|node| (node.uid.link_id.as_str(), node))
.collect(); .collect();
nodes let mut origins = Vec::new();
let mut rows: Vec<CatalogNode> = nodes
.iter() .iter()
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name)) .filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
.map(|node| { .map(|node| {
let mut row = from_node(share_id, node); let mut row = from_node(share_id, node);
let context = ancestry(node, &by_link); let context = ancestry(node, &by_link);
origins.push(Origin {
own: row.parsed.clone(),
folder: context.title_folder.clone(),
through_container: context.through_container,
});
if let Some(title) = context.title { if let Some(title) = context.title {
// The filename's own title, if it had one and it differs, is the // The filename's own title, if it had one and it differs, is the
@@ -1601,7 +1609,75 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
} }
row row
}) })
.collect() .collect();
split_films(&mut rows, &origins);
rows
}
/// Where a row's title came from, kept for [`split_films`].
struct Origin {
/// What the filename alone parsed as, before the folder overrode it.
own: ParsedName,
/// The link id of the folder that named the row.
folder: Option<String>,
through_container: bool,
}
/// Give each film in a folder of films its own title.
///
/// `Rebuild of Evangelion/` holding `Evangelion - 1.11 You Are (Not)
/// Alone.mkv` and three more is four films, each its own entry at every
/// provider. Titled by the folder they are one series of four unnumbered
/// files, matched as whichever single film the folder's name reads most like —
/// and no single film is right.
///
/// Only when every file in the folder says so, because the same shape with one
/// file out of place is a series:
///
/// - none is numbered, by its name or by a season folder;
/// - none is under a grouping folder, because `Neon Genesis Evangelion/Movies/`
/// is the series' films, and they belong on its page;
/// - each names itself, differently from the others, and not by the folder's
/// name — `Mushishi - The Green Seat.mkv` under `Mushishi/` is an episode
/// of that show whose number is missing, not a film.
fn split_films(rows: &mut [CatalogNode], origins: &[Origin]) {
use crate::library::title_key;
use std::collections::HashSet;
let mut by_folder: HashMap<&str, Vec<usize>> = HashMap::new();
for (index, origin) in origins.iter().enumerate() {
if let Some(folder) = &origin.folder {
by_folder.entry(folder.as_str()).or_default().push(index);
}
}
for members in by_folder.into_values() {
if members.len() < 2 {
continue;
}
let mut keys = HashSet::new();
let films = members.iter().all(|&index| {
let (row, origin) = (&rows[index], &origins[index]);
let own = &origin.own.title;
!origin.through_container
&& !origin.own.is_episode()
&& origin.own.season.is_none()
&& row.parsed.season.is_none()
&& !own.is_empty()
&& strip_prefix_title(own, &row.parsed.title) == *own
&& !own.eq_ignore_ascii_case(&row.parsed.title)
&& keys.insert(title_key(own))
});
if !films {
continue;
}
for index in members {
let row = &mut rows[index];
let own = &origins[index].own;
row.parsed.title = own.title.clone();
row.parsed.episode_title = own.episode_title.clone();
}
}
} }
/// Pair each subtitle file in a crawl with the video it is for. /// Pair each subtitle file in a crawl with the video it is for.
@@ -1784,6 +1860,10 @@ fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Ancestry { struct Ancestry {
title: Option<String>, title: Option<String>,
/// The link id of the folder [`Ancestry::title`] came from.
title_folder: Option<String>,
/// Whether a grouping folder sat between the file and that one.
through_container: bool,
season: Option<u32>, season: Option<u32>,
year: Option<u32>, year: Option<u32>,
} }
@@ -1822,10 +1902,13 @@ fn ancestry(
match crate::naming::classify_folder(&ancestor.name) { match crate::naming::classify_folder(&ancestor.name) {
FolderRole::Season(season) => found.season = found.season.or(Some(season)), FolderRole::Season(season) => found.season = found.season.or(Some(season)),
FolderRole::Container => {} FolderRole::Container => {
found.through_container |= found.title.is_none();
}
FolderRole::Title(parsed) => { FolderRole::Title(parsed) => {
if found.title.is_none() && !parsed.title.is_empty() { if found.title.is_none() && !parsed.title.is_empty() {
found.title = Some(parsed.title.clone()); found.title = Some(parsed.title.clone());
found.title_folder = Some(link_id.clone());
found.year = found.year.or(parsed.year); found.year = found.year.or(parsed.year);
// `Game of Thrones - Season 1` names both; the season it // `Game of Thrones - Season 1` names both; the season it
// states belongs to the files under it. // states belongs to the files under it.
@@ -3081,6 +3164,126 @@ mod tests {
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress"); assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
} }
/// **What the Rebuild of Evangelion films were matched as.** A folder of
/// four films titled by the folder is one series, matched as a single film
/// that none of the four is. Each names itself, so each is its own title.
#[test]
fn a_folder_of_unnumbered_films_is_one_title_per_film() {
let nodes = vec![
root(),
folder("rebuild", Some("root"), "Rebuild of Evangelion"),
video(
"one",
"rebuild",
"Evangelion - 1.11 You Are (Not) Alone.mkv",
),
video(
"two",
"rebuild",
"Evangelion - 2.22 You Can (Not) Advance..mkv",
),
video(
"three",
"rebuild",
"Evangelion - 3.33 You Can (Not) Redo..mkv",
),
video(
"four",
"rebuild",
"Evangelion - 3.0+1.01 Thrice Upon a Time.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "one").parsed.title,
"Evangelion - 1.11 You Are Alone"
);
assert_eq!(
row(&rows, "four").parsed.title,
"Evangelion - 3.0+1.01 Thrice Upon a Time"
);
for film in &rows {
assert_eq!(film.parsed.episode, None);
assert_eq!(film.parsed.episode_title, None, "a film is not an episode");
}
let library = crate::library::Library::build(rows, &HashMap::new());
assert_eq!(library.titles.len(), 4);
assert!(
library
.titles
.iter()
.all(|title| title.kind == TitleKind::Film)
);
}
/// One numbered file says the folder is a show, and the unnumbered ones are
/// its specials.
#[test]
fn a_folder_with_a_numbered_episode_in_it_is_not_split() {
let nodes = vec![
root(),
folder("show", Some("root"), "Promised Neverland"),
video("e1", "show", "[Group] Yakusoku no Neverland - 01.mkv"),
video("film", "show", "Some Recap Film.mkv"),
video("other", "show", "Another Special.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Promised Neverland");
}
}
/// Films under `Movies/` are the series' films, and stay on its page.
#[test]
fn films_under_a_grouping_folder_stay_with_the_series() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
folder("movies", Some("eva"), "Movies"),
video("a", "movies", "Death and Rebirth.mkv"),
video("b", "movies", "The End of Evangelion.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Neon Genesis Evangelion");
}
}
/// Files that repeat the folder's name are that show's episodes with their
/// numbers missing, not films of their own.
#[test]
fn unnumbered_files_named_after_their_folder_stay_one_title() {
let nodes = vec![
root(),
folder("mushi", Some("root"), "Mushishi"),
video("a", "mushi", "Mushishi - The Green Seat.mkv"),
video("b", "mushi", "Mushishi - The Light of the Eyelid.mkv"),
];
let rows = build_rows("share", &nodes);
assert_eq!(row(&rows, "a").parsed.title, "Mushishi");
assert_eq!(
row(&rows, "a").parsed.episode_title.as_deref(),
Some("The Green Seat")
);
}
/// Two files that name themselves the same thing are not two films.
#[test]
fn files_whose_own_names_collide_are_not_split() {
let nodes = vec![
root(),
folder("film", Some("root"), "Akira"),
video("a", "film", "[GroupA] Neo Tokyo.mkv"),
video("b", "film", "Neo.Tokyo.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Akira");
}
}
/// The share root names the *share*, not a series — using it would label /// The share root names the *share*, not a series — using it would label
/// every top-level file identically. /// every top-level file identically.
#[test] #[test]
+72 -2
View File
@@ -539,6 +539,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
if is_token_char(tail) && tail != Some(b'v') { if is_token_char(tail) && tail != Some(b'v') {
continue; continue;
} }
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch { found = Some(NumberingMatch {
numbering: Numbering { numbering: Numbering {
season: None, season: None,
@@ -551,6 +554,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
found found
} }
/// Whether the number that ended at `at` goes on as a decimal: `.11` in
/// `Evangelion - 1.11 You Are (Not) Alone`, `.0` in `3.0+1.01`.
///
/// That is a version of a film, not episode 1 of anything, and reading it as
/// one turns a folder of four films into a series of episodes 1, 2, 3 and 3.
/// One or two digits only: `- 05.1080p` is episode 5 and a resolution.
fn is_decimal_tail(text: &str, at: usize) -> bool {
let bytes = text.as_bytes();
if bytes.get(at) != Some(&b'.') {
return false;
}
let digits = bytes[at + 1..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
(1..=2).contains(&digits) && !is_token_char(bytes.get(at + 1 + digits).copied())
}
/// Step over an `E` / `EP` episode marker and any separator after it. /// Step over an `E` / `EP` episode marker and any separator after it.
/// ///
/// Returns `at` unchanged when there is none, so a bare number and a marked one /// Returns `at` unchanged when there is none, so a bare number and a marked one
@@ -630,9 +651,18 @@ fn is_token_start(text: &str, at: usize) -> bool {
/// Normalize the title span: separators to spaces, then trim the debris that /// Normalize the title span: separators to spaces, then trim the debris that
/// sits between the title and whatever followed it. /// sits between the title and whatever followed it.
fn clean_title(raw: &str, year: Option<u32>) -> String { fn clean_title(raw: &str, year: Option<u32>) -> String {
// A dot between two digits is a decimal point, not a separator: `1.11`
// is the film's name, `1 11` is two numbers.
let bytes = raw.as_bytes();
let mut text: String = raw let mut text: String = raw
.chars() .char_indices()
.map(|c| match c { .map(|(at, c)| match c {
'.' if at > 0
&& bytes[at - 1].is_ascii_digit()
&& bytes.get(at + 1).is_some_and(u8::is_ascii_digit) =>
{
'.'
}
'.' | '_' => ' ', '.' | '_' => ' ',
other => other, other => other,
}) })
@@ -856,6 +886,46 @@ mod tests {
); );
} }
/// **What the four Rebuild of Evangelion films parsed as.** ` - 1.11` read
/// as episode 1, so the folder became a series of episodes 1, 2, 3 and 3.
/// A decimal is a version of the film, and the name keeps it.
#[test]
fn a_decimal_after_the_dash_is_part_of_a_films_name_not_an_episode() {
assert_eq!(
parsed("Evangelion - 1.11 You Are (Not) Alone.mkv"),
("Evangelion - 1.11 You Are Alone".into(), None, None, None)
);
assert_eq!(
parsed("Evangelion - 3.0+1.01 Thrice Upon a Time.mkv"),
(
"Evangelion - 3.0+1.01 Thrice Upon a Time".into(),
None,
None,
None
)
);
}
/// A recap numbered between two episodes is not a second copy of the one
/// before it.
#[test]
fn a_half_episode_is_not_the_episode_before_it() {
assert_eq!(
parsed("[Group] Show Name - 12.5 [1080p].mkv"),
("Show Name - 12.5".into(), None, None, None)
);
}
/// The decimal rule stops at two digits, so a resolution glued on with a
/// dot leaves the episode number alone.
#[test]
fn a_resolution_after_a_dot_does_not_hide_the_episode() {
assert_eq!(
parsed("Show Name - 05.1080p.mkv"),
("Show Name".into(), None, Some(5), None)
);
}
/// A version suffix still names the same episode. /// A version suffix still names the same episode.
#[test] #[test]
fn a_versioned_fansub_episode_keeps_its_number() { fn a_versioned_fansub_episode_keeps_its_number() {
+257
View File
@@ -60,6 +60,211 @@ pub struct PlaybackPrefs {
/// everything at 1.25, and a speed that reset every episode would be a /// everything at 1.25, and a speed that reset every episode would be a
/// setting nobody could use. /// setting nobody could use.
pub speed: f64, 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 subtitles are drawn.
///
/// 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 none of this, and moves
/// only to stay clear of the player's controls. All of it reaches a styled
/// track only with [`Self::restyle_styled`].
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SubtitleStyle {
/// 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 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 drawn in this style too, size and
/// position included. Off by default: it turns signs and karaoke into
/// dialogue-looking text.
pub restyle_styled: bool,
}
impl SubtitleStyle {
/// The family a style with no font of its own is set in: mpv's default,
/// which fontconfig resolves to the system's sans-serif face.
pub const DEFAULT_FONT: &str = "sans-serif";
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,
}
}
}
/// 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 {
/// An outline round each letter: mpv's own, and the default.
#[default]
Outline,
/// A 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 => "Box",
}
}
}
/// 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 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:?}"))
}
} }
impl Default for PlaybackPrefs { impl Default for PlaybackPrefs {
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
autoplay_next: true, autoplay_next: true,
auto_skip: false, auto_skip: false,
speed: 1.0, speed: 1.0,
subtitle_style: SubtitleStyle::default(),
} }
} }
} }
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED); self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty()); 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_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_style = self.subtitle_style.sanitized();
self self
} }
} }
@@ -149,6 +356,16 @@ mod tests {
autoplay_next: false, autoplay_next: false,
auto_skip: true, auto_skip: true,
speed: 1.5, speed: 1.5,
subtitle_style: SubtitleStyle {
scale: 1.5,
font: Some("Noto Sans".into()),
italic: true,
colour: Colour::rgba(0xFF, 0xE6, 0x00, 0xFF),
backdrop: SubtitleBackdrop::Box,
box_colour: Colour::rgba(0x10, 0x10, 0x10, 0x80),
restyle_styled: true,
..SubtitleStyle::default()
},
}; };
save(&dirs, &prefs).unwrap(); save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs); assert_eq!(load(&dirs).unwrap(), prefs);
@@ -156,6 +373,46 @@ mod tests {
std::fs::remove_dir_all(&temp).ok(); 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_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] #[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() { fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs { let prefs = PlaybackPrefs {
+51
View File
@@ -379,6 +379,12 @@ impl AniList {
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only /// tokenisation: by dropping the `s` it was holding on to. It is asked for only
/// after the name as written found nothing, which is what keeps it from /// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working. /// broadening a search that was already working.
///
/// A film's version number is the same kind of miss. `Evangelion - 1.11 You
/// Are (Not) Alone` is the file of a film AniList calls `Evangelion: 1.0 You
/// Are (Not) Alone`, and `1.11` is not a word of that name — so the last
/// fallback asks without it, and leaves telling the films apart to the scoring,
/// which still sees the whole name.
fn search_terms(name: &str) -> Vec<String> { fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()]; let mut terms = vec![name.to_string()];
@@ -386,9 +392,31 @@ fn search_terms(name: &str) -> Vec<String> {
if depossessed != name { if depossessed != name {
terms.push(depossessed); terms.push(depossessed);
} }
let unversioned = without_version_numbers(name);
if !unversioned.is_empty() && !terms.contains(&unversioned) {
terms.push(unversioned);
}
terms terms
} }
/// `Evangelion - 1.11 You Are Alone` → `Evangelion - You Are Alone`.
///
/// A version is digits with a dot in them (`1.11`, `3.0+1.01`); a bare number
/// is left alone, because in `Kaijuu 8-gou` or `86` it is the name.
fn without_version_numbers(name: &str) -> String {
name.split(' ')
.filter(|word| {
let versioned = word.contains('.')
&& word.chars().any(|c| c.is_ascii_digit())
&& word
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '+'));
!versioned
})
.collect::<Vec<_>>()
.join(" ")
}
/// `Heavens Feel` → `Heaven Feel`. /// `Heavens Feel` → `Heaven Feel`.
/// ///
/// Only words long enough to still mean something without it, and only where /// Only words long enough to still mean something without it, and only where
@@ -836,6 +864,29 @@ mod tests {
); );
} }
/// **What three of the four Rebuild films matched as: nothing.** AniList
/// requires every word, and a film's version number is not one of its
/// title's.
#[test]
fn a_films_version_number_is_searched_for_again_without_it() {
assert_eq!(
search_terms("Evangelion - 1.11 You Are Alone"),
vec![
"Evangelion - 1.11 You Are Alone".to_string(),
"Evangelion - You Are Alone".to_string(),
]
);
assert_eq!(
search_terms("Evangelion - 3.0+1.01 Thrice Upon a Time")[1],
"Evangelion - Thrice Upon a Time"
);
assert_eq!(
search_terms("Kaijuu 8-gou"),
vec!["Kaijuu 8-gou"],
"a number that is part of the name stays"
);
}
/// Byte slicing, on names that are not ASCII. /// Byte slicing, on names that are not ASCII.
#[test] #[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() { fn a_multi_byte_name_is_not_cut_inside_a_character() {
+1
View File
@@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start,
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use player::{ pub use player::{
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput, EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
subtitle_options,
}; };
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer; pub use render::VideoRenderer;
+166
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use libmpv2::events::{Event, PropertyData}; use libmpv2::events::{Event, PropertyData};
use libmpv2::{Format, Mpv}; use libmpv2::{Format, Mpv};
use pstr_core::prefs::{SubtitleBackdrop, SubtitleStyle};
use pstr_stream::VideoStream; use pstr_stream::VideoStream;
use tokio::runtime::Runtime; 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 /// 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. /// and expects to keep, and no language preference can express it.
pub subtitles: bool, pub subtitles: bool,
/// How subtitles look. See [`subtitle_options`].
pub subtitle_style: SubtitleStyle,
/// Raw mpv options, applied last, so anything here wins. /// Raw mpv options, applied last, so anything here wins.
pub options: Vec<(String, String)>, pub options: Vec<(String, String)>,
} }
@@ -206,6 +209,7 @@ impl Default for PlayerConfig {
audio_language: None, audio_language: None,
subtitle_language: None, subtitle_language: None,
subtitles: true, subtitles: true,
subtitle_style: SubtitleStyle::default(),
options: Vec::new(), options: Vec::new(),
} }
} }
@@ -293,6 +297,9 @@ impl Player {
if !config.subtitles { if !config.subtitles {
init.set_property("sid", "no")?; init.set_property("sid", "no")?;
} }
apply_subtitle_options(&config.subtitle_style, |name, value| {
init.set_property(name, value).map_err(Error::from)
});
for (name, value) in &config.options { for (name, value) in &config.options {
init.set_property(name.as_str(), value.as_str())?; init.set_property(name.as_str(), value.as_str())?;
} }
@@ -537,6 +544,18 @@ impl Player {
Ok(()) 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 /// 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×. /// default and what keeps a voice a voice at 1.5×.
pub fn set_speed(&self, speed: f64) -> Result<()> { pub fn set_speed(&self, speed: f64) -> Result<()> {
@@ -593,6 +612,78 @@ impl Player {
} }
} }
/// 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.
///
/// `sub-ass-override` is `yes` unless the viewer asked otherwise, so a styled
/// track keeps its own size, fonts and colours. Not mpv's default, `scale`,
/// which hands it `sub-scale` as well. `force` repaints it whole. See
/// [`SubtitleStyle`] for why that is opt-in. `sub-pos` reaches a styled track
/// under `yes` too, so where a styled track sits is the caller's to decide:
/// see `pstr-app`'s `Playback::lift_subtitles`.
/// The border options come last, led by [`BORDER_STYLE`].
pub fn subtitle_options(style: &SubtitleStyle) -> Vec<(&'static str, String)> {
let flag = |on: bool| if on { "yes" } else { "no" }.to_owned();
let mut options = vec![
(
"sub-ass-override",
if style.restyle_styled { "force" } else { "yes" }.to_owned(),
),
("sub-scale", format!("{:.2}", style.scale)),
(
"sub-font",
style
.font
.clone()
.unwrap_or_else(|| SubtitleStyle::DEFAULT_FONT.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.
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. /// mpv's borrowed event to ours.
fn translate(event: Event<'_>) -> PlayerEvent { fn translate(event: Event<'_>) -> PlayerEvent {
match event { match event {
@@ -620,6 +711,81 @@ fn translate(event: Event<'_>) -> PlayerEvent {
mod tests { mod tests {
use super::*; use super::*;
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` — all but
// `sub-ass-override`, which mpv has at `scale`.
for (name, value) in [
("sub-ass-override", "yes"),
("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()
};
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_or_resized_only_when_asked() {
// `scale` would hand a styled track the size; `yes` hands it nothing
// but the `sub-ass-*` options, none of which this sets.
let kept = subtitle_options(&SubtitleStyle {
scale: 1.5,
..SubtitleStyle::default()
});
assert_eq!(option(&kept, "sub-ass-override"), Some("yes"));
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] #[test]
fn every_end_reason_maps_to_something_the_ui_can_act_on() { fn every_end_reason_maps_to_something_the_ui_can_act_on() {
use libmpv2::mpv_end_file_reason as raw; use libmpv2::mpv_end_file_reason as raw;
+12
View File
@@ -21,6 +21,18 @@ the blocks around the point you are watching. A seek fetches the block it lands
No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work, No. The video plays as it is stored, through libmpv. HEVC 10-bit, HDR and ASS subtitles all work,
with hardware decoding where the device has it. with hardware decoding where the device has it.
## Subtitles are hard to read. Can I change them?
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, size and place their makers gave them, 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? ## Does it provide any videos?
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue, No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
+8 -2
View File
@@ -160,6 +160,14 @@ same facts, tags and franchise row as the desktop's; *Title names* is in
Settings → Appearance. Android's grid is always covers, so it has no Settings → Appearance. Android's grid is always covers, so it has no
*Library tiles* choice. *Library tiles* choice.
**Folders of films.** `pstr_core::catalog::build_rows` gives each file in a
folder of films its own title, so the four Rebuild films in one folder are
four entries, matched one by one, and the franchise folds them back into one
tile. Only when no file is numbered, none sits under `Movies`/`OVA`, and each
names itself differently from the others and from the folder; anything less
is a series with its specials. A film's version number (`1.11`) is not part of its name on
AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details. Not done: TMDB fills no details.
## Phase 3 — reach ## Phase 3 — reach
@@ -168,7 +176,5 @@ Not done: TMDB fills no details.
machine. machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for - **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share. whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from - **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan. Jikan.
+26 -2
View File
@@ -46,12 +46,14 @@ The folder decides which show a file belongs to. The file name gives the season
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 | | `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 | | `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
| `Akira (1988).mkv` | A film, from 1988 | | `Akira (1988).mkv` | A film, from 1988 |
| `Rebuild of Evangelion/Evangelion - 1.11 You Are (Not) Alone.mkv` and three more | Four films, one title each |
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`. - Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name; - Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
the folder above them is. the folder above them is.
- An episode title is read only after a dash, as in `S01E01 - Name`. - An episode title is read only after a dash, as in `S01E01 - Name`.
- A file with no episode number is a film. - A file with no episode number is a film. A folder of such files, each with a different name, is
one title per film.
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
containers. containers.
@@ -83,7 +85,9 @@ 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 - **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**. **Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your - **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 change styled subtitles only when they are restyled, 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. - **Speed** goes from 0.5× to 3×, and stays for the next episode.
### Keyboard ### Keyboard
@@ -110,6 +114,7 @@ Press **?** anywhere to see this list in the app.
| N / P | Next and previous episode | | N / P | Next and previous episode |
| [ / ] | Slower and faster | | [ / ] | Slower and faster |
| S | Skip the opening or the credits | | S | Skip the opening or the credits |
| Z / X | Subtitles 0.1 s earlier and later |
| E | The list of episodes | | E | The list of episodes |
| F or double-click | Fullscreen | | F or double-click | Fullscreen |
| Ctrl+S | Save the frame to `Pictures/proton-stream` | | Ctrl+S | Save the frame to `Pictures/proton-stream` |
@@ -185,6 +190,25 @@ The **Proton account** section on this page is where you sign in. See
- **Play the next episode** (on by default) and **Skip openings and credits** (off). - **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. - **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. On Linux the sample
line shows the font too; on Windows and macOS it is drawn in the app's own font, and only 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, size and place their makers gave them unless this is on; they
only move up while the player's controls are showing. Turned on, signs and songs can come out
wrong.
- **Reset** goes back to the default look.
**Storage** **Storage**
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed, - **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=1.6.2 pkgver=1.7.0
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')