mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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"
This commit is contained in:
1 parent
d3325843a9
commit
9037e6c2d5
6 files changed
+102
-1
No files matched your search
Generated
+1
@@ -4396,6 +4396,7 @@ version = "0.4.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dbus",
|
"dbus",
|
||||||
|
"directories",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ sha2.workspace = true
|
|||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
directories.workspace = true
|
||||||
souvlaki.workspace = true
|
souvlaki.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ pub enum Action {
|
|||||||
Crawl(Option<String>),
|
Crawl(Option<String>),
|
||||||
/// Stop the crawls still listing.
|
/// Stop the crawls still listing.
|
||||||
StopCrawl,
|
StopCrawl,
|
||||||
|
/// Save the frame on screen to the pictures folder.
|
||||||
|
Screenshot,
|
||||||
/// A share link was pasted outside the form: open the form with it.
|
/// A share link was pasted outside the form: open the form with it.
|
||||||
PasteShare(String),
|
PasteShare(String),
|
||||||
AddShare {
|
AddShare {
|
||||||
@@ -449,6 +451,8 @@ pub struct App {
|
|||||||
media: crate::media::MediaSession,
|
media: crate::media::MediaSession,
|
||||||
/// The cover handed to it, worked out once per player.
|
/// The cover handed to it, worked out once per player.
|
||||||
media_cover: Option<(u64, Option<String>)>,
|
media_cover: Option<(u64, Option<String>)>,
|
||||||
|
/// The page last drawn and when it was first drawn, for the fade in.
|
||||||
|
shown_page: (Option<Page>, f64),
|
||||||
/// Holds the screensaver off while a film is on screen and playing.
|
/// Holds the screensaver off while a film is on screen and playing.
|
||||||
inhibitor: crate::inhibit::Inhibitor,
|
inhibitor: crate::inhibit::Inhibitor,
|
||||||
notifier: crate::notify::Notifier,
|
notifier: crate::notify::Notifier,
|
||||||
@@ -521,6 +525,7 @@ impl App {
|
|||||||
media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)),
|
media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)),
|
||||||
media_cover: None,
|
media_cover: None,
|
||||||
inhibitor: crate::inhibit::Inhibitor::new(),
|
inhibitor: crate::inhibit::Inhibitor::new(),
|
||||||
|
shown_page: (None, 0.0),
|
||||||
notifier: crate::notify::Notifier::new(),
|
notifier: crate::notify::Notifier::new(),
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
offline_files: std::collections::HashSet::new(),
|
offline_files: std::collections::HashSet::new(),
|
||||||
@@ -1172,6 +1177,24 @@ impl App {
|
|||||||
self.engine.crawl(share);
|
self.engine.crawl(share);
|
||||||
}
|
}
|
||||||
Action::StopCrawl => self.engine.stop_crawl(),
|
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) => {
|
Action::PasteShare(url) => {
|
||||||
self.page = Page::Shares;
|
self.page = Page::Shares;
|
||||||
self.form.url = url;
|
self.form.url = url;
|
||||||
@@ -1659,6 +1682,7 @@ impl eframe::App for App {
|
|||||||
offline_files,
|
offline_files,
|
||||||
confirm_partial_delete,
|
confirm_partial_delete,
|
||||||
confirm_remove_share,
|
confirm_remove_share,
|
||||||
|
shown_page,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
@@ -1702,6 +1726,7 @@ impl eframe::App for App {
|
|||||||
.height();
|
.height();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fade = page_fade(ctx, shown_page, page);
|
||||||
egui::CentralPanel::default()
|
egui::CentralPanel::default()
|
||||||
.frame(
|
.frame(
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
@@ -1709,6 +1734,7 @@ impl eframe::App for App {
|
|||||||
.inner_margin(egui::Margin::symmetric(18, 12)),
|
.inner_margin(egui::Margin::symmetric(18, 12)),
|
||||||
)
|
)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
|
ui.multiply_opacity(fade);
|
||||||
// Reborrowed rather than moved: the matching dialog below is
|
// Reborrowed rather than moved: the matching dialog below is
|
||||||
// drawn from the same caches, and a moved `&mut` would leave
|
// drawn from the same caches, and a moved `&mut` would leave
|
||||||
// nothing to draw it with.
|
// nothing to draw it with.
|
||||||
@@ -1851,6 +1877,54 @@ fn window_handle(_cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_v
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where screenshots go: a folder of their own in the pictures folder.
|
||||||
|
fn screenshot_folder() -> Option<std::path::PathBuf> {
|
||||||
|
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<Page>, 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,
|
/// 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.
|
/// several as a count, with the files found across all of them.
|
||||||
fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
|
fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
|
||||||
@@ -2005,6 +2079,10 @@ fn shortcuts(
|
|||||||
/// enough that holding the key is a ramp rather than a switch.
|
/// enough that holding the key is a ramp rather than a switch.
|
||||||
const VOLUME_STEP: f64 = 5.0;
|
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<Key> = ctx.input(|input| {
|
let pressed: Vec<Key> = ctx.input(|input| {
|
||||||
[
|
[
|
||||||
Key::Space,
|
Key::Space,
|
||||||
@@ -2161,6 +2239,15 @@ mod tests {
|
|||||||
assert!(!Filter::Watching.admits(&film));
|
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]
|
#[test]
|
||||||
fn the_crawl_label_names_one_share_and_counts_several() {
|
fn the_crawl_label_names_one_share_and_counts_several() {
|
||||||
let mut progress = std::collections::BTreeMap::new();
|
let mut progress = std::collections::BTreeMap::new();
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ impl PlaybackTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What the UI can ask the player to do.
|
/// What the UI can ask the player to do.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
TogglePause,
|
TogglePause,
|
||||||
SeekBy(f64),
|
SeekBy(f64),
|
||||||
@@ -149,6 +149,8 @@ pub enum Command {
|
|||||||
SetSubtitlePosition(f64),
|
SetSubtitlePosition(f64),
|
||||||
/// 1.0 is normal speed.
|
/// 1.0 is normal speed.
|
||||||
SetSpeed(f64),
|
SetSpeed(f64),
|
||||||
|
/// Save the current frame to this file.
|
||||||
|
Screenshot(std::path::PathBuf),
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +628,7 @@ fn apply_command(
|
|||||||
Command::SetMuted(muted) => player.set_muted(muted),
|
Command::SetMuted(muted) => player.set_muted(muted),
|
||||||
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent),
|
||||||
Command::SetSpeed(speed) => player.set_speed(speed),
|
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::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
|
||||||
Command::Stop => {
|
Command::Stop => {
|
||||||
let _ = player.quit();
|
let _ = player.quit();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("Space or K", "Pause and play"),
|
("Space or K", "Pause and play"),
|
||||||
("← / →", "Back 10 s, forward 30 s"),
|
("← / →", "Back 10 s, forward 30 s"),
|
||||||
("↑ / ↓", "Volume"),
|
("↑ / ↓", "Volume"),
|
||||||
|
("Ctrl S", "Save the frame to Pictures/proton-stream"),
|
||||||
("M", "Mute"),
|
("M", "Mute"),
|
||||||
("N / P", "Next and previous episode"),
|
("N / P", "Next and previous episode"),
|
||||||
("[ / ]", "Slower and faster"),
|
("[ / ]", "Slower and faster"),
|
||||||
|
|||||||
@@ -476,6 +476,14 @@ impl Player {
|
|||||||
Ok(())
|
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<()> {
|
pub fn stop(&self) -> Result<()> {
|
||||||
self.mpv.command("stop", &[])?;
|
self.mpv.command("stop", &[])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in new issue
Block a user