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 {