From 015e57f06fc4468d3a9cdcebb20108d3f8cfcce3 Mon Sep 17 00:00:00 2001 From: Yannick Seeger Date: Thu, 15 Oct 2026 15:14:10 +0200 Subject: [PATCH] feat(macos): follow the system's audio output Playback moves to AirPods or headphones as they connect, and pauses when the output in use disconnects, instead of carrying on through the speakers. librespot's rodio output stays on the device that was the default when it opened. When that device disconnected, its write loop waited forever for a stream that never drained again, and playback stalled. - cloofy-os reports changes of the default output, through a CoreAudio property listener on macOS. - cloofy-player gets a macOS output that reopens on the new default when it changes. If the output it played on is gone, it reports it and plays silence until the pause arrives, so nothing leaks through the speakers. Its waits are bounded, so a dead stream can't block the player anymore. - The TUI and `cloofy play`/`cloofy connect` pause on the report. The TUI says which output went away. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + Cargo.lock | 4 + Cargo.toml | 1 + README.md | 1 + crates/cloofy-os/Cargo.toml | 4 + crates/cloofy-os/src/audio.rs | 19 +++ crates/cloofy-os/src/fallback.rs | 13 ++ crates/cloofy-os/src/lib.rs | 1 + crates/cloofy-os/src/macos/audio.rs | 76 +++++++++ crates/cloofy-os/src/macos/mod.rs | 1 + crates/cloofy-player/Cargo.toml | 5 + crates/cloofy-player/src/lib.rs | 33 +++- crates/cloofy-player/src/macos.rs | 252 ++++++++++++++++++++++++++++ crates/cloofy-tui/src/app/mod.rs | 12 ++ crates/cloofy-tui/src/playback.rs | 14 +- crates/cloofy/src/playback.rs | 13 +- 16 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 crates/cloofy-os/src/audio.rs create mode 100644 crates/cloofy-os/src/macos/audio.rs create mode 100644 crates/cloofy-player/src/macos.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c226ed5..57cc1e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 47a83fa..8d1f33f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,9 +614,11 @@ dependencies = [ "dispatch2", "objc2", "objc2-app-kit", + "objc2-core-audio", "objc2-core-foundation", "objc2-foundation", "objc2-media-player", + "tracing", ] [[package]] @@ -624,8 +626,10 @@ name = "cloofy-player" version = "0.1.0" dependencies = [ "anyhow", + "cloofy-os", "cloofy-spotify", "librespot", + "rodio", "tokio", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index fa54f2d..0e5fa09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 7936a43..c9d91ce 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/cloofy-os/Cargo.toml b/crates/cloofy-os/Cargo.toml index d981b87..fb7469b 100644 --- a/crates/cloofy-os/Cargo.toml +++ b/crates/cloofy-os/Cargo.toml @@ -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 = [ diff --git a/crates/cloofy-os/src/audio.rs b/crates/cloofy-os/src/audio.rs new file mode 100644 index 0000000..530be6b --- /dev/null +++ b/crates/cloofy-os/src/audio.rs @@ -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 { + os::audio::OutputWatch::new(Arc::new(on_change)).map(|watch| Self { _watch: watch }) + } +} diff --git a/crates/cloofy-os/src/fallback.rs b/crates/cloofy-os/src/fallback.rs index 10ef7f8..9172050 100644 --- a/crates/cloofy-os/src/fallback.rs +++ b/crates/cloofy-os/src/fallback.rs @@ -4,6 +4,19 @@ pub fn run_main(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) -> Option { + None + } + } +} + pub mod media { use std::sync::Arc; diff --git a/crates/cloofy-os/src/lib.rs b/crates/cloofy-os/src/lib.rs index 899e3c7..c1f2ee4 100644 --- a/crates/cloofy-os/src/lib.rs +++ b/crates/cloofy-os/src/lib.rs @@ -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")] diff --git a/crates/cloofy-os/src/macos/audio.rs b/crates/cloofy-os/src/macos/audio.rs new file mode 100644 index 0000000..c5889a6 --- /dev/null +++ b/crates/cloofy-os/src/macos/audio.rs @@ -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; + +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, +} + +// 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 { + 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, + 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(); + 0 +} diff --git a/crates/cloofy-os/src/macos/mod.rs b/crates/cloofy-os/src/macos/mod.rs index 9a5ec6b..e6f454e 100644 --- a/crates/cloofy-os/src/macos/mod.rs +++ b/crates/cloofy-os/src/macos/mod.rs @@ -1,3 +1,4 @@ +pub mod audio; pub mod media; use std::{panic, thread}; diff --git a/crates/cloofy-player/Cargo.toml b/crates/cloofy-player/Cargo.toml index 7e4431a..ddb5f1e 100644 --- a/crates/cloofy-player/Cargo.toml +++ b/crates/cloofy-player/Cargo.toml @@ -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"] } diff --git a/crates/cloofy-player/src/lib.rs b/crates/cloofy-player/src/lib.rs index 85a241b..3c03696 100644 --- a/crates/cloofy-player/src/lib.rs +++ b/crates/cloofy-player/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(target_os = "macos")] +mod macos; + use std::{sync::Arc, time::Duration}; use anyhow::{Context, Result}; @@ -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")] @@ -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 { - 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)> { + 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, @@ -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) -> Box { + 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) -> Box { + backend(None, AudioFormat::default()) +} diff --git a/crates/cloofy-player/src/macos.rs b/crates/cloofy-player/src/macos.rs new file mode 100644 index 0000000..d5a3ebd --- /dev/null +++ b/crates/cloofy-player/src/macos.rs @@ -0,0 +1,252 @@ +//! Audio output on macOS that follows the system's default output, like Apple's own apps: +//! playback moves over when AirPods or headphones connect or another output gets picked, and +//! pauses when the output in use goes away instead of carrying on through the speakers. + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use cloofy_os::audio::OutputWatch; +use librespot::playback::{ + NUM_CHANNELS, SAMPLE_RATE, + audio_backend::{Sink, SinkError, SinkResult}, + convert::Converter, + decoder::AudioPacket, +}; +use rodio::{ + buffer::SamplesBuffer, + cpal::{ + self, SampleFormat, StreamConfig, + traits::{DeviceTrait, HostTrait}, + }, +}; +use tokio::sync::mpsc::UnboundedSender; + +/// Audio queued ahead, about half a second, like librespot's own rodio output. +const QUEUED_CHUNKS: usize = 26; +/// How long stopping may wait for queued audio to play out. +const DRAIN_LIMIT: Duration = Duration::from_secs(1); +/// After the output in use went away, audio stays silent until the player stops, or this +/// long in case nothing pauses it. +const SILENT_AFTER_LOSS: Duration = Duration::from_secs(3); +const POLL: Duration = Duration::from_millis(10); + +pub struct FollowingSink { + host: cpal::Host, + output: Option, + /// Set when the default output changed or the stream failed; checked before each write. + stale: Arc, + _watch: OutputWatch, + silent_until: Option, + lost: UnboundedSender, +} + +struct Output { + name: String, + sink: rodio::Sink, + _stream: rodio::OutputStream, +} + +impl FollowingSink { + /// Names of outputs that go away during playback are sent to `lost`. `None` when changes + /// of the default output can't be watched. + pub fn new(lost: UnboundedSender) -> Option { + let stale = Arc::new(AtomicBool::new(false)); + let watch = OutputWatch::new({ + let stale = stale.clone(); + move || stale.store(true, Ordering::Relaxed) + })?; + let host = cpal::default_host(); + let output = open(&host, &stale); + Some(Self { host, output, stale, _watch: watch, silent_until: None, lost }) + } + + /// Moves to the default output when it changed or the stream failed. While playing, an + /// output that went away gets reported, so playback pauses instead of moving on. + fn follow(&mut self, playing: bool) { + if !self.stale.swap(false, Ordering::Relaxed) { + return; + } + if let Some(previous) = self.output.take() + && playing + && !self.is_present(&previous.name) + { + tracing::info!("audio output {} went away", previous.name); + let _ = self.lost.send(previous.name); + self.silent_until = Some(Instant::now() + SILENT_AFTER_LOSS); + } + self.output = open(&self.host, &self.stale); + } + + fn is_present(&self, name: &str) -> bool { + match self.host.output_devices() { + Ok(mut devices) => devices.any(|device| device.name().is_ok_and(|n| n == name)), + // Can't tell; better not to pause for nothing. + Err(_) => true, + } + } + + fn is_silent(&self) -> bool { + self.silent_until.is_some_and(|until| Instant::now() < until) + } +} + +impl Sink for FollowingSink { + fn start(&mut self) -> SinkResult<()> { + self.silent_until = None; + if self.output.is_none() { + self.stale.store(true, Ordering::Relaxed); + } + // Starting again was asked for, so it plays wherever the default output is now. + self.follow(false); + if let Some(output) = &self.output { + output.sink.play(); + } + Ok(()) + } + + fn stop(&mut self) -> SinkResult<()> { + self.silent_until = None; + if let Some(output) = &self.output { + // Lets the queued audio play out, unless the output changed in the meantime. + let deadline = Instant::now() + DRAIN_LIMIT; + while !output.sink.empty() && !self.stale.load(Ordering::Relaxed) && Instant::now() < deadline { + thread::sleep(POLL); + } + output.sink.pause(); + } + Ok(()) + } + + fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> { + self.follow(true); + let samples = packet.samples().map_err(|err| SinkError::OnWrite(err.to_string()))?; + let mut samples = converter.f64_to_f32(samples); + if self.is_silent() { + samples.fill(0.0); + } + let Some(output) = &self.output else { + // Nowhere to play: keep the pace, so playback doesn't race ahead. + let frames = samples.len() as f64 / f64::from(NUM_CHANNELS); + thread::sleep(Duration::from_secs_f64(frames / f64::from(SAMPLE_RATE))); + return Ok(()); + }; + output.sink.append(SamplesBuffer::new(NUM_CHANNELS.into(), SAMPLE_RATE, samples)); + // A stream that stopped never drains; its replacement comes with the next write. + while output.sink.len() > QUEUED_CHUNKS && !self.stale.load(Ordering::Relaxed) { + thread::sleep(POLL); + } + Ok(()) + } +} + +fn open(host: &cpal::Host, stale: &Arc) -> Option { + match open_default(host, stale) { + Ok(output) => { + tracing::info!("audio output: {}", output.name); + Some(output) + } + Err(err) => { + tracing::warn!("no audio output: {err:#}"); + None + } + } +} + +fn open_default(host: &cpal::Host, stale: &Arc) -> Result { + let device = host.default_output_device().context("No default audio output")?; + let name = device.name().unwrap_or_else(|_| "unnamed output".into()); + let config = stereo_config(&device)?; + let stale = stale.clone(); + let mut stream = rodio::OutputStreamBuilder::default() + .with_device(device) + .with_config(&config) + .with_sample_format(SampleFormat::F32) + .with_error_callback(move |err| { + tracing::warn!("audio output: {err}"); + stale.store(true, Ordering::Relaxed); + }) + .open_stream_or_fallback()?; + stream.log_on_drop(false); + let sink = rodio::Sink::connect_new(stream.mixer()); + Ok(Output { name, sink, _stream: stream }) +} + +/// Stereo at Spotify's 44.1 kHz where the device has it, else at the device's own rate. +fn stereo_config(device: &cpal::Device) -> Result { + let default = device.default_output_config()?; + let config = device + .supported_output_configs()? + .find(|c| c.channels() == u16::from(NUM_CHANNELS)) + .and_then(|c| { + c.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)) + .or_else(|| c.try_with_sample_rate(default.sample_rate())) + }) + .unwrap_or(default); + Ok(config.config()) +} + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc::{self, UnboundedReceiver}; + + use super::*; + + fn silence() -> AudioPacket { + AudioPacket::Samples(vec![0.0; 2048]) + } + + /// A playing sink, or `None` on machines without an audio output. + fn playing() -> Option<(FollowingSink, UnboundedReceiver)> { + let (tx, rx) = mpsc::unbounded_channel(); + let mut sink = FollowingSink::new(tx).expect("macOS reports changes of the default output"); + sink.output.as_ref()?; + sink.start().unwrap(); + Some((sink, rx)) + } + + /// Makes the output in use look like headphones that were just unplugged. + fn unplug(sink: &mut FollowingSink) { + sink.output.as_mut().unwrap().name = "Headphones that went away".into(); + sink.stale.store(true, Ordering::Relaxed); + } + + #[test] + fn pauses_for_an_output_that_went_away() { + let Some((mut sink, mut lost)) = playing() else { return }; + let mut converter = Converter::new(None); + unplug(&mut sink); + sink.write(silence(), &mut converter).unwrap(); + assert_eq!(lost.try_recv().as_deref(), Ok("Headphones that went away")); + assert!(sink.is_silent(), "nothing plays through the new output until the pause"); + assert!(sink.output.is_some(), "the new default output is open"); + sink.stop().unwrap(); + assert!(!sink.is_silent()); + } + + #[test] + fn moves_on_when_another_output_was_picked() { + let Some((mut sink, mut lost)) = playing() else { return }; + let mut converter = Converter::new(None); + sink.stale.store(true, Ordering::Relaxed); + sink.write(silence(), &mut converter).unwrap(); + assert!(lost.try_recv().is_err()); + assert!(!sink.is_silent()); + } + + #[test] + fn plays_on_after_an_output_went_away_while_paused() { + let Some((mut sink, mut lost)) = playing() else { return }; + sink.stop().unwrap(); + unplug(&mut sink); + sink.start().unwrap(); + assert!(lost.try_recv().is_err(), "starting again was asked for"); + assert!(!sink.is_silent()); + } +} diff --git a/crates/cloofy-tui/src/app/mod.rs b/crates/cloofy-tui/src/app/mod.rs index c0bd351..3e606b1 100644 --- a/crates/cloofy-tui/src/app/mod.rs +++ b/crates/cloofy-tui/src/app/mod.rs @@ -67,6 +67,8 @@ pub enum AppEvent { PlayerFailed(String), /// A media key, headphone button or Control Center. Media(cloofy_os::media::Command), + /// The audio output in use went away, named. + OutputLost(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -478,10 +480,20 @@ impl App { } AppEvent::PlayerFailed(err) => self.player_error = Some(err), AppEvent::Media(command) => self.on_media(command), + AppEvent::OutputLost(name) => self.on_output_lost(name), AppEvent::Data(data) => self.on_data(data), } } + /// Unplugged headphones pause playback rather than move it to the speakers. + fn on_output_lost(&mut self, name: String) { + if self.remote_active || !self.now.playing { + return; + } + self.control(|c| c.pause()); + self.notice(format!("Paused: {name} disconnected")); + } + fn on_media(&mut self, command: cloofy_os::media::Command) { use cloofy_os::media::Command as Media; match command { diff --git a/crates/cloofy-tui/src/playback.rs b/crates/cloofy-tui/src/playback.rs index 822ec3c..edd72d9 100644 --- a/crates/cloofy-tui/src/playback.rs +++ b/crates/cloofy-tui/src/playback.rs @@ -265,6 +265,10 @@ impl Controller { Ok(()) } + pub fn pause(&self) -> Result<()> { + Ok(self.spirc.pause()?) + } + pub fn next(&self) -> Result<()> { Ok(self.spirc.next()?) } @@ -306,7 +310,7 @@ pub fn spawn(paths: Paths, crossfade: Duration, tx: UnboundedSender) { let result = async { let pending = session::prepare(&paths)?; let session = pending.session.clone(); - let engine = Engine::new(&session, crossfade)?; + let (engine, mut lost_outputs) = Engine::new(&session, crossfade)?; let mut events = engine.player.get_player_event_channel(); let (spirc, task) = engine.connect_device(pending, DEVICE_NAME.into()).await?; tokio::spawn(task); @@ -319,6 +323,14 @@ pub fn spawn(paths: Paths, crossfade: Duration, tx: UnboundedSender) { } } }); + let output_tx = tx.clone(); + tokio::spawn(async move { + while let Some(name) = lost_outputs.recv().await { + if output_tx.send(AppEvent::OutputLost(name)).is_err() { + break; + } + } + }); anyhow::Ok(Controller { spirc, session, engine }) } .await; diff --git a/crates/cloofy/src/playback.rs b/crates/cloofy/src/playback.rs index 7460f96..0230407 100644 --- a/crates/cloofy/src/playback.rs +++ b/crates/cloofy/src/playback.rs @@ -26,7 +26,7 @@ pub async fn play(paths: &Paths, input: &str, shuffle: bool) -> Result<()> { let uri = parse_uri(input)?; let pending = session::prepare(paths)?; let session = pending.session.clone(); - let engine = Engine::new(&session, Duration::ZERO)?; + let (engine, mut lost_outputs) = Engine::new(&session, Duration::ZERO)?; let mut events = engine.player.get_player_event_channel(); let (spirc, task) = engine.connect_device(pending, DEVICE_NAME.into()).await?; tokio::pin!(task); @@ -57,6 +57,10 @@ pub async fn play(paths: &Paths, input: &str, shuffle: bool) -> Result<()> { tokio::select! { () = &mut task => break, Some(event) = media_events.recv() => media.on_event(event, &spirc), + Some(name) = lost_outputs.recv() => { + spirc.pause()?; + println!("\r\x1b[2K {}", style::dim(&format!("❚❚ paused: {name} disconnected"))); + } Some(event) = events.recv() => { media.on_player(&event); match event { @@ -85,7 +89,7 @@ pub async fn play(paths: &Paths, input: &str, shuffle: bool) -> Result<()> { /// Remote mode: waits until a Spotify app picks cloofy as its playback device. pub async fn connect(paths: &Paths, name: String) -> Result<()> { let pending = session::prepare(paths)?; - let engine = Engine::new(&pending.session, Duration::ZERO)?; + let (engine, mut lost_outputs) = Engine::new(&pending.session, Duration::ZERO)?; let mut events = engine.player.get_player_event_channel(); let (spirc, task) = engine.connect_device(pending, name.clone()).await?; tokio::pin!(task); @@ -102,6 +106,11 @@ pub async fn connect(paths: &Paths, name: String) -> Result<()> { tokio::select! { () = &mut task => break, Some(event) = media_events.recv() => media.on_event(event, &spirc), + Some(name) = lost_outputs.recv() => { + spirc.pause()?; + paused = true; + println!(" {}", style::dim(&format!("❚❚ paused: {name} disconnected"))); + } Some(event) = events.recv() => { media.on_player(&event); match event {