From 9037e6c2d536135c0445a91c7b8beee5f4c576c1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 20:13:29 +0200 Subject: [PATCH] feat(app,player): page fade, Ctrl+S screenshots - a page fades in over 140 ms when the page changes, rather than cutting - Ctrl+S in the player saves the frame, without subtitles, to Pictures/proton-stream as "Title S01E04 12-34.png" --- Cargo.lock | 1 + crates/pstr-app/Cargo.toml | 1 + crates/pstr-app/src/app.rs | 87 +++++++++++++++++++++++++++++ crates/pstr-app/src/playback.rs | 5 +- crates/pstr-app/src/ui/shortcuts.rs | 1 + crates/pstr-player/src/player.rs | 8 +++ 6 files changed, 102 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 95aacb2..93ccef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4396,6 +4396,7 @@ version = "0.4.2" dependencies = [ "anyhow", "dbus", + "directories", "eframe", "egui", "futures", diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index 99f71ed..b771049 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -29,6 +29,7 @@ sha2.workspace = true parking_lot.workspace = true serde.workspace = true serde_json.workspace = true +directories.workspace = true souvlaki.workspace = true [target.'cfg(target_os = "linux")'.dependencies] diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index e475666..b92771b 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -105,6 +105,8 @@ pub enum Action { Crawl(Option), /// Stop the crawls still listing. StopCrawl, + /// Save the frame on screen to the pictures folder. + Screenshot, /// A share link was pasted outside the form: open the form with it. PasteShare(String), AddShare { @@ -449,6 +451,8 @@ pub struct App { media: crate::media::MediaSession, /// The cover handed to it, worked out once per player. media_cover: Option<(u64, Option)>, + /// The page last drawn and when it was first drawn, for the fade in. + shown_page: (Option, f64), /// Holds the screensaver off while a film is on screen and playing. inhibitor: crate::inhibit::Inhibitor, notifier: crate::notify::Notifier, @@ -521,6 +525,7 @@ impl App { media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)), media_cover: None, inhibitor: crate::inhibit::Inhibitor::new(), + shown_page: (None, 0.0), notifier: crate::notify::Notifier::new(), downloads: Vec::new(), offline_files: std::collections::HashSet::new(), @@ -1172,6 +1177,24 @@ impl App { self.engine.crawl(share); } Action::StopCrawl => self.engine.stop_crawl(), + Action::Screenshot => { + let Some(playback) = &self.playback else { + return; + }; + let Some(folder) = screenshot_folder() else { + return self.note(ctx, "no pictures folder to save a screenshot in", true); + }; + if let Err(error) = std::fs::create_dir_all(&folder) { + return self.note(ctx, format!("could not save a screenshot: {error}"), true); + } + let path = folder.join(screenshot_name( + &playback.target.title_name, + playback.target.caption().as_str(), + playback.position, + )); + self.send_player(crate::playback::Command::Screenshot(path)); + self.flash(ctx, "screenshot saved".to_owned()); + } Action::PasteShare(url) => { self.page = Page::Shares; self.form.url = url; @@ -1659,6 +1682,7 @@ impl eframe::App for App { offline_files, confirm_partial_delete, confirm_remove_share, + shown_page, .. } = self; @@ -1702,6 +1726,7 @@ impl eframe::App for App { .height(); } + let fade = page_fade(ctx, shown_page, page); egui::CentralPanel::default() .frame( egui::Frame::new() @@ -1709,6 +1734,7 @@ impl eframe::App for App { .inner_margin(egui::Margin::symmetric(18, 12)), ) .show(ui, |ui| { + ui.multiply_opacity(fade); // Reborrowed rather than moved: the matching dialog below is // drawn from the same caches, and a moved `&mut` would leave // nothing to draw it with. @@ -1851,6 +1877,54 @@ fn window_handle(_cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_v None } +/// Where screenshots go: a folder of their own in the pictures folder. +fn screenshot_folder() -> Option { + let user = directories::UserDirs::new()?; + let pictures = user + .picture_dir() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| user.home_dir().to_path_buf()); + Some(pictures.join("proton-stream")) +} + +/// `Frieren S01E04 · The Land Where Souls Rest 12-34.png` — what it is +/// and where in it, with anything a file system would refuse taken out. +fn screenshot_name(title: &str, caption: &str, position: f64) -> String { + let at = ui::format_time(position).replace(':', "-"); + let name = if caption.is_empty() { + format!("{title} {at}") + } else { + format!("{title} {caption} {at}") + }; + let safe: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + c if c.is_control() => ' ', + c => c, + }) + .collect(); + format!("{}.png", safe.trim()) +} + +/// How far the page has faded in. A new page arrives over a few frames +/// rather than in one: the cut between two pages of tiles reads as a flicker, +/// a short fade as a change of place. +fn page_fade(ctx: &egui::Context, shown: &mut (Option, f64), page: &Page) -> f32 { + const SECONDS: f64 = 0.14; + let now = ctx.input(|input| input.time); + if shown.0.as_ref() != Some(page) { + // The first page of a launch is there at once; only changes fade. + let first = shown.0.is_none(); + *shown = (Some(page.clone()), if first { now - SECONDS } else { now }); + } + let progress = ((now - shown.1) / SECONDS).clamp(0.0, 1.0) as f32; + if progress < 1.0 { + ctx.request_repaint(); + } + 1.0 - (1.0 - progress).powi(3) +} + /// What the top bar says while shares are being listed: one share by name, /// several as a count, with the files found across all of them. fn crawl_label(progress: &std::collections::BTreeMap) -> String { @@ -2005,6 +2079,10 @@ fn shortcuts( /// enough that holding the key is a ramp rather than a switch. const VOLUME_STEP: f64 = 5.0; + // Before the plain keys, and consumed: Ctrl+S is not also S, which skips. + if ctx.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, Key::S)) { + actions.push(Action::Screenshot); + } let pressed: Vec = ctx.input(|input| { [ Key::Space, @@ -2161,6 +2239,15 @@ mod tests { assert!(!Filter::Watching.admits(&film)); } + #[test] + fn a_screenshot_name_says_what_and_when_without_a_slash() { + assert_eq!( + screenshot_name("AC/DC Live", "S01E02", 754.0), + "AC-DC Live S01E02 12-34.png" + ); + assert_eq!(screenshot_name("Film", "", 5.0), "Film 0-05.png"); + } + #[test] fn the_crawl_label_names_one_share_and_counts_several() { let mut progress = std::collections::BTreeMap::new(); diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index de8a4a7..7f6bf0b 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -130,7 +130,7 @@ impl PlaybackTarget { } /// What the UI can ask the player to do. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum Command { TogglePause, SeekBy(f64), @@ -149,6 +149,8 @@ pub enum Command { SetSubtitlePosition(f64), /// 1.0 is normal speed. SetSpeed(f64), + /// Save the current frame to this file. + Screenshot(std::path::PathBuf), Stop, } @@ -626,6 +628,7 @@ fn apply_command( Command::SetMuted(muted) => player.set_muted(muted), Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), Command::SetSpeed(speed) => player.set_speed(speed), + Command::Screenshot(path) => player.screenshot(&path), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), Command::Stop => { let _ = player.quit(); diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index e32d5c3..e935b6a 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -28,6 +28,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[ ("Space or K", "Pause and play"), ("← / →", "Back 10 s, forward 30 s"), ("↑ / ↓", "Volume"), + ("Ctrl S", "Save the frame to Pictures/proton-stream"), ("M", "Mute"), ("N / P", "Next and previous episode"), ("[ / ]", "Slower and faster"), diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs index 0c478f8..bbd8fe5 100644 --- a/crates/pstr-player/src/player.rs +++ b/crates/pstr-player/src/player.rs @@ -476,6 +476,14 @@ impl Player { Ok(()) } + /// Save the current frame, without subtitles or anything drawn over it, + /// to `path`. The format follows the extension. + pub fn screenshot(&self, path: &std::path::Path) -> Result<()> { + let path = path.to_string_lossy(); + self.mpv.command("screenshot-to-file", &[&path, "video"])?; + Ok(()) + } + pub fn stop(&self) -> Result<()> { self.mpv.command("stop", &[])?; Ok(())