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
This commit is contained in:
nvrl committed 2026-10-02 20:16:27 +02:00
1 parent 959547cdc8
commit 53a1d0afda
6 files changed
+159 -4

No files matched your search

+49
View File
@@ -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,
/// A share link was pasted outside the form: open the form with it.
PasteShare(String),
AddShare { AddShare {
name: String, name: String,
url: String, url: String,
@@ -434,6 +436,7 @@ pub struct App {
media_cover: Option<(u64, Option<String>)>, media_cover: Option<(u64, Option<String>)>,
/// 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,
pub downloads: Vec<DownloadItem>, pub downloads: Vec<DownloadItem>,
pub offline_files: std::collections::HashSet<DownloadKey>, pub offline_files: std::collections::HashSet<DownloadKey>,
pub confirm_partial_delete: Option<DownloadKey>, pub confirm_partial_delete: Option<DownloadKey>,
@@ -503,6 +506,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(),
notifier: crate::notify::Notifier::new(),
downloads: Vec::new(), downloads: Vec::new(),
offline_files: std::collections::HashSet::new(), offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None, confirm_partial_delete: None,
@@ -573,6 +577,10 @@ impl App {
format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"),
false, 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 => { Event::CrawlFinished => {
self.crawling = false; self.crawling = false;
@@ -582,6 +590,21 @@ impl App {
self.crawling = true; self.crawling = true;
self.crawl_progress.insert(share, found); 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 } => { Event::CrawlStopped { share_id } => {
let name = self.share_name(&share_id); let name = self.share_name(&share_id);
self.crawl_progress.remove(&name); self.crawl_progress.remove(&name);
@@ -777,6 +800,15 @@ impl App {
cover 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 { fn share_name(&self, id: &str) -> String {
self.shares self.shares
.iter() .iter()
@@ -1125,6 +1157,12 @@ impl App {
self.engine.crawl(share); self.engine.crawl(share);
} }
Action::StopCrawl => self.engine.stop_crawl(), 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 { Action::AddShare {
name, name,
url, url,
@@ -1807,6 +1845,17 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
if input.consume_key(Modifiers::COMMAND, Key::Comma) { if input.consume_key(Modifiers::COMMAND, Key::Comma) {
tab = Some(3); 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 { if let Some(index) = tab {
let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone(); let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone();
+3 -4
View File
@@ -186,6 +186,8 @@ pub enum Event {
CrawlProgress { share: String, found: usize }, CrawlProgress { share: String, found: usize },
/// Every requested crawl is done. /// Every requested crawl is done.
CrawlFinished, CrawlFinished,
/// A download finished; this is what it was.
Downloaded(String),
/// That share's crawl was stopped before it stored anything. /// That share's crawl was stopped before it stored anything.
CrawlStopped { share_id: String }, CrawlStopped { share_id: String },
/// A decoded Proton thumbnail, keyed as [`thumbnail_key`]. /// A decoded Proton thumbnail, keyed as [`thumbnail_key`].
@@ -1145,10 +1147,7 @@ impl Engine {
item.state = DownloadState::Completed; item.state = DownloadState::Completed;
}); });
engine.load_offline_files(); engine.load_offline_files();
engine.emit(Event::Status(format!( engine.emit(Event::Downloaded(task_target.caption_or_name()));
"{} is available offline",
task_target.name
)));
} }
Ok(DownloadEnd::Cancelled) => { Ok(DownloadEnd::Cancelled) => {
engine.update_download(&task_key, |item| item.state = DownloadState::Cancelled); engine.update_download(&task_key, |item| item.state = DownloadState::Cancelled);
+1
View File
@@ -23,6 +23,7 @@ pub mod app;
pub mod engine; pub mod engine;
pub mod inhibit; pub mod inhibit;
pub mod media; pub mod media;
pub mod notify;
pub mod pacing; pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
+95
View File
@@ -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<std::sync::mpsc::Sender<(String, String)>>,
}
impl Notifier {
pub fn new() -> Self {
Self {
#[cfg(target_os = "linux")]
orders: linux::spawn(),
}
}
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
#[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<Sender<(String, String)>> {
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<Connection> = 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::<String>::new(),
PropMap::new(),
-1i32,
),
);
if let Err(error) = answer {
tracing::debug!("notification: {error}");
}
}
})
.inspect_err(|error| tracing::debug!("notify thread: {error}"))
.ok()?;
Some(orders)
}
}
+10
View File
@@ -110,6 +110,16 @@ impl PlaybackTarget {
/// The line under the title while this plays: the numbering, and the /// The line under the title while this plays: the numbering, and the
/// episode's name when a provider has given one. /// 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 { pub fn caption(&self) -> String {
match &self.episode_name { match &self.episode_name {
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle), Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
+1
View File
@@ -15,6 +15,7 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("F5 or Ctrl R", "Crawl every share again"), ("F5 or Ctrl R", "Crawl every share again"),
("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"), ("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"),
("Ctrl ,", "Settings"), ("Ctrl ,", "Settings"),
("Ctrl V", "Paste a share link to add it"),
("Alt ← or mouse back", "Back"), ("Alt ← or mouse back", "Back"),
("?", "This list"), ("?", "This list"),
], ],