@@ -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 ;
1618use librespot_connect:: { ConnectConfig , Spirc } ;
19+ #[ cfg( feature = "dbus_mpris" ) ]
20+ use librespot_core:: SpotifyUri ;
1721use librespot_core:: {
1822 Error , SessionConfig , authentication:: Credentials , cache:: Cache , config:: DeviceType ,
1923 session:: Session ,
2024} ;
2125use 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 ;
2230use 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 ;
2838use log:: { error, info} ;
2939use std:: pin:: Pin ;
3040use 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" ) ) ]
3352type 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+
102267impl 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 {
0 commit comments