diff --git a/src/config.rs b/src/config.rs index 5ce23f30..ecf8bd41 100644 --- a/src/config.rs +++ b/src/config.rs @@ -232,6 +232,16 @@ pub struct CliConfig { #[arg(long)] pub no_daemon: bool, + /// Enable spectator mode (listen only, no playback control) + #[arg( + long, + default_missing_value("true"), + require_equals = true, + num_args(0..=1), + value_name = "BOOL" + )] + pub spectator: Option, + /// Path to PID file. #[cfg(unix)] #[arg(long, value_name = "PATH")] @@ -635,6 +645,7 @@ pub(crate) struct SpotifydConfig { pub(crate) discovery: bool, pub(crate) zeroconf_port: Option, pub(crate) device_type: LSDeviceType, + pub(crate) spectator: bool, #[cfg(feature = "dbus_mpris")] pub(crate) mpris: MprisConfig, #[cfg(feature = "alsa_backend")] @@ -758,6 +769,7 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig { discovery: !config.shared_config.disable_discovery.unwrap_or(false), zeroconf_port: config.shared_config.zeroconf_port, device_type, + spectator: config.spectator.unwrap_or(false), #[cfg(unix)] pid, #[cfg(feature = "dbus_mpris")] diff --git a/src/dbus_mpris.rs b/src/dbus_mpris.rs index 37679baa..1661156f 100644 --- a/src/dbus_mpris.rs +++ b/src/dbus_mpris.rs @@ -502,27 +502,51 @@ async fn create_dbus_server( let event = event.expect("event channel was unexpectedly closed"); if let PlayerEvent::SessionConnected { connection_id, .. } = event { - let mut cr = crossroads.lock().await; - let seeked_fn = register_player_interface( - &mut cr, - spirc.clone().unwrap(), - session.clone().unwrap(), - current_state.clone(), - quit_tx.clone(), - ); - if cur_conn.is_none() { + if let Some(current) = cur_conn.as_mut() { + if current.conn_id.is_empty() { + current.conn_id = connection_id; + } + } else if let (Some(spirc), Some(session)) = (spirc.clone(), session.clone()) { + let mut cr = crossroads.lock().await; + let seeked_fn = register_player_interface( + &mut cr, + spirc, + session, + current_state.clone(), + quit_tx.clone(), + ); conn.request_name(&mpris_name, true, true, true).await?; + cur_conn = Some(ConnectionData { conn_id: connection_id, seeked_fn }); } - cur_conn = Some(ConnectionData { conn_id: connection_id, seeked_fn }); } else if let PlayerEvent::SessionDisconnected { connection_id, .. } = event { - // if this message isn't outdated yet, we vanish from the bus - if cur_conn.as_ref().is_some_and(|d| d.conn_id == connection_id) { + if cur_conn + .as_ref() + .is_some_and(|d| d.conn_id.is_empty() || d.conn_id == connection_id) + { let mut cr = crossroads.lock().await; conn.release_name(&mpris_name).await?; cr.remove::<()>(&MPRIS_PATH.into()); cur_conn = None; } } else { + if cur_conn.is_none() + && let (Some(spirc), Some(session)) = (spirc.clone(), session.clone()) + { + let mut cr = crossroads.lock().await; + let seeked_fn = register_player_interface( + &mut cr, + spirc, + session, + current_state.clone(), + quit_tx.clone(), + ); + conn.request_name(&mpris_name, true, true, true).await?; + cur_conn = Some(ConnectionData { + conn_id: String::new(), + seeked_fn, + }); + } + let (changed, seeked) = current_state .write() .expect("state has been poisoned") @@ -572,8 +596,10 @@ async fn create_dbus_server( } ControlMessage::DropSession => { let mut cr = crossroads.lock().await; - conn.release_name(&mpris_name).await?; - cr.remove::<()>(&MPRIS_PATH.into()); + if cur_conn.is_some() { + conn.release_name(&mpris_name).await?; + cr.remove::<()>(&MPRIS_PATH.into()); + } cr.remove::<()>(&CONTROLS_PATH.into()); spirc = None; session = None; @@ -583,7 +609,9 @@ async fn create_dbus_server( } } } - conn.release_name(&mpris_name).await?; + if cur_conn.is_some() { + conn.release_name(&mpris_name).await?; + } conn.release_name(&spotifyd_name).await?; Ok(()) } diff --git a/src/main_loop.rs b/src/main_loop.rs index 69832fde..b762c026 100644 --- a/src/main_loop.rs +++ b/src/main_loop.rs @@ -13,22 +13,27 @@ use futures::{ future::{self, Fuse, FusedFuture}, stream::Peekable, }; -use librespot_connect::{ConnectConfig, Spirc}; +use librespot_connect::{ClusterUpdateInfo, ConnectConfig, Spirc}; use librespot_core::{ - Error, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType, - session::Session, + Error, SessionConfig, SpotifyUri, authentication::Credentials, cache::Cache, + config::DeviceType, session::Session, }; use librespot_discovery::Discovery; +use librespot_metadata::audio::AudioItem; use librespot_playback::{ audio_backend::Sink, config::{AudioFormat, PlayerConfig}, mixer::Mixer, - player::Player, + player::{Player, PlayerEvent}, }; use log::{error, info}; use std::pin::Pin; use std::sync::Arc; +const SPECTATOR_CONNECTION_ID: &str = "spectator-remote"; +const SPECTATOR_USER_NAME: &str = "spectator"; +const SPECTATOR_PLAY_REQUEST_ID: u64 = 0; + #[cfg(not(feature = "dbus_mpris"))] type DbusServer = Pending<()>; @@ -87,6 +92,7 @@ pub(crate) struct MainLoop { pub(crate) device_name: String, pub(crate) player_event_program: Option, pub(crate) credentials_provider: CredentialsProvider, + pub(crate) spectator: bool, #[cfg(feature = "dbus_mpris")] pub(crate) mpris_config: MprisConfig, } @@ -99,6 +105,12 @@ struct ConnectionInfo> { spirc_task: SpircTask, } +#[cfg(feature = "dbus_mpris")] +async fn fetch_audio_item(session: &Session, track_uri: &str) -> Option> { + let uri = SpotifyUri::from_uri(track_uri).ok()?; + AudioItem::get_file(session, uri).await.ok().map(Box::new) +} + impl MainLoop { async fn get_connection( &mut self, @@ -197,6 +209,8 @@ impl MainLoop { tokio::pin!(spirc_task); let shared_spirc = Arc::new(connection.spirc); + #[cfg(feature = "dbus_mpris")] + let spectator_session = connection.session.clone(); #[cfg(feature = "dbus_mpris")] if let Either::Left(mut dbus_server) = Either::as_pin_mut(dbus_server.as_mut()) @@ -212,6 +226,64 @@ impl MainLoop { let mut running_event_program = Box::pin(Fuse::terminated()); let mut event_channel = connection.player.get_player_event_channel(); + let mut cluster_update_channel = self + .spectator + .then(|| shared_spirc.as_ref().get_cluster_update_channel()); + + let mut last_track_uri: Option = None; + #[cfg(feature = "dbus_mpris")] + let mut spectator_connected = false; + + let emit_spectator_state = |last_track_uri: &mut Option, + track_uri: String, + is_playing: bool, + position_ms: u32, + play_request_id: u64| { + let track_changed = if let Some(ref last_uri) = *last_track_uri { + last_uri != &track_uri && !track_uri.is_empty() + } else { + !track_uri.is_empty() + }; + + if track_changed { + #[cfg(feature = "dbus_mpris")] + { + let session = spectator_session.clone(); + let uri = track_uri.clone(); + let tx_clone = mpris_event_tx.clone(); + tokio::spawn(async move { + if let Some(audio_item) = fetch_audio_item(&session, &uri).await { + if let Some(ref tx) = tx_clone { + let _ = tx.send(PlayerEvent::TrackChanged { audio_item }); + } + } + }); + } + } + + if let Ok(uri) = SpotifyUri::from_uri(&track_uri) { + let event = if is_playing { + PlayerEvent::Playing { + play_request_id, + track_id: uri, + position_ms, + } + } else { + PlayerEvent::Paused { + play_request_id, + track_id: uri, + position_ms, + } + }; + + #[cfg(feature = "dbus_mpris")] + if let Some(ref tx) = mpris_event_tx { + let _ = tx.send(event); + } + } + + *last_track_uri = Some(track_uri); + }; loop { tokio::select!( @@ -243,8 +315,57 @@ impl MainLoop { #[cfg(not(feature = "dbus_mpris"))] result // unused variable } + state = async { + match cluster_update_channel.as_mut() { + Some(channel) => channel.recv().await, + None => future::pending().await + } + }, if self.spectator => { + if let Ok(ClusterUpdateInfo { active_device_id, track_uri, is_playing, position_ms }) = state { + let has_active_remote = !active_device_id.is_empty(); + + #[cfg(feature = "dbus_mpris")] + if let Some(ref tx) = mpris_event_tx { + if has_active_remote && !spectator_connected { + let _ = tx.send(PlayerEvent::SessionConnected { + connection_id: SPECTATOR_CONNECTION_ID.to_string(), + user_name: SPECTATOR_USER_NAME.to_string(), + }); + spectator_connected = true; + } else if !has_active_remote && spectator_connected { + if let Some(ref uri) = last_track_uri + && let Ok(track_id) = SpotifyUri::from_uri(uri) + { + let _ = tx.send(PlayerEvent::Stopped { + play_request_id: SPECTATOR_PLAY_REQUEST_ID, + track_id, + }); + } + let _ = tx.send(PlayerEvent::SessionDisconnected { + connection_id: SPECTATOR_CONNECTION_ID.to_string(), + user_name: SPECTATOR_USER_NAME.to_string(), + }); + spectator_connected = false; + } + } + + if !has_active_remote { + continue; + } + + if let Some(track_uri) = track_uri { + emit_spectator_state( + &mut last_track_uri, + track_uri, + is_playing.unwrap_or(false), + position_ms.unwrap_or(0), + SPECTATOR_PLAY_REQUEST_ID, + ); + } + } + } // a new player event is available and no program is running - event = event_channel.recv(), if running_event_program.is_terminated() => { + event = event_channel.recv(), if running_event_program.is_terminated() && !self.spectator => { let event = event.unwrap(); #[cfg(feature = "dbus_mpris")] if let Some(ref tx) = mpris_event_tx { diff --git a/src/setup.rs b/src/setup.rs index 52d5acff..c34db386 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -160,6 +160,7 @@ pub(crate) fn initial_state( device_type: config.device_type, device_name: config.device_name, player_event_program: config.onevent, + spectator: config.spectator, #[cfg(feature = "dbus_mpris")] mpris_config: config.mpris, })