Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions crates/cloofy-spotify/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub struct Config {
pub crossfade: Option<u64>,
/// `"nerd"` or `"text"`; detected from the terminal when missing.
pub icons: Option<String>,
/// Desktop notifications for new tracks while cloofy is in the background; on when missing.
pub notifications: Option<bool>,
}

impl Config {
Expand Down
23 changes: 23 additions & 0 deletions crates/cloofy-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod persist;

use std::{
collections::{HashMap, HashSet},
io::Write,
time::{Duration, Instant},
};

Expand All @@ -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,
Expand Down Expand Up @@ -291,6 +293,10 @@ pub struct App {
pub now: NowPlaying,
/// The system's media controls, where there are any.
media: Option<MediaSync>,
/// Announces new tracks while cloofy is in the background, in terminals that can.
notifier: Option<Notifier>,
/// 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<std::time::SystemTime>,
/// Reduce Motion and Low Power Mode, as of the last check.
Expand Down Expand Up @@ -368,6 +374,7 @@ impl App {
state: crate::state::State,
state_path: std::path::PathBuf,
media: Option<cloofy_os::media::Controls>,
notifier: Option<Notifier>,
) -> Self {
loader.me();
loader.playlists();
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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 };
Expand Down
15 changes: 10 additions & 5 deletions crates/cloofy-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod commands;
mod icons;
mod loader;
mod media;
mod notify;
mod overlay;
mod playback;
mod snapshot;
Expand All @@ -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;
Expand Down Expand Up @@ -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
}
Expand All @@ -59,6 +62,7 @@ async fn run_app(
api: Arc<WebApi>,
picker: Picker,
crossfade: u64,
notifier: Option<notify::Notifier>,
) -> Result<()> {
let (tx, mut events) = mpsc::unbounded_channel();
let (session_tx, session_rx) = watch::channel(None);
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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);
}
Expand Down
186 changes: 186 additions & 0 deletions crates/cloofy-tui/src/notify.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl Notifier {
/// `None` in terminals without notifications.
pub fn detect() -> Option<Self> {
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<String> {
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<String> {
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\\"));
}
}
Loading