Skip to content

Commit aea1c69

Browse files
committed
add spectator mode
listen-only mode that mirrors mpris session/playback state from whichever remote device is active on the Connect group, without taking over playback
1 parent 8591fe2 commit aea1c69

3 files changed

Lines changed: 253 additions & 1 deletion

File tree

src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,16 @@ pub struct CliConfig {
232232
#[arg(long)]
233233
pub no_daemon: bool,
234234

235+
/// Enable spectator mode (listen only, no playback control)
236+
#[arg(
237+
long,
238+
default_missing_value("true"),
239+
require_equals = true,
240+
num_args(0..=1),
241+
value_name = "BOOL"
242+
)]
243+
pub spectator: Option<bool>,
244+
235245
/// Path to PID file.
236246
#[cfg(unix)]
237247
#[arg(long, value_name = "PATH")]
@@ -635,6 +645,7 @@ pub(crate) struct SpotifydConfig {
635645
pub(crate) discovery: bool,
636646
pub(crate) zeroconf_port: Option<u16>,
637647
pub(crate) device_type: LSDeviceType,
648+
pub(crate) spectator: bool,
638649
#[cfg(feature = "dbus_mpris")]
639650
pub(crate) mpris: MprisConfig,
640651
#[cfg(feature = "alsa_backend")]
@@ -758,6 +769,7 @@ pub(crate) fn get_internal_config(config: CliConfig) -> SpotifydConfig {
758769
discovery: !config.shared_config.disable_discovery.unwrap_or(false),
759770
zeroconf_port: config.shared_config.zeroconf_port,
760771
device_type,
772+
spectator: config.spectator.unwrap_or(false),
761773
#[cfg(unix)]
762774
pid,
763775
#[cfg(feature = "dbus_mpris")]

src/main_loop.rs

Lines changed: 240 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,40 @@ use futures::{
1313
future::{self, Fuse, FusedFuture},
1414
stream::Peekable,
1515
};
16+
#[cfg(feature = "dbus_mpris")]
17+
use librespot_connect::ClusterState;
1618
use librespot_connect::{ConnectConfig, Spirc};
19+
#[cfg(feature = "dbus_mpris")]
20+
use librespot_core::SpotifyUri;
1721
use librespot_core::{
1822
Error, SessionConfig, authentication::Credentials, cache::Cache, config::DeviceType,
1923
session::Session,
2024
};
2125
use librespot_discovery::Discovery;
26+
#[cfg(feature = "dbus_mpris")]
27+
use librespot_metadata::audio::AudioItem;
28+
#[cfg(feature = "dbus_mpris")]
29+
use librespot_playback::player::PlayerEvent;
2230
use librespot_playback::{
2331
audio_backend::Sink,
2432
config::{AudioFormat, PlayerConfig},
2533
mixer::Mixer,
2634
player::Player,
2735
};
36+
#[cfg(feature = "dbus_mpris")]
37+
use librespot_protocol::player::PlayerState;
2838
use log::{error, info};
2939
use std::pin::Pin;
3040
use std::sync::Arc;
41+
#[cfg(feature = "dbus_mpris")]
42+
use tokio::sync::{mpsc::UnboundedSender, watch};
43+
44+
#[cfg(feature = "dbus_mpris")]
45+
const SPECTATOR_CONNECTION_ID: &str = "spectator-remote";
46+
#[cfg(feature = "dbus_mpris")]
47+
const SPECTATOR_USER_NAME: &str = "spectator";
48+
#[cfg(feature = "dbus_mpris")]
49+
const SPECTATOR_PLAY_REQUEST_ID: u64 = 0;
3150

3251
#[cfg(not(feature = "dbus_mpris"))]
3352
type DbusServer = Pending<()>;
@@ -87,6 +106,7 @@ pub(crate) struct MainLoop {
87106
pub(crate) device_name: String,
88107
pub(crate) player_event_program: Option<String>,
89108
pub(crate) credentials_provider: CredentialsProvider,
109+
pub(crate) spectator: bool,
90110
#[cfg(feature = "dbus_mpris")]
91111
pub(crate) mpris_config: MprisConfig,
92112
}
@@ -99,6 +119,151 @@ struct ConnectionInfo<SpircTask: Future<Output = ()>> {
99119
spirc_task: SpircTask,
100120
}
101121

122+
#[cfg(feature = "dbus_mpris")]
123+
async fn fetch_audio_item(session: &Session, track_uri: &str) -> Option<Box<AudioItem>> {
124+
let uri = SpotifyUri::from_uri(track_uri).ok()?;
125+
AudioItem::get_file(session, uri).await.ok().map(Box::new)
126+
}
127+
128+
/// Emits mpris session connect/disconnect on active-device transitions in the cluster snapshot.
129+
#[cfg(feature = "dbus_mpris")]
130+
fn sync_cluster_state(
131+
watch: &mut watch::Receiver<ClusterState>,
132+
has_active_remote: &mut bool,
133+
last_track_uri: &mut Option<String>,
134+
mpris_event_tx: &Option<UnboundedSender<PlayerEvent>>,
135+
) {
136+
let now_active = watch.borrow_and_update().active_device_id.is_some();
137+
138+
if now_active && !*has_active_remote {
139+
*has_active_remote = true;
140+
if let Some(tx) = mpris_event_tx {
141+
let _ = tx.send(PlayerEvent::SessionConnected {
142+
connection_id: SPECTATOR_CONNECTION_ID.to_string(),
143+
user_name: SPECTATOR_USER_NAME.to_string(),
144+
});
145+
}
146+
} else if !now_active && *has_active_remote {
147+
*has_active_remote = false;
148+
if let Some(tx) = mpris_event_tx {
149+
if let Some(uri) = last_track_uri.as_deref()
150+
&& let Ok(track_id) = SpotifyUri::from_uri(uri)
151+
{
152+
let _ = tx.send(PlayerEvent::Stopped {
153+
play_request_id: SPECTATOR_PLAY_REQUEST_ID,
154+
track_id,
155+
});
156+
}
157+
let _ = tx.send(PlayerEvent::SessionDisconnected {
158+
connection_id: SPECTATOR_CONNECTION_ID.to_string(),
159+
user_name: SPECTATOR_USER_NAME.to_string(),
160+
});
161+
}
162+
*last_track_uri = None;
163+
}
164+
}
165+
166+
/// Mirrors the current player snapshot's track/play state into mpris.
167+
#[cfg(feature = "dbus_mpris")]
168+
fn sync_player_state(
169+
watch: &mut watch::Receiver<Option<PlayerState>>,
170+
last_track_uri: &mut Option<String>,
171+
spectator_session: &Session,
172+
mpris_event_tx: &Option<UnboundedSender<PlayerEvent>>,
173+
) {
174+
let Some(state) = watch.borrow_and_update().clone() else {
175+
return;
176+
};
177+
let Some(track_uri) = state
178+
.track
179+
.as_ref()
180+
.map(|t| t.uri.clone())
181+
.filter(|uri| !uri.is_empty())
182+
else {
183+
return;
184+
};
185+
let is_playing = state.is_playing;
186+
let is_paused = state.is_paused;
187+
let position_ms = extrapolated_position_ms(
188+
state.position_as_of_timestamp,
189+
state.timestamp,
190+
is_playing && !is_paused,
191+
chrono::Utc::now().timestamp_millis(),
192+
);
193+
194+
if last_track_uri.as_deref() != Some(track_uri.as_str()) {
195+
let session = spectator_session.clone();
196+
let uri = track_uri.clone();
197+
let tx_clone = mpris_event_tx.clone();
198+
tokio::spawn(async move {
199+
if let Some(audio_item) = fetch_audio_item(&session, &uri).await
200+
&& let Some(ref tx) = tx_clone
201+
{
202+
let _ = tx.send(PlayerEvent::TrackChanged { audio_item });
203+
}
204+
});
205+
*last_track_uri = Some(track_uri.clone());
206+
}
207+
208+
if let Ok(track_id) = SpotifyUri::from_uri(&track_uri) {
209+
let event = if is_playing && !is_paused {
210+
PlayerEvent::Playing {
211+
play_request_id: SPECTATOR_PLAY_REQUEST_ID,
212+
track_id,
213+
position_ms,
214+
}
215+
} else {
216+
PlayerEvent::Paused {
217+
play_request_id: SPECTATOR_PLAY_REQUEST_ID,
218+
track_id,
219+
position_ms,
220+
}
221+
};
222+
if let Some(tx) = mpris_event_tx {
223+
let _ = tx.send(event);
224+
}
225+
}
226+
}
227+
228+
/// `position_as_of_timestamp` is only accurate as of `timestamp`, so extrapolate forward.
229+
#[cfg(feature = "dbus_mpris")]
230+
fn extrapolated_position_ms(
231+
position_as_of_timestamp: i64,
232+
timestamp: i64,
233+
is_playing: bool,
234+
now_ms: i64,
235+
) -> u32 {
236+
let elapsed_ms = if is_playing {
237+
(now_ms - timestamp).max(0)
238+
} else {
239+
0
240+
};
241+
(position_as_of_timestamp + elapsed_ms).max(0) as u32
242+
}
243+
244+
#[cfg(all(test, feature = "dbus_mpris"))]
245+
mod tests {
246+
use super::extrapolated_position_ms;
247+
248+
#[test]
249+
fn playing_extrapolates_forward_from_stale_timestamp() {
250+
assert_eq!(extrapolated_position_ms(10_000, 1_000, true, 4_000), 13_000);
251+
}
252+
253+
#[test]
254+
fn paused_does_not_extrapolate() {
255+
assert_eq!(
256+
extrapolated_position_ms(10_000, 1_000, false, 4_000),
257+
10_000
258+
);
259+
}
260+
261+
#[test]
262+
fn negative_diff_clamps_to_zero_elapsed() {
263+
assert_eq!(extrapolated_position_ms(10_000, 4_000, true, 1_000), 10_000);
264+
}
265+
}
266+
102267
impl MainLoop {
103268
async fn get_connection(
104269
&mut self,
@@ -162,6 +327,7 @@ impl MainLoop {
162327
}
163328

164329
pub(crate) async fn run(mut self) -> eyre::Result<()> {
330+
info!("spectator mode: {}", self.spectator);
165331
tokio::pin! {
166332
let ctrl_c = tokio::signal::ctrl_c();
167333
// we don't necessarily have a dbus server
@@ -197,6 +363,8 @@ impl MainLoop {
197363
tokio::pin!(spirc_task);
198364

199365
let shared_spirc = Arc::new(connection.spirc);
366+
#[cfg(feature = "dbus_mpris")]
367+
let spectator_session = connection.session.clone();
200368

201369
#[cfg(feature = "dbus_mpris")]
202370
if let Either::Left(mut dbus_server) = Either::as_pin_mut(dbus_server.as_mut())
@@ -213,6 +381,32 @@ impl MainLoop {
213381

214382
let mut event_channel = connection.player.get_player_event_channel();
215383

384+
let mut cluster_state_watch =
385+
self.spectator.then(|| shared_spirc.watch_cluster_state());
386+
let mut player_state_watch = self.spectator.then(|| shared_spirc.watch_player_state());
387+
let mut last_track_uri: Option<String> = None;
388+
let mut has_active_remote = false;
389+
390+
// a fresh watch::Receiver doesn't treat its current value as a "change", so seed
391+
// once upfront in case a remote is already active
392+
#[cfg(feature = "dbus_mpris")]
393+
if let Some(watch) = cluster_state_watch.as_mut() {
394+
sync_cluster_state(
395+
watch,
396+
&mut has_active_remote,
397+
&mut last_track_uri,
398+
&mpris_event_tx,
399+
);
400+
if has_active_remote && let Some(watch) = player_state_watch.as_mut() {
401+
sync_player_state(
402+
watch,
403+
&mut last_track_uri,
404+
&spectator_session,
405+
&mpris_event_tx,
406+
);
407+
}
408+
}
409+
216410
loop {
217411
tokio::select!(
218412
// a new session has been started via the discovery stream
@@ -243,8 +437,53 @@ impl MainLoop {
243437
#[cfg(not(feature = "dbus_mpris"))]
244438
result // unused variable
245439
}
440+
// a cluster snapshot changed: track whether some remote device is active
441+
cluster_changed = async {
442+
match cluster_state_watch.as_mut() {
443+
Some(watch) => watch.changed().await,
444+
None => future::pending().await,
445+
}
446+
}, if self.spectator => {
447+
#[cfg(feature = "dbus_mpris")]
448+
if cluster_changed.is_ok() {
449+
sync_cluster_state(
450+
cluster_state_watch.as_mut().unwrap(),
451+
&mut has_active_remote,
452+
&mut last_track_uri,
453+
&mpris_event_tx,
454+
);
455+
// player watch may not fire its own changed() here, so sync it too
456+
if has_active_remote
457+
&& let Some(watch) = player_state_watch.as_mut()
458+
{
459+
sync_player_state(
460+
watch,
461+
&mut last_track_uri,
462+
&spectator_session,
463+
&mpris_event_tx,
464+
);
465+
}
466+
}
467+
}
468+
// the remote player snapshot changed: mirror track/play state into mpris
469+
player_changed = async {
470+
match player_state_watch.as_mut() {
471+
Some(watch) => watch.changed().await,
472+
None => future::pending().await,
473+
}
474+
}, if self.spectator && has_active_remote => {
475+
#[cfg(feature = "dbus_mpris")]
476+
if player_changed.is_ok() {
477+
sync_player_state(
478+
player_state_watch.as_mut().unwrap(),
479+
&mut last_track_uri,
480+
&spectator_session,
481+
&mpris_event_tx,
482+
);
483+
}
484+
}
246485
// a new player event is available and no program is running
247-
event = event_channel.recv(), if running_event_program.is_terminated() => {
486+
event = event_channel.recv(), if running_event_program.is_terminated() && !self.spectator => {
248487
let event = event.unwrap();
249488
#[cfg(feature = "dbus_mpris")]
250489
if let Some(ref tx) = mpris_event_tx {

src/setup.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ pub(crate) fn initial_state(
160160
device_type: config.device_type,
161161
device_name: config.device_name,
162162
player_event_program: config.onevent,
163+
spectator: config.spectator,
163164
#[cfg(feature = "dbus_mpris")]
164165
mpris_config: config.mpris,
165166
})

0 commit comments

Comments
 (0)