diff --git a/CHANGELOG.md b/CHANGELOG.md index b224e18..fe91457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to cloofy. The format follows [Keep a Changelog](https://kee - **Follows your headphones.** On macOS, playback moves to AirPods or headphones as they connect, and pauses when the output in use disconnects instead of carrying on through the speakers. - **Reconnecting.** cloofy reconnects to Spotify by itself when the connection drops, after sleep or a network outage, and offers to continue where playback stopped. On macOS it pauses before the Mac sleeps and reconnects as soon as it wakes. - **Reduce Motion and Low Power Mode.** On macOS with Reduce Motion, slides become fades, lyrics jump to the current line and the equalizer stands still. In Low Power Mode animations stop and cloofy wakes the computer less often. +- **Notifications.** Each new track shows a desktop notification while cloofy's window is in the background, in kitty, Ghostty, WezTerm and iTerm2, also inside tmux. `notifications = false` turns them off. ### Fixed diff --git a/README.md b/README.md index cd6aa02..9a5fe46 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ cloofy plays music itself, with no official client or daemon running in the back - **Lyrics.** Synced lyrics next to a large cover in the now-playing view. - **Cover art.** Real images in terminals that speak the Kitty graphics protocol (kitty, Ghostty, WezTerm), and blocks elsewhere. - **Mouse.** Click, hover, scroll, and drag the seek and volume bars. +- **Notifications.** In kitty, Ghostty, WezTerm and iTerm2, each new track shows a desktop notification while cloofy's window is in the background. - **Media keys.** On macOS, the media keys, AirPods and Control Center play, pause, skip and seek, and Control Center shows the track with its cover. - **Follows your headphones.** On macOS, playback moves to AirPods or headphones as they connect, and pauses when they disconnect. - **Stays connected.** cloofy reconnects by itself after sleep or a lost network. On macOS it also pauses before the Mac sleeps. @@ -106,6 +107,7 @@ cloofy login --force # sign in again client_id = "your client id" crossfade = 6 # seconds tracks overlap, 0 to 12; off when missing icons = "nerd" # "nerd" or "text"; detected from the terminal when missing +notifications = false # no desktop notifications for new tracks; on when missing ``` cloofy keeps its sign-ins and session in `~/.local/share/cloofy`, and its caches and log in `~/.cache/cloofy`. diff --git a/crates/cloofy-spotify/src/config.rs b/crates/cloofy-spotify/src/config.rs index bbf4744..702a3d7 100644 --- a/crates/cloofy-spotify/src/config.rs +++ b/crates/cloofy-spotify/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub crossfade: Option, /// `"nerd"` or `"text"`; detected from the terminal when missing. pub icons: Option, + /// Desktop notifications for new tracks while cloofy is in the background; on when missing. + pub notifications: Option, } impl Config { diff --git a/crates/cloofy-tui/src/app/mod.rs b/crates/cloofy-tui/src/app/mod.rs index 88e5aa5..789274c 100644 --- a/crates/cloofy-tui/src/app/mod.rs +++ b/crates/cloofy-tui/src/app/mod.rs @@ -3,6 +3,7 @@ mod persist; use std::{ collections::{HashMap, HashSet}, + io::Write, time::{Duration, Instant}, }; @@ -23,6 +24,7 @@ use crate::{ commands::Command, loader::{DataEvent, Loader, Picture}, media::MediaSync, + notify::Notifier, overlay::{MenuAction, Overlay, PaletteAction, PickItem, Picker, PickerOutcome}, playback::{Controller, Cover, NowPlaying, Repeat}, snapshot::Placement, @@ -291,6 +293,10 @@ pub struct App { pub now: NowPlaying, /// The system's media controls, where there are any. media: Option, + /// Announces new tracks while cloofy is in the background, in terminals that can. + notifier: Option, + /// Whether the terminal window has keyboard focus, as far as it reports. + focused: bool, /// When the computer went to sleep, in wall-clock time: the monotonic clock stops meanwhile. slept_at: Option, /// Reduce Motion and Low Power Mode, as of the last check. @@ -368,6 +374,7 @@ impl App { state: crate::state::State, state_path: std::path::PathBuf, media: Option, + notifier: Option, ) -> Self { loader.me(); loader.playlists(); @@ -390,6 +397,8 @@ impl App { player_error: None, now: NowPlaying::new(), media: media.map(MediaSync::new), + notifier, + focused: true, slept_at: None, settings: cloofy_os::settings::current(), settings_checked: Instant::now(), @@ -487,6 +496,10 @@ impl App { } Event::Mouse(mouse) => self.on_mouse(mouse), Event::Resize(..) => true, + Event::FocusGained | Event::FocusLost => { + self.focused = matches!(event, Event::FocusGained); + false + } _ => false, } } @@ -569,6 +582,16 @@ impl App { } } + /// Writes a desktop notification once a new track plays while cloofy is in the background. + pub fn announce_track(&mut self) { + let Some(notifier) = &mut self.notifier else { return }; + let track = self.now.track.as_ref().filter(|_| !self.remote_active); + if let Some(sequence) = notifier.update(track, self.now.playing, self.focused) { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(sequence.as_bytes()).and_then(|()| stdout.flush()); + } + } + /// Brings the system's media controls up to date; cheap when nothing changed. pub fn sync_media(&mut self) { let Some(media) = &mut self.media else { return }; diff --git a/crates/cloofy-tui/src/lib.rs b/crates/cloofy-tui/src/lib.rs index 8996e81..38afb8d 100644 --- a/crates/cloofy-tui/src/lib.rs +++ b/crates/cloofy-tui/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod icons; mod loader; mod media; +mod notify; mod overlay; mod playback; mod snapshot; @@ -16,7 +17,7 @@ use std::{sync::Arc, time::Duration}; use anyhow::{Result, bail}; use cloofy_spotify::{config::Config, paths::Paths, session, webapi::WebApi}; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture, EventStream}; +use crossterm::event::{DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, EventStream}; use futures::{FutureExt, StreamExt}; use ratatui::DefaultTerminal; use ratatui_image::picker::Picker; @@ -45,10 +46,12 @@ pub async fn run(paths: &Paths) -> Result<()> { let mut terminal = ratatui::init(); // Must run after entering the alternate screen and before reading input events. let picker = image_picker(); - crossterm::execute!(std::io::stdout(), EnableMouseCapture)?; + // Focus changes tell whether notifications are worth showing. + crossterm::execute!(std::io::stdout(), EnableMouseCapture, EnableFocusChange)?; let crossfade = config.crossfade.unwrap_or(0).min(12); - let result = run_app(&mut terminal, paths, api, picker, crossfade).await; - let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture); + let notifier = if config.notifications == Some(false) { None } else { notify::Notifier::detect() }; + let result = run_app(&mut terminal, paths, api, picker, crossfade, notifier).await; + let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture, DisableFocusChange); ratatui::restore(); result } @@ -59,6 +62,7 @@ async fn run_app( api: Arc, picker: Picker, crossfade: u64, + notifier: Option, ) -> Result<()> { let (tx, mut events) = mpsc::unbounded_channel(); let (session_tx, session_rx) = watch::channel(None); @@ -92,7 +96,7 @@ async fn run_app( }); let saved = state::load(&paths.state_file()); playback::spawn(paths.clone(), Duration::from_secs(crossfade), saved.volume, retry_now, tx); - let mut app = App::new(loader, session_tx, crossfade, saved, paths.state_file(), media); + let mut app = App::new(loader, session_tx, crossfade, saved, paths.state_file(), media, notifier); let mut input = EventStream::new(); let mut closed = shutdown_signals()?; @@ -120,6 +124,7 @@ async fn run_app( _ = closed.recv() => break, }; app.sync_media(); + app.announce_track(); if ticker.period() != ticker_period(app.settings.low_power) { ticker = new_ticker(app.settings.low_power); } diff --git a/crates/cloofy-tui/src/notify.rs b/crates/cloofy-tui/src/notify.rs new file mode 100644 index 0000000..feac828 --- /dev/null +++ b/crates/cloofy-tui/src/notify.rs @@ -0,0 +1,186 @@ +//! Desktop notifications for new tracks while cloofy's window is in the background. They go +//! through the terminal's own escape sequence, so they need no permission of their own and a +//! click leads back to the terminal. + +use std::time::{Duration, Instant}; + +use base64::{Engine, engine::general_purpose::STANDARD}; + +use crate::playback::CurrentTrack; + +/// How long a track has to play before it gets announced, so skipping through doesn't flood. +const SETTLE: Duration = Duration::from_millis(1500); + +/// The escape sequence a terminal understands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Protocol { + /// kitty's OSC 99: title and body, and a new one replaces the last. + Kitty, + /// rxvt's OSC 777, also spoken by Ghostty and WezTerm: title and body. + Rxvt, + /// iTerm2's OSC 9: a single line. + ITerm, +} + +pub struct Notifier { + protocol: Protocol, + /// Inside tmux, which passes the sequence on only when wrapped. + tmux: bool, + /// The track playing, and since when. + pending: Option<(String, Instant)>, + /// The track last announced, or that played while cloofy was in front. + announced: Option, +} + +impl Notifier { + /// `None` in terminals without notifications. + pub fn detect() -> Option { + let var = |name| std::env::var(name).unwrap_or_default(); + let set = |name| std::env::var_os(name).is_some(); + let protocol = if set("KITTY_WINDOW_ID") || var("TERM").contains("kitty") { + Protocol::Kitty + } else if set("GHOSTTY_RESOURCES_DIR") || set("WEZTERM_PANE") { + Protocol::Rxvt + } else if set("ITERM_SESSION_ID") { + Protocol::ITerm + } else { + match var("TERM_PROGRAM").as_str() { + "ghostty" | "WezTerm" => Protocol::Rxvt, + "iTerm.app" => Protocol::ITerm, + _ => return None, + } + }; + Some(Self { protocol, tmux: set("TMUX"), pending: None, announced: None }) + } + + /// The escape sequence to write when a track has been playing for a moment while cloofy + /// is in the background. `track` is cloofy's own playback only. + pub fn update(&mut self, track: Option<&CurrentTrack>, playing: bool, focused: bool) -> Option { + let Some(track) = track.filter(|_| playing) else { + self.pending = None; + return None; + }; + if self.announced.as_deref() == Some(track.uri.as_str()) { + return None; + } + match &self.pending { + Some((uri, since)) if *uri == track.uri => { + if since.elapsed() < SETTLE { + return None; + } + self.pending = None; + self.announced = Some(track.uri.clone()); + (!focused) + .then(|| self.sequence(&track.name, &format!("{} · {}", track.artists.join(", "), track.album))) + } + _ => { + self.pending = Some((track.uri.clone(), Instant::now())); + None + } + } + } + + fn sequence(&self, title: &str, body: &str) -> String { + let (title, body) = (clean(title), clean(body)); + let sequence = match self.protocol { + Protocol::Kitty => { + let app = STANDARD.encode("cloofy"); + let (title, body) = (STANDARD.encode(title), STANDARD.encode(body)); + format!( + "\x1b]99;i=cloofy:d=0:e=1:o=unfocused:f={app};{title}\x1b\\\x1b]99;i=cloofy:p=body:e=1;{body}\x1b\\" + ) + } + Protocol::Rxvt => format!("\x1b]777;notify;{title};{body}\x07"), + Protocol::ITerm => format!("\x1b]9;{title} · {body}\x07"), + }; + if self.tmux { passthrough(&sequence) } else { sequence } + } +} + +/// Without control characters, which would end the sequence, or semicolons, which separate its +/// fields (and turn OSC 9 into other commands in some terminals). +fn clean(text: &str) -> String { + text.chars().filter(|c| !c.is_control()).map(|c| if c == ';' { ',' } else { c }).collect() +} + +/// tmux passes a sequence on to the terminal when wrapped like this, with `allow-passthrough on`. +fn passthrough(sequence: &str) -> String { + format!("\x1bPtmux;{}\x1b\\", sequence.replace('\x1b', "\x1b\x1b")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn notifier(protocol: Protocol) -> Notifier { + Notifier { protocol, tmux: false, pending: None, announced: None } + } + + fn track(uri: &str) -> CurrentTrack { + CurrentTrack { + uri: uri.into(), + name: "One More Time".into(), + artists: vec!["Daft Punk".into()], + artist_uris: Vec::new(), + album: "Discovery".into(), + album_uri: None, + duration_ms: 320_000, + cover_url: None, + cover_url_large: None, + } + } + + /// Plays `track` until it settles. + fn settle(notifier: &mut Notifier, track: &CurrentTrack, focused: bool) -> Option { + assert_eq!(notifier.update(Some(track), true, focused), None, "not before it settled"); + notifier.pending.as_mut().unwrap().1 -= SETTLE; + notifier.update(Some(track), true, focused) + } + + #[test] + fn announces_a_track_once_it_settled() { + let mut notifier = notifier(Protocol::Rxvt); + let one = track("spotify:track:1"); + let sequence = settle(&mut notifier, &one, false); + assert_eq!(sequence.as_deref(), Some("\x1b]777;notify;One More Time;Daft Punk · Discovery\x07")); + assert_eq!(notifier.update(Some(&one), true, false), None, "only once"); + } + + #[test] + fn stays_quiet_in_front_and_for_that_track_afterwards() { + let mut notifier = notifier(Protocol::Rxvt); + let one = track("spotify:track:1"); + assert_eq!(settle(&mut notifier, &one, true), None); + assert_eq!(notifier.update(Some(&one), true, false), None); + assert!(settle(&mut notifier, &track("spotify:track:2"), false).is_some()); + } + + #[test] + fn waits_while_paused() { + let mut notifier = notifier(Protocol::Rxvt); + let one = track("spotify:track:1"); + notifier.update(Some(&one), true, false); + notifier.update(Some(&one), false, false); + assert!(notifier.pending.is_none(), "pausing starts the wait over"); + } + + #[test] + fn speaks_each_terminal_s_protocol() { + let mut kitty = notifier(Protocol::Kitty); + let sequence = settle(&mut kitty, &track("spotify:track:1"), false).unwrap(); + assert!(sequence.starts_with("\x1b]99;i=cloofy:d=0:e=1:o=unfocused:f=Y2xvb2Z5;T25lIE1vcmUgVGltZQ==\x1b\\")); + let mut iterm = notifier(Protocol::ITerm); + let sequence = settle(&mut iterm, &track("spotify:track:1"), false); + assert_eq!(sequence.as_deref(), Some("\x1b]9;One More Time · Daft Punk · Discovery\x07")); + } + + #[test] + fn keeps_titles_from_breaking_the_sequence() { + assert_eq!(clean("a;b\x07c\x1bd"), "a,bcd"); + let mut tmux = notifier(Protocol::Rxvt); + tmux.tmux = true; + let sequence = settle(&mut tmux, &track("spotify:track:1"), false).unwrap(); + assert!(sequence.starts_with("\x1bPtmux;\x1b\x1b]777;notify;")); + assert!(sequence.ends_with("\x07\x1b\\")); + } +}