mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
959547cdc8
commit
53a1d0afda
6 files changed
+159
-4
No files matched your search
@@ -105,6 +105,8 @@ pub enum Action {
|
||||
Crawl(Option<String>),
|
||||
/// 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<String>)>,
|
||||
/// Holds the screensaver off while a film is on screen and playing.
|
||||
inhibitor: crate::inhibit::Inhibitor,
|
||||
notifier: crate::notify::Notifier,
|
||||
pub downloads: Vec<DownloadItem>,
|
||||
pub offline_files: std::collections::HashSet<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_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<Action>)
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user