From 53a1d0afdace0288c2f510a561e919b3e31bf7dd Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 19:58:44 +0200 Subject: [PATCH] feat(app): desktop notifications, paste a share link anywhere - a finished batch of downloads, and a crawl long enough to walk away from, notify through org.freedesktop.Notifications when the window is not focused; once per batch, not once per file - Ctrl+V with a share link outside a text field opens the add form with the link in it --- crates/pstr-app/src/app.rs | 49 +++++++++++++++ crates/pstr-app/src/engine.rs | 7 +-- crates/pstr-app/src/lib.rs | 1 + crates/pstr-app/src/notify.rs | 95 +++++++++++++++++++++++++++++ crates/pstr-app/src/playback.rs | 10 +++ crates/pstr-app/src/ui/shortcuts.rs | 1 + 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 crates/pstr-app/src/notify.rs diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 7a53fe4..c84134b 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, + /// A share link was pasted outside the form: open the form with it. + PasteShare(String), AddShare { name: String, url: String, @@ -434,6 +436,7 @@ pub struct App { media_cover: Option<(u64, Option)>, /// Holds the screensaver off while a film is on screen and playing. inhibitor: crate::inhibit::Inhibitor, + notifier: crate::notify::Notifier, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, @@ -503,6 +506,7 @@ impl App { media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)), media_cover: None, inhibitor: crate::inhibit::Inhibitor::new(), + notifier: crate::notify::Notifier::new(), downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, @@ -573,6 +577,10 @@ impl App { format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), false, ); + // Only a crawl long enough to have walked away from. + if seconds >= 30.0 { + self.notify(ctx, &format!("Crawled {name}"), &format!("{files} files")); + } } Event::CrawlFinished => { self.crawling = false; @@ -582,6 +590,21 @@ impl App { self.crawling = true; self.crawl_progress.insert(share, found); } + Event::Downloaded(name) => { + self.note(ctx, format!("{name} is available offline"), false); + // Once per batch, when the last of it lands: a season is + // two dozen files, and two dozen notifications is spam. + let more = self.downloads.iter().any(|item| { + matches!( + item.state, + crate::engine::DownloadState::Queued + | crate::engine::DownloadState::Running + ) + }); + if !more { + self.notify(ctx, "Downloads finished", &name); + } + } Event::CrawlStopped { share_id } => { let name = self.share_name(&share_id); self.crawl_progress.remove(&name); @@ -777,6 +800,15 @@ impl App { cover } + /// A desktop notification, if the window is not what the viewer is + /// looking at. When it is, the toast already said it. + fn notify(&self, ctx: &egui::Context, summary: &str, body: &str) { + let focused = ctx.input(|input| input.viewport().focused.unwrap_or(true)); + if !focused { + self.notifier.send(summary, body); + } + } + fn share_name(&self, id: &str) -> String { self.shares .iter() @@ -1125,6 +1157,12 @@ impl App { self.engine.crawl(share); } Action::StopCrawl => self.engine.stop_crawl(), + Action::PasteShare(url) => { + self.page = Page::Shares; + self.form.url = url; + self.form.error = None; + self.note(ctx, "share link pasted — name it and add it", false); + } Action::AddShare { name, url, @@ -1807,6 +1845,17 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec) if input.consume_key(Modifiers::COMMAND, Key::Comma) { tab = Some(3); } + // A share link pasted anywhere outside a text field is someone adding + // a share, wherever they happen to be. + if !typing { + for event in &input.events { + if let egui::Event::Paste(text) = event + && pstr_core::shares::share_token(text.trim()).is_ok() + { + actions.push(Action::PasteShare(text.trim().to_owned())); + } + } + } }); if let Some(index) = tab { let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone(); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 012c740..a90b007 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -186,6 +186,8 @@ pub enum Event { CrawlProgress { share: String, found: usize }, /// Every requested crawl is done. CrawlFinished, + /// A download finished; this is what it was. + Downloaded(String), /// That share's crawl was stopped before it stored anything. CrawlStopped { share_id: String }, /// A decoded Proton thumbnail, keyed as [`thumbnail_key`]. @@ -1145,10 +1147,7 @@ impl Engine { item.state = DownloadState::Completed; }); engine.load_offline_files(); - engine.emit(Event::Status(format!( - "{} is available offline", - task_target.name - ))); + engine.emit(Event::Downloaded(task_target.caption_or_name())); } Ok(DownloadEnd::Cancelled) => { engine.update_download(&task_key, |item| item.state = DownloadState::Cancelled); diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index 4514bd1..c189f95 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -23,6 +23,7 @@ pub mod app; pub mod engine; pub mod inhibit; pub mod media; +pub mod notify; pub mod pacing; pub mod playback; pub mod theme; diff --git a/crates/pstr-app/src/notify.rs b/crates/pstr-app/src/notify.rs new file mode 100644 index 0000000..7a7f7b0 --- /dev/null +++ b/crates/pstr-app/src/notify.rs @@ -0,0 +1,95 @@ +//! Desktop notifications, for things that finish while nobody is looking. +//! +//! Only when the window does not have focus: a viewer who is looking at the +//! app already has the toast, and a second copy of it from the panel is noise. +//! That rule is the caller's — see `App::notify`. +//! +//! Linux only, over `org.freedesktop.Notifications`. Windows wants an +//! application id registered by an installer before a toast will show, and a +//! notification that silently never appears is worse than none. + +/// Sends notifications from a thread of its own, so a notification daemon +/// that is slow to answer never costs a frame. +pub struct Notifier { + #[cfg(target_os = "linux")] + orders: Option>, +} + +impl Notifier { + pub fn new() -> Self { + Self { + #[cfg(target_os = "linux")] + orders: linux::spawn(), + } + } + + pub fn send(&self, summary: impl Into, body: impl Into) { + #[cfg(target_os = "linux")] + if let Some(orders) = &self.orders { + let _ = orders.send((summary.into(), body.into())); + } + #[cfg(not(target_os = "linux"))] + let _ = (summary.into(), body.into()); + } +} + +impl Default for Notifier { + fn default() -> Self { + Self::new() + } +} + +#[cfg(target_os = "linux")] +mod linux { + use std::sync::mpsc::{Sender, channel}; + use std::time::Duration; + + use dbus::arg::PropMap; + use dbus::blocking::Connection; + + pub fn spawn() -> Option> { + let (orders, receiver) = channel::<(String, String)>(); + std::thread::Builder::new() + .name("notify".into()) + .spawn(move || { + // Connected on first use rather than at startup: most sessions + // never send one. + let mut connection: Option = None; + for (summary, body) in receiver { + if connection.is_none() { + connection = Connection::new_session() + .inspect_err(|error| tracing::debug!("notifications: {error}")) + .ok(); + } + let Some(connection) = &connection else { + continue; + }; + let proxy = connection.with_proxy( + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + Duration::from_secs(2), + ); + let answer: Result<(u32,), _> = proxy.method_call( + "org.freedesktop.Notifications", + "Notify", + ( + "proton-stream", + 0u32, + "io.narl.proton-stream", + summary, + body, + Vec::::new(), + PropMap::new(), + -1i32, + ), + ); + if let Err(error) = answer { + tracing::debug!("notification: {error}"); + } + } + }) + .inspect_err(|error| tracing::debug!("notify thread: {error}")) + .ok()?; + Some(orders) + } +} diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 66290e1..ebb871a 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -110,6 +110,16 @@ impl PlaybackTarget { /// The line under the title while this plays: the numbering, and the /// episode's name when a provider has given one. + /// How to name this file to someone not looking at its title page: + /// the title and the episode, or the file name when there is no title. + pub fn caption_or_name(&self) -> String { + match (self.title_name.is_empty(), self.caption()) { + (false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name), + (false, _) => self.title_name.clone(), + (true, _) => self.name.clone(), + } + } + pub fn caption(&self) -> String { match &self.episode_name { Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle), diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index 9511e81..53a9233 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -15,6 +15,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[ ("F5 or Ctrl R", "Crawl every share again"), ("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"), ("Ctrl ,", "Settings"), + ("Ctrl V", "Paste a share link to add it"), ("Alt ← or mouse back", "Back"), ("?", "This list"), ],