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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ All notable changes to cloofy. The format follows [Keep a Changelog](https://kee
### Added

- **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. This works in `cloofy play` and `cloofy connect` too.
- **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.

### Fixed

- On macOS, playback no longer stalls for good when the headphones or AirPods in use disconnect.

## [0.1.0] - 2026-10-15

Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ nucleo-matcher = "0.3"
# macOS frameworks; each crate turns on the parts it uses.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std"] }
objc2-core-audio = { version = "0.3", default-features = false, features = ["std"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std"] }
objc2-media-player = { version = "0.3", default-features = false, features = ["std"] }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ cloofy plays music itself, with no official client or daemon running in the back
- **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.
- **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.
- **Picks up where you left off.** The same page, the same selection, the last track ready to resume.

## Requirements
Expand Down
4 changes: 4 additions & 0 deletions crates/cloofy-os/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ license.workspace = true
repository.workspace = true
description = "Operating system integration for cloofy: media controls and system events"

[dependencies]
tracing.workspace = true

[target.'cfg(target_os = "macos")'.dependencies]
block2.workspace = true
dispatch2.workspace = true
objc2.workspace = true
objc2-app-kit = { workspace = true, features = ["NSImage"] }
objc2-core-audio = { workspace = true, features = ["AudioHardware"] }
objc2-core-foundation = { workspace = true, features = ["CFCGTypes", "CFRunLoop"] }
objc2-foundation = { workspace = true, features = ["NSData", "NSDictionary", "NSString", "NSValue"] }
objc2-media-player = { workspace = true, features = [
Expand Down
19 changes: 19 additions & 0 deletions crates/cloofy-os/src/audio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! The system's audio output.

use std::sync::Arc;

use crate::os;

/// Calls back whenever the system's default output changes: headphones plugged in, AirPods
/// connected or gone, another output picked in Control Center. Stops when dropped.
pub struct OutputWatch {
_watch: os::audio::OutputWatch,
}

impl OutputWatch {
/// `on_change` may get called on any thread. `None` where the system can't tell, or where
/// its audio follows the default output by itself.
pub fn new(on_change: impl Fn() + Send + Sync + 'static) -> Option<Self> {
os::audio::OutputWatch::new(Arc::new(on_change)).map(|watch| Self { _watch: watch })
}
}
13 changes: 13 additions & 0 deletions crates/cloofy-os/src/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ pub fn run_main<R>(work: impl FnOnce() -> R) -> R {
work()
}

pub mod audio {
use std::sync::Arc;

/// Never exists: the output can't be watched here, or follows the default by itself.
pub enum OutputWatch {}

impl OutputWatch {
pub fn new(_on_change: Arc<dyn Fn() + Send + Sync>) -> Option<Self> {
None
}
}
}

pub mod media {
use std::sync::Arc;

Expand Down
1 change: 1 addition & 0 deletions crates/cloofy-os/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Each OS implements it in its own module; on systems without an implementation everything
//! here does nothing, so callers never need `cfg` attributes of their own.

pub mod audio;
pub mod media;

#[cfg(target_os = "macos")]
Expand Down
76 changes: 76 additions & 0 deletions crates/cloofy-os/src/macos/audio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Changes of the default output, through a CoreAudio property listener.

use std::{ffi::c_void, ptr::NonNull, sync::Arc};

use objc2_core_audio::{
AudioObjectAddPropertyListener, AudioObjectID, AudioObjectPropertyAddress, AudioObjectRemovePropertyListener,
kAudioHardwarePropertyDefaultOutputDevice, kAudioObjectPropertyElementMain, kAudioObjectPropertyScopeGlobal,
kAudioObjectSystemObject,
};

type Callback = Arc<dyn Fn() + Send + Sync>;

static DEFAULT_OUTPUT: AudioObjectPropertyAddress = AudioObjectPropertyAddress {
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain,
};

pub struct OutputWatch {
/// Handed to CoreAudio as the listener's data; freed once the listener is removed.
callback: NonNull<Callback>,
}

// SAFETY: the callback is `Send + Sync` and only ever called, never changed.
unsafe impl Send for OutputWatch {}

impl OutputWatch {
pub fn new(on_change: Callback) -> Option<Self> {
let callback = NonNull::from(Box::leak(Box::new(on_change)));
// SAFETY: the address is a valid static, and the callback stays alive until the
// listener is removed in `drop`.
let status = unsafe {
AudioObjectAddPropertyListener(
kAudioObjectSystemObject as AudioObjectID,
NonNull::from(&DEFAULT_OUTPUT),
Some(changed),
callback.as_ptr().cast(),
)
};
if status != 0 {
tracing::warn!("cannot watch the default audio output: OSStatus {status}");
// SAFETY: the listener wasn't added, so nothing else holds the pointer.
drop(unsafe { Box::from_raw(callback.as_ptr()) });
return None;
}
Some(Self { callback })
}
}

impl Drop for OutputWatch {
fn drop(&mut self) {
// SAFETY: removes exactly the listener added in `new`; once it is gone, CoreAudio no
// longer uses the callback and it can be freed.
unsafe {
AudioObjectRemovePropertyListener(
kAudioObjectSystemObject as AudioObjectID,
NonNull::from(&DEFAULT_OUTPUT),
Some(changed),
self.callback.as_ptr().cast(),
);
drop(Box::from_raw(self.callback.as_ptr()));
}
}
}

unsafe extern "C-unwind" fn changed(
_: AudioObjectID,
_: u32,
_: NonNull<AudioObjectPropertyAddress>,
data: *mut c_void,
) -> i32 {
// SAFETY: `data` is the callback of a registered listener, alive until it is removed.
let callback = unsafe { &*data.cast::<Callback>() };
callback();
0
}
1 change: 1 addition & 0 deletions crates/cloofy-os/src/macos/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod audio;
pub mod media;

use std::{panic, thread};
Expand Down
5 changes: 5 additions & 0 deletions crates/cloofy-player/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true

# Follows the system's default output; librespot's own rodio output stays on the first one.
[target.'cfg(target_os = "macos")'.dependencies]
cloofy-os.workspace = true
rodio = { version = "0.21", default-features = false, features = ["playback"] }

# PulseAudio talks to PipeWire's pulse server directly; cpal over ALSA keeps erroring while paused.
[target.'cfg(target_os = "linux")'.dependencies]
librespot = { workspace = true, features = ["pulseaudio-backend"] }
33 changes: 28 additions & 5 deletions crates/cloofy-player/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#[cfg(target_os = "macos")]
mod macos;

use std::{sync::Arc, time::Duration};

use anyhow::{Context, Result};
Expand All @@ -6,12 +9,13 @@ use librespot::{
connect::{ConnectConfig, Spirc},
core::{config::DeviceType, session::Session},
playback::{
audio_backend,
audio_backend::{self, Sink, SinkBuilder},
config::{AudioFormat, Bitrate, PlayerConfig},
mixer::{self, Mixer, MixerConfig},
player::Player,
},
};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

/// Preferred audio outputs, best first; librespot's default (rodio) is the fallback.
#[cfg(target_os = "linux")]
Expand All @@ -27,17 +31,21 @@ pub struct Engine {

impl Engine {
/// `crossfade` overlaps consecutive tracks by that long; zero plays them back to back.
pub fn new(session: &Session, crossfade: Duration) -> Result<Self> {
let sink = PREFERRED_BACKENDS
///
/// The receiver gets the name of each audio output that goes away during playback, such as
/// headphones being unplugged; playback should pause then.
pub fn new(session: &Session, crossfade: Duration) -> Result<(Self, UnboundedReceiver<String>)> {
let backend = PREFERRED_BACKENDS
.iter()
.find_map(|name| audio_backend::find(Some((*name).to_owned())))
.or_else(|| audio_backend::find(None))
.context("No audio backend available")?;
let mixer = (mixer::find(None).context("No mixer available")?)(MixerConfig::default())?;
let config = PlayerConfig { bitrate: Bitrate::Bitrate320, crossfade, ..PlayerConfig::default() };
let (lost_tx, lost_rx) = mpsc::unbounded_channel();
let player =
Player::new(config, session.clone(), mixer.get_soft_volume(), move || sink(None, AudioFormat::default()));
Ok(Self { player, mixer })
Player::new(config, session.clone(), mixer.get_soft_volume(), move || open_output(backend, lost_tx));
Ok((Self { player, mixer }, lost_rx))
}

/// Registers the player as a Spotify Connect device. Spirc connects the session itself,
Expand All @@ -51,3 +59,18 @@ impl Engine {
Ok(Spirc::new(config, pending.session, pending.credentials, self.player.clone(), self.mixer.clone()).await?)
}
}

/// On macOS, an output that follows the system's default output.
#[cfg(target_os = "macos")]
fn open_output(backend: SinkBuilder, lost: UnboundedSender<String>) -> Box<dyn Sink> {
match macos::FollowingSink::new(lost) {
Some(sink) => Box::new(sink),
None => backend(None, AudioFormat::default()),
}
}

/// PulseAudio follows the default output by itself.
#[cfg(not(target_os = "macos"))]
fn open_output(backend: SinkBuilder, _lost: UnboundedSender<String>) -> Box<dyn Sink> {
backend(None, AudioFormat::default())
}
Loading
Loading