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>),
|
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
/// 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),
|
||||||
|
|||||||
@@ -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"),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user