mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79f0d74e1a | ||
|
|
648c35e92a | ||
|
|
ffd6e959bb | ||
|
|
69d961d7b8 | ||
|
|
238ecd8220 | ||
|
|
65a669e231 |
No files matched your search
Generated
+7
-7
@@ -5279,7 +5279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-android"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
@@ -5299,7 +5299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-app"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5333,7 +5333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-cli"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5352,7 +5352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-core"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
@@ -5372,7 +5372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-meta"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"pstr-core",
|
||||
"reqwest",
|
||||
@@ -5385,7 +5385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-player"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libloading",
|
||||
@@ -5400,7 +5400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pstr-stream"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/narrrl/proton-stream"
|
||||
|
||||
@@ -1909,7 +1909,11 @@ impl AndroidEngine {
|
||||
pub fn set_playback_prefs(&self, prefs: PlaybackPrefsRecord) -> Result<(), BridgeError> {
|
||||
// Sanitized rather than trusted: the volume arrives from a slider, and
|
||||
// a language from a text field that can hold spaces and nothing else.
|
||||
let prefs = playback_prefs(prefs).sanitized();
|
||||
// Onto what is stored, not the defaults: the record has no subtitle
|
||||
// style yet, and saving one without it would reset the desktop's and,
|
||||
// through sync, every other installation's.
|
||||
let stored = pstr_core::prefs::load(&self.dirs).unwrap_or_default();
|
||||
let prefs = playback_prefs(prefs, stored).sanitized();
|
||||
pstr_core::prefs::save(&self.dirs, &prefs).map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
@@ -3580,7 +3584,7 @@ fn playback_prefs_record(prefs: PlaybackPrefs) -> PlaybackPrefsRecord {
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
||||
fn playback_prefs(record: PlaybackPrefsRecord, stored: PlaybackPrefs) -> PlaybackPrefs {
|
||||
PlaybackPrefs {
|
||||
volume: record.volume,
|
||||
muted: record.muted,
|
||||
@@ -3590,6 +3594,7 @@ fn playback_prefs(record: PlaybackPrefsRecord) -> PlaybackPrefs {
|
||||
autoplay_next: record.autoplay_next,
|
||||
auto_skip: record.auto_skip,
|
||||
speed: record.speed,
|
||||
..stored
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,14 @@ pub enum Action {
|
||||
subtitles: bool,
|
||||
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.
|
||||
SetSpeed(f64),
|
||||
/// 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
|
||||
/// into an opening on purpose is not undone a frame later.
|
||||
auto_skipped: (u64, Vec<i64>),
|
||||
/// The subtitle style changed and has not been written yet.
|
||||
subtitle_style_unsaved: bool,
|
||||
/// Whether the episode list is out on the player page.
|
||||
pub episode_drawer: bool,
|
||||
/// Set while the window is fullscreen, so `F` can toggle rather than only
|
||||
@@ -760,6 +770,7 @@ impl App {
|
||||
up_next: UpNext::default(),
|
||||
warmed: 0,
|
||||
auto_skipped: (0, Vec::new()),
|
||||
subtitle_style_unsaved: false,
|
||||
fullscreen: false,
|
||||
episode_drawer: false,
|
||||
opening: None,
|
||||
@@ -1437,6 +1448,21 @@ impl App {
|
||||
self.apply(ctx, Action::Play(target));
|
||||
}
|
||||
|
||||
/// Write the subtitle style, once whatever is changing it has let go.
|
||||
///
|
||||
/// A slider or a colour wheel changes the style every frame it is dragged,
|
||||
/// and each change is shown on the playing file straight away. Writing
|
||||
/// each one would be a file write per frame, so it waits for the mouse —
|
||||
/// which also covers a menu closed mid-drag, where the control that would
|
||||
/// have said "released" is no longer drawn.
|
||||
fn save_subtitle_style(&mut self, ctx: &egui::Context) {
|
||||
if self.subtitle_style_unsaved && !ctx.input(|input| input.pointer.any_down()) {
|
||||
self.subtitle_style_unsaved = false;
|
||||
self.engine
|
||||
.set_playback_prefs(self.engine.playback_prefs(), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_player(&self, command: crate::playback::Command) {
|
||||
if let Some(playback) = &self.playback {
|
||||
playback.send(command);
|
||||
@@ -1938,6 +1964,32 @@ impl App {
|
||||
prefs.subtitle_language = subtitle;
|
||||
self.engine.set_playback_prefs(prefs, true);
|
||||
}
|
||||
Action::SetSubtitleStyle(style) => {
|
||||
let mut prefs = self.engine.playback_prefs();
|
||||
prefs.subtitle_style = style;
|
||||
self.engine.set_playback_prefs(prefs, false);
|
||||
self.subtitle_style_unsaved = true;
|
||||
let style = self.engine.playback_prefs().subtitle_style;
|
||||
if let Some(playback) = &mut self.playback {
|
||||
playback.restyle_subtitles(&style);
|
||||
}
|
||||
}
|
||||
Action::NudgeSubtitleDelay(delta) => {
|
||||
if let Some(playback) = &mut self.playback {
|
||||
playback.set_subtitle_delay(playback.subtitle_delay + delta);
|
||||
let text = format!(
|
||||
"Subtitle delay {}",
|
||||
ui::format_delay(playback.subtitle_delay)
|
||||
);
|
||||
self.flash(ctx, text);
|
||||
}
|
||||
}
|
||||
Action::ResetSubtitleDelay => {
|
||||
if let Some(playback) = &mut self.playback {
|
||||
playback.set_subtitle_delay(0.0);
|
||||
self.flash(ctx, "Subtitle delay 0 s");
|
||||
}
|
||||
}
|
||||
Action::SetAppearance(appearance) => {
|
||||
self.engine.set_appearance(appearance);
|
||||
theme::apply(ctx, appearance);
|
||||
@@ -2129,6 +2181,7 @@ impl eframe::App for App {
|
||||
}
|
||||
self.warm_next();
|
||||
self.auto_skip(ctx);
|
||||
self.save_subtitle_style(ctx);
|
||||
self.window.observe(ctx);
|
||||
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
@@ -2909,6 +2962,8 @@ fn shortcuts(
|
||||
Key::OpenBracket,
|
||||
Key::CloseBracket,
|
||||
Key::E,
|
||||
Key::Z,
|
||||
Key::X,
|
||||
Key::Escape,
|
||||
]
|
||||
.into_iter()
|
||||
@@ -2952,6 +3007,9 @@ fn shortcuts(
|
||||
// Escape leaves fullscreen first and the page second, which is what
|
||||
// it does everywhere else and what stops one press from both
|
||||
// un-maximising the window and hiding the film.
|
||||
// mpv's own keys for it, and the step mpv uses.
|
||||
Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)),
|
||||
Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)),
|
||||
Key::E => actions.push(Action::ToggleEpisodes),
|
||||
// The episode list is the innermost thing open, so it goes first.
|
||||
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
|
||||
|
||||
@@ -147,6 +147,10 @@ pub enum Command {
|
||||
/// Move subtitles, as a percentage of the window's height. See
|
||||
/// [`Playback::lift_subtitles`].
|
||||
SetSubtitlePosition(f64),
|
||||
/// Restyle subtitles, the line already showing included.
|
||||
SetSubtitleStyle(pstr_core::prefs::SubtitleStyle),
|
||||
/// Shift subtitles this many seconds later; negative is earlier.
|
||||
SetSubtitleDelay(f64),
|
||||
/// 1.0 is normal speed.
|
||||
SetSpeed(f64),
|
||||
/// Save the current frame to this file.
|
||||
@@ -184,6 +188,14 @@ pub struct Playback {
|
||||
/// Where subtitles were last put, so they are only moved when that changes
|
||||
/// rather than once a frame.
|
||||
subtitle_position: f64,
|
||||
/// How the viewer has subtitles drawn, as last handed to this player.
|
||||
pub subtitle_style: pstr_core::prefs::SubtitleStyle,
|
||||
/// Where the controls would lift subtitles to. The higher of this and the
|
||||
/// style's own position wins.
|
||||
lifted_position: f64,
|
||||
/// How far subtitles are shifted, in seconds. Per file rather than a
|
||||
/// preference: an offset belongs to one release, not to the viewer.
|
||||
pub subtitle_delay: f64,
|
||||
/// What the file contains. Empty until mpv has demuxed it.
|
||||
pub tracks: Vec<Track>,
|
||||
/// The file's chapters, in order. Empty for a file muxed without them.
|
||||
@@ -259,7 +271,10 @@ impl Playback {
|
||||
paused: false,
|
||||
volume: prefs.volume,
|
||||
muted: prefs.muted,
|
||||
subtitle_position: 100.0,
|
||||
subtitle_position: prefs.subtitle_style.position,
|
||||
subtitle_style: prefs.subtitle_style.clone(),
|
||||
lifted_position: 100.0,
|
||||
subtitle_delay: 0.0,
|
||||
speed: prefs.speed,
|
||||
tracks: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
@@ -282,17 +297,57 @@ impl Playback {
|
||||
/// the mouse is moving, which is exactly when someone is looking at the
|
||||
/// screen.
|
||||
pub fn lift_subtitles(&mut self, covered: f32, height: f32) {
|
||||
let position = if covered > 0.0 && height > 0.0 {
|
||||
self.lifted_position = if covered > 0.0 && height > 0.0 {
|
||||
(100.0 * (1.0 - f64::from(covered / height))).round()
|
||||
} else {
|
||||
100.0
|
||||
};
|
||||
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 {
|
||||
self.subtitle_position = position;
|
||||
self.send(Command::SetSubtitlePosition(position));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where subtitles go: where the style puts them, unless the controls
|
||||
/// would cover that, in which case just above the controls.
|
||||
///
|
||||
/// 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.
|
||||
pub fn is_embedded(&self) -> bool {
|
||||
self.video.is_some()
|
||||
@@ -372,7 +427,11 @@ impl Playback {
|
||||
PlayerEvent::FileLoaded => self.loaded = true,
|
||||
PlayerEvent::Volume(volume) => self.volume = *volume,
|
||||
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) => {
|
||||
self.chapters = chapters.clone();
|
||||
self.reclassify();
|
||||
@@ -479,6 +538,11 @@ fn build_player(
|
||||
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.
|
||||
fn from_prefs(
|
||||
prefs: &PlaybackPrefs,
|
||||
@@ -494,6 +558,7 @@ fn from_prefs(
|
||||
.and_then(|p| p.subtitle_language.clone())
|
||||
.or_else(|| prefs.subtitle_language.clone()),
|
||||
subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles),
|
||||
subtitle_style: prefs.subtitle_style.clone(),
|
||||
// Carried from file to file, like the volume: somebody who watches at
|
||||
// 1.25 watches the next episode at 1.25 too.
|
||||
options: vec![("speed".into(), format!("{:.2}", prefs.speed))],
|
||||
@@ -658,6 +723,11 @@ fn apply_command(
|
||||
Command::SetVolume(volume) => player.set_volume(volume),
|
||||
Command::SetMuted(muted) => player.set_muted(muted),
|
||||
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||
Command::SetSubtitleStyle(style) => {
|
||||
player.apply_subtitle_style(&style);
|
||||
Ok(())
|
||||
}
|
||||
Command::SetSubtitleDelay(seconds) => player.set_subtitle_delay(seconds),
|
||||
Command::SetSpeed(speed) => player.set_speed(speed),
|
||||
Command::Screenshot(path) => player.screenshot(&path),
|
||||
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||
@@ -820,3 +890,17 @@ fn save(
|
||||
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
@@ -186,9 +186,48 @@ pub fn semibold() -> egui::FontFamily {
|
||||
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
|
||||
/// symbols Inter does not carry. `cjk` goes last, when there is one.
|
||||
fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
/// symbols Inter does not carry. The CJK fallback goes last, when there is
|
||||
/// one.
|
||||
fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
|
||||
use egui::FontFamily::{Monospace, Proportional};
|
||||
|
||||
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
|
||||
// bundled Inter no longer maps, so neither font shadows the other.
|
||||
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();
|
||||
fonts.font_data.insert(name.clone(), Arc::new(cjk));
|
||||
fonts.font_data.insert(name.clone(), cjk.clone());
|
||||
for family in [Proportional, Monospace] {
|
||||
fonts.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
let fallbacks = fonts.families[&Proportional]
|
||||
let fallbacks: Vec<String> = fonts.families[&Proportional]
|
||||
.iter()
|
||||
.filter(|name| *name != "inter")
|
||||
.cloned();
|
||||
.cloned()
|
||||
.collect();
|
||||
let semibold_chain = std::iter::once("inter-semibold".to_owned())
|
||||
.chain(fallbacks)
|
||||
.collect();
|
||||
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
|
||||
}
|
||||
|
||||
@@ -234,7 +281,7 @@ fn font_definitions(cjk: Option<egui::FontData>) -> egui::FontDefinitions {
|
||||
/// anyway. egui deliberately does not bundle the multi-megabyte CJK families,
|
||||
/// and native desktops already provide one.
|
||||
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 spawned = std::thread::Builder::new()
|
||||
.name("fonts".into())
|
||||
@@ -251,20 +298,80 @@ fn install_cjk_fallback(ctx: &egui::Context) {
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
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());
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
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()?;
|
||||
let mut data = egui::FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
@@ -299,6 +406,68 @@ fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
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")]
|
||||
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
||||
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
|
||||
}
|
||||
|
||||
/// The font families installed here, by name, for the subtitle font picker.
|
||||
///
|
||||
/// `None` until they have been read. The first call starts that on a thread
|
||||
/// of its own and asks for a repaint when it is done: `fc-list` over a large
|
||||
/// font collection takes long enough to be seen as a stalled frame.
|
||||
pub fn font_families(ctx: &egui::Context) -> Option<&'static [String]> {
|
||||
static FAMILIES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
if let Some(families) = FAMILIES.get() {
|
||||
return Some(families);
|
||||
}
|
||||
if !STARTED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
let ctx = ctx.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("font-families".into())
|
||||
.spawn(move || {
|
||||
let _ = FAMILIES.set(system_font_families());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
tracing::debug!("font families thread: {error}");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_font_families() -> Vec<String> {
|
||||
let Ok(output) = std::process::Command::new("fc-list")
|
||||
.args([":", "family"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
font_families_from_fc_list(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// Elsewhere the picker takes a typed name only. mpv's font lookup there is
|
||||
/// the system's own, so any installed family's name works.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn system_font_families() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// One family per line of `fc-list : family`, sorted and without repeats.
|
||||
///
|
||||
/// A line lists a family's names in every language it has, comma-separated,
|
||||
/// and one family appears once per style. The first name is the one libass
|
||||
/// matches on.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn font_families_from_fc_list(listing: &str) -> Vec<String> {
|
||||
let mut families: Vec<String> = listing
|
||||
.lines()
|
||||
.filter_map(|line| line.split(',').next())
|
||||
.map(|family| family.trim().to_owned())
|
||||
.filter(|family| !family.is_empty() && !family.starts_with('.'))
|
||||
.collect();
|
||||
families.sort_by_key(|family| family.to_lowercase());
|
||||
families.dedup();
|
||||
families
|
||||
}
|
||||
|
||||
/// Every colour the app draws with, as egui wants them.
|
||||
///
|
||||
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
||||
@@ -881,6 +1112,15 @@ const RAMP_STOPS: usize = 64;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_font_family_is_listed_once_by_its_first_name() {
|
||||
let listing = "Noto Sans,Noto Sans Regular\nDejaVu Sans\nnoto sans mono\nNoto Sans,Noto Sans Bold\n\n.LastResort\n";
|
||||
assert_eq!(
|
||||
font_families_from_fc_list(listing),
|
||||
["DejaVu Sans", "Noto Sans", "noto sans mono"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_fonts_named_instance_is_not_taken_for_a_face() {
|
||||
assert_eq!(fontconfig_face(262_144), 0);
|
||||
@@ -889,10 +1129,40 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fallback_font_egui_cannot_read_is_left_out() {
|
||||
assert!(cjk_font_data(INTER.to_vec(), 0).is_some());
|
||||
assert!(cjk_font_data(INTER.to_vec(), 4).is_none());
|
||||
assert!(cjk_font_data(b"not a font".to_vec(), 0).is_none());
|
||||
fn a_system_font_egui_cannot_read_is_left_out() {
|
||||
assert!(system_font_data(INTER.to_vec(), 0).is_some());
|
||||
assert!(system_font_data(INTER.to_vec(), 4).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]
|
||||
|
||||
@@ -206,6 +206,18 @@ pub fn format_speed(speed: f64) -> String {
|
||||
format!("{}×", text.trim_end_matches('0').trim_end_matches('.'))
|
||||
}
|
||||
|
||||
/// `+0.3 s`, `−1.2 s` or `0 s`: a subtitle delay, signed so the direction
|
||||
/// reads without knowing which way mpv counts.
|
||||
pub fn format_delay(seconds: f64) -> String {
|
||||
if seconds.abs() < 0.05 {
|
||||
"0 s".to_owned()
|
||||
} else if seconds > 0.0 {
|
||||
format!("+{seconds:.1} s")
|
||||
} else {
|
||||
format!("−{:.1} s", -seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// `1.4 GiB`, for what a file costs to watch.
|
||||
pub fn format_size(bytes: i64) -> String {
|
||||
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
|
||||
@@ -917,6 +929,13 @@ pub fn columns_of(available: f32, minimum: f32) -> Grid {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_subtitle_delay_reads_with_its_direction() {
|
||||
assert_eq!(super::format_delay(0.3), "+0.3 s");
|
||||
assert_eq!(super::format_delay(-1.2), "−1.2 s");
|
||||
assert_eq!(super::format_delay(0.0000001), "0 s");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles};
|
||||
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 crate::app::Action;
|
||||
@@ -58,6 +58,7 @@ pub fn show(
|
||||
ui.add_space(theme::space::M);
|
||||
appearance_form(ui, prefs.appearance, prefs.desktop, actions);
|
||||
playback_form(ui, prefs, actions);
|
||||
subtitle_form(ui, &prefs.playback.subtitle_style, actions);
|
||||
storage_form(ui, prefs, actions);
|
||||
metadata_form(ui, settings, api_key, actions);
|
||||
about(ui);
|
||||
@@ -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.
|
||||
fn storage_form(ui: &mut egui::Ui, prefs: Prefs<'_>, actions: &mut Vec<Action>) {
|
||||
ui::widgets::settings_group(
|
||||
|
||||
@@ -36,6 +36,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
||||
("N / P", "Next and previous episode"),
|
||||
("[ / ]", "Slower and faster"),
|
||||
("S", "Skip the opening or the credits"),
|
||||
("Z / X", "Subtitles 0.1 s earlier and later"),
|
||||
("E", "The list of episodes"),
|
||||
("F or double-click", "Fullscreen"),
|
||||
("Esc", "Leave fullscreen, then the player"),
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! and neither mutates: this module draws.
|
||||
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
|
||||
use pstr_core::prefs::SubtitleStyle;
|
||||
use pstr_player::{MAX_VOLUME, Track, TrackKind};
|
||||
|
||||
use crate::app::{Action, Adjacent, Page};
|
||||
@@ -470,6 +471,8 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
||||
}
|
||||
ui.separator();
|
||||
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
|
||||
ui.separator();
|
||||
subtitle_adjustments(ui, playback, actions);
|
||||
})
|
||||
.response
|
||||
.on_hover_text("Subtitle track");
|
||||
@@ -489,6 +492,49 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
|
||||
}
|
||||
}
|
||||
|
||||
/// What is worth changing mid-episode without a trip to Settings: the size and
|
||||
/// place of the line, and its timing for a release that is out of step.
|
||||
fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
|
||||
let mut style = playback.subtitle_style.clone();
|
||||
egui::Grid::new("subtitle-adjustments")
|
||||
.num_columns(2)
|
||||
.spacing([theme::space::M, theme::space::S])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Size");
|
||||
ui::settings::percent_slider(
|
||||
ui,
|
||||
&mut style.scale,
|
||||
SubtitleStyle::MIN_SCALE..=SubtitleStyle::MAX_SCALE,
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Position");
|
||||
ui::settings::subtitle_position_slider(ui, &mut style.position);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Delay");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("−").on_hover_text("Earlier (Z)").clicked() {
|
||||
actions.push(Action::NudgeSubtitleDelay(-0.1));
|
||||
}
|
||||
ui.label(ui::format_delay(playback.subtitle_delay));
|
||||
if ui.button("+").on_hover_text("Later (X)").clicked() {
|
||||
actions.push(Action::NudgeSubtitleDelay(0.1));
|
||||
}
|
||||
if ui
|
||||
.add_enabled(playback.subtitle_delay != 0.0, egui::Button::new("Reset"))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ResetSubtitleDelay);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
if style != playback.subtitle_style {
|
||||
actions.push(Action::SetSubtitleStyle(style.sanitized()));
|
||||
}
|
||||
}
|
||||
|
||||
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
|
||||
for track in tracks {
|
||||
if ui.selectable_label(track.selected, track.label()).clicked() {
|
||||
|
||||
@@ -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
|
||||
/// contents group under the series above them;
|
||||
/// - 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`
|
||||
/// 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))
|
||||
.collect();
|
||||
|
||||
nodes
|
||||
let mut origins = Vec::new();
|
||||
let mut rows: Vec<CatalogNode> = nodes
|
||||
.iter()
|
||||
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
|
||||
.map(|node| {
|
||||
let mut row = from_node(share_id, node);
|
||||
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 {
|
||||
// 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
|
||||
})
|
||||
.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.
|
||||
@@ -1784,6 +1860,10 @@ fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
|
||||
#[derive(Debug, Default)]
|
||||
struct Ancestry {
|
||||
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>,
|
||||
year: Option<u32>,
|
||||
}
|
||||
@@ -1822,10 +1902,13 @@ fn ancestry(
|
||||
|
||||
match crate::naming::classify_folder(&ancestor.name) {
|
||||
FolderRole::Season(season) => found.season = found.season.or(Some(season)),
|
||||
FolderRole::Container => {}
|
||||
FolderRole::Container => {
|
||||
found.through_container |= found.title.is_none();
|
||||
}
|
||||
FolderRole::Title(parsed) => {
|
||||
if found.title.is_none() && !parsed.title.is_empty() {
|
||||
found.title = Some(parsed.title.clone());
|
||||
found.title_folder = Some(link_id.clone());
|
||||
found.year = found.year.or(parsed.year);
|
||||
// `Game of Thrones - Season 1` names both; the season it
|
||||
// states belongs to the files under it.
|
||||
@@ -3081,6 +3164,126 @@ mod tests {
|
||||
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
|
||||
/// every top-level file identically.
|
||||
#[test]
|
||||
|
||||
@@ -539,6 +539,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
||||
if is_token_char(tail) && tail != Some(b'v') {
|
||||
continue;
|
||||
}
|
||||
if is_decimal_tail(text, after) {
|
||||
continue;
|
||||
}
|
||||
found = Some(NumberingMatch {
|
||||
numbering: Numbering {
|
||||
season: None,
|
||||
@@ -551,6 +554,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// sits between the title and whatever followed it.
|
||||
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
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
.char_indices()
|
||||
.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,
|
||||
})
|
||||
@@ -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.
|
||||
#[test]
|
||||
fn a_versioned_fansub_episode_keeps_its_number() {
|
||||
|
||||
@@ -60,6 +60,211 @@ pub struct PlaybackPrefs {
|
||||
/// everything at 1.25, and a speed that reset every episode would be a
|
||||
/// setting nobody could use.
|
||||
pub speed: f64,
|
||||
/// How plain subtitles look. Kept with the rest because it is the same
|
||||
/// kind of choice: made once, for every file after.
|
||||
pub subtitle_style: SubtitleStyle,
|
||||
}
|
||||
|
||||
/// How 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 {
|
||||
@@ -73,6 +278,7 @@ impl Default for PlaybackPrefs {
|
||||
autoplay_next: true,
|
||||
auto_skip: false,
|
||||
speed: 1.0,
|
||||
subtitle_style: SubtitleStyle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +299,7 @@ impl PlaybackPrefs {
|
||||
self.speed = self.speed.clamp(MIN_SPEED, MAX_SPEED);
|
||||
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
|
||||
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
|
||||
self.subtitle_style = self.subtitle_style.sanitized();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -149,6 +356,16 @@ mod tests {
|
||||
autoplay_next: false,
|
||||
auto_skip: true,
|
||||
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();
|
||||
assert_eq!(load(&dirs).unwrap(), prefs);
|
||||
@@ -156,6 +373,46 @@ mod tests {
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_subtitle_styles_loads_with_the_default_style() {
|
||||
let prefs: PlaybackPrefs =
|
||||
serde_json::from_str(r#"{"volume": 60.0, "subtitles": true, "speed": 1.0}"#).unwrap();
|
||||
assert_eq!(prefs.subtitle_style, SubtitleStyle::default());
|
||||
assert_eq!(prefs.volume, 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_is_written_rgba_and_handed_to_mpv_as_argb() {
|
||||
let colour = Colour::parse("#FFE60080").unwrap();
|
||||
assert_eq!(colour, Colour::rgba(0xFF, 0xE6, 0x00, 0x80));
|
||||
assert_eq!(colour.to_string(), "#FFE60080");
|
||||
assert_eq!(colour.to_mpv(), "#80FFE600");
|
||||
assert_eq!(
|
||||
Colour::parse("ffe600"),
|
||||
Some(Colour::rgba(0xFF, 0xE6, 0, 0xFF))
|
||||
);
|
||||
assert_eq!(Colour::parse("#FFE6"), None);
|
||||
assert_eq!(Colour::parse("#GGGGGG"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtitle_style_out_of_range_is_brought_back_into_it() {
|
||||
let style = SubtitleStyle {
|
||||
scale: 40.0,
|
||||
outline_size: -3.0,
|
||||
blur: f64::NAN,
|
||||
position: 400.0,
|
||||
font: Some(" ".into()),
|
||||
..SubtitleStyle::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(style.scale, SubtitleStyle::MAX_SCALE);
|
||||
assert_eq!(style.outline_size, 0.0);
|
||||
assert_eq!(style.blur, 0.0);
|
||||
assert_eq!(style.position, 100.0);
|
||||
assert_eq!(style.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
|
||||
let prefs = PlaybackPrefs {
|
||||
|
||||
@@ -379,6 +379,12 @@ impl AniList {
|
||||
/// 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
|
||||
/// 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> {
|
||||
let mut terms = vec![name.to_string()];
|
||||
|
||||
@@ -386,9 +392,31 @@ fn search_terms(name: &str) -> Vec<String> {
|
||||
if depossessed != name {
|
||||
terms.push(depossessed);
|
||||
}
|
||||
let unversioned = without_version_numbers(name);
|
||||
if !unversioned.is_empty() && !terms.contains(&unversioned) {
|
||||
terms.push(unversioned);
|
||||
}
|
||||
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`.
|
||||
///
|
||||
/// 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.
|
||||
#[test]
|
||||
fn a_multi_byte_name_is_not_cut_inside_a_character() {
|
||||
|
||||
@@ -49,6 +49,7 @@ pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start,
|
||||
pub use error::{Error, Result};
|
||||
pub use player::{
|
||||
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
|
||||
subtitle_options,
|
||||
};
|
||||
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
|
||||
pub use render::VideoRenderer;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
|
||||
use libmpv2::events::{Event, PropertyData};
|
||||
use libmpv2::{Format, Mpv};
|
||||
use pstr_core::prefs::{SubtitleBackdrop, SubtitleStyle};
|
||||
use pstr_stream::VideoStream;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
@@ -181,6 +182,8 @@ pub struct PlayerConfig {
|
||||
/// Whether subtitles are shown at all. Off is a choice a viewer makes once
|
||||
/// and expects to keep, and no language preference can express it.
|
||||
pub subtitles: bool,
|
||||
/// How subtitles look. See [`subtitle_options`].
|
||||
pub subtitle_style: SubtitleStyle,
|
||||
/// Raw mpv options, applied last, so anything here wins.
|
||||
pub options: Vec<(String, String)>,
|
||||
}
|
||||
@@ -206,6 +209,7 @@ impl Default for PlayerConfig {
|
||||
audio_language: None,
|
||||
subtitle_language: None,
|
||||
subtitles: true,
|
||||
subtitle_style: SubtitleStyle::default(),
|
||||
options: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -293,6 +297,9 @@ impl Player {
|
||||
if !config.subtitles {
|
||||
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 {
|
||||
init.set_property(name.as_str(), value.as_str())?;
|
||||
}
|
||||
@@ -537,6 +544,18 @@ impl Player {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restyle subtitles now, on the line already showing.
|
||||
pub fn apply_subtitle_style(&self, style: &SubtitleStyle) {
|
||||
apply_subtitle_options(style, |name, value| self.set_option(name, value));
|
||||
}
|
||||
|
||||
/// Show subtitles `seconds` later (or, negative, earlier) than the file
|
||||
/// says. For a release whose subtitles were timed against another cut.
|
||||
pub fn set_subtitle_delay(&self, seconds: f64) -> Result<()> {
|
||||
self.mpv.set_property("sub-delay", seconds)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Playback speed, where 1.0 is normal. Pitch is corrected, which is mpv's
|
||||
/// default and what keeps a voice a voice at 1.5×.
|
||||
pub fn set_speed(&self, speed: f64) -> Result<()> {
|
||||
@@ -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.
|
||||
fn translate(event: Event<'_>) -> PlayerEvent {
|
||||
match event {
|
||||
@@ -620,6 +711,81 @@ fn translate(event: Event<'_>) -> PlayerEvent {
|
||||
mod tests {
|
||||
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]
|
||||
fn every_end_reason_maps_to_something_the_ui_can_act_on() {
|
||||
use libmpv2::mpv_end_file_reason as raw;
|
||||
|
||||
+12
@@ -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,
|
||||
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?
|
||||
|
||||
No. It plays what you keep in Proton Drive or what someone shares with you. It has no catalogue,
|
||||
|
||||
+8
-2
@@ -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
|
||||
*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.
|
||||
|
||||
## Phase 3 — reach
|
||||
@@ -168,7 +176,5 @@ Not done: TMDB fills no details.
|
||||
machine.
|
||||
- **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.
|
||||
- **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
|
||||
Jikan.
|
||||
+26
-2
@@ -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 |
|
||||
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
|
||||
| `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`.
|
||||
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
|
||||
the folder above them is.
|
||||
- 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
|
||||
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
|
||||
**Play now**, **Cancel** or **Watch till the end**.
|
||||
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
|
||||
choice for each show.
|
||||
choice for each show. The subtitle menu also has **Size** and **Position**, which are saved like
|
||||
the ones in Settings and 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.
|
||||
|
||||
### Keyboard
|
||||
@@ -110,6 +114,7 @@ Press **?** anywhere to see this list in the app.
|
||||
| N / P | Next and previous episode |
|
||||
| [ / ] | Slower and faster |
|
||||
| S | Skip the opening or the credits |
|
||||
| Z / X | Subtitles 0.1 s earlier and later |
|
||||
| E | The list of episodes |
|
||||
| F or double-click | Fullscreen |
|
||||
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
|
||||
@@ -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).
|
||||
- **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**
|
||||
|
||||
- **Streaming cache:** 1 to 16 GiB, 4 GiB by default. It keeps the parts of videos you streamed,
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# builds from the registry alone.
|
||||
|
||||
pkgname=proton-stream
|
||||
pkgver=1.6.2
|
||||
pkgver=1.7.0
|
||||
pkgrel=1
|
||||
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user