From 3ec43ad020b9045d1d3dceece7e58c0131b7a3a7 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 11:33:11 +0200 Subject: [PATCH 01/29] fix: fix clippy needless-borrow on discovery lib --- discovery/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discovery/src/lib.rs b/discovery/src/lib.rs index e440c67f..021473c9 100644 --- a/discovery/src/lib.rs +++ b/discovery/src/lib.rs @@ -406,7 +406,7 @@ fn launch_libmdns( } .map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?; - let svc = responder.register(&DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD); + let svc = responder.register(DNS_SD_SERVICE_NAME, &name, port, &TXT_RECORD); let _ = shutdown_rx.blocking_recv(); From 58e8a8ee6b12f48ba7b2b9d62601b28d4a49d635 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:17:57 +0200 Subject: [PATCH 02/29] Allow cloning SPIRC - which is just a tokio::sync::mpsc sender, so this should be safe - prep for MPRIS support, which will use this to control playback --- connect/src/spirc.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 43702d8a..874b3fd0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -139,6 +139,7 @@ const VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500); const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200); /// The spotify connect handle +#[derive(Clone)] pub struct Spirc { commands: mpsc::UnboundedSender, } From 64995ab785f42984906ddd17bc079af247f7c3ee Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:19:26 +0200 Subject: [PATCH 03/29] add release date to AudioItem - preparation for MPRIS support - now that the data is there, also yield from player_event_handler --- metadata/src/audio/item.rs | 4 ++++ src/player_event_handler.rs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index 3df63d9e..b14c4cda 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -45,6 +45,7 @@ pub enum UniqueFields { Track { artists: ArtistsWithRole, album: String, + album_date: Date, album_artists: Vec, popularity: u8, number: u32, @@ -80,6 +81,8 @@ impl AudioItem { let uri_string = uri.to_uri()?; let album = track.album.name; + let album_date = track.album.date; + let album_artists = track .album .artists @@ -113,6 +116,7 @@ impl AudioItem { let unique_fields = UniqueFields::Track { artists: track.artists_with_role, album, + album_date, album_artists, popularity, number, diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 51495932..22f237e3 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -61,6 +61,7 @@ impl EventHandler { UniqueFields::Track { artists, album, + album_date, album_artists, popularity, number, @@ -81,6 +82,10 @@ impl EventHandler { album_artists.join("\n"), ); env_vars.insert("ALBUM", album); + env_vars.insert( + "ALBUM_DATE", + album_date.unix_timestamp().to_string(), + ); env_vars .insert("POPULARITY", popularity.to_string()); env_vars.insert("NUMBER", number.to_string()); From feb2dc6b1ba1da6c63816a19b265bb6cbe4be069 Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:27:47 +0200 Subject: [PATCH 04/29] add Spirc.seek_offset command - preparation for MPRIS support --- connect/src/spirc.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 874b3fd0..a4145c10 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -126,6 +126,7 @@ enum SpircCommand { RepeatTrack(bool), Disconnect { pause: bool }, SetPosition(u32), + SeekOffset(i32), SetVolume(u16), Activate, Load(LoadRequest), @@ -385,6 +386,13 @@ impl Spirc { Ok(self.commands.send(SpircCommand::Load(command))?) } + /// Seek to given offset. + /// + /// Does nothing if we are not the active device. + pub fn seek_offset(&self, offset_ms: i32) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::SeekOffset(offset_ms))?) + } + /// Disconnects the current device and pauses the playback according the value. /// /// Does nothing if we are not the active device. @@ -651,6 +659,7 @@ impl SpircTask { SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?, SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat), SpircCommand::SetPosition(position) => self.handle_seek(position), + SpircCommand::SeekOffset(offset) => self.handle_seek_offset(offset), SpircCommand::SetVolume(volume) => self.set_volume(volume), SpircCommand::Load(command) => self.handle_load(command, None).await?, }; @@ -1461,6 +1470,25 @@ impl SpircTask { }; } + fn handle_seek_offset(&mut self, offset_ms: i32) { + let position_ms = match self.play_status { + SpircPlayStatus::Stopped => return, + SpircPlayStatus::LoadingPause { position_ms } + | SpircPlayStatus::LoadingPlay { position_ms } + | SpircPlayStatus::Paused { position_ms, .. } => position_ms, + SpircPlayStatus::Playing { + nominal_start_time, .. + } => { + let now = self.now_ms(); + (now - nominal_start_time) as u32 + } + }; + + let position_ms = ((position_ms as i32) + offset_ms).max(0) as u32; + + self.handle_seek(position_ms); + } + fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { self.player.emit_shuffle_changed_event(shuffle); self.connect_state.handle_shuffle(shuffle) From 01ac2e3b609638f9063d038e7d4fa5afca672d1a Mon Sep 17 00:00:00 2001 From: wisp3rwind <17089248+wisp3rwind@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:36:41 +0200 Subject: [PATCH 05/29] add initial MPRIS support using zbus - following the spec at https://specifications.freedesktop.org/mpris-spec/latest/ - some properties/commands are not fully supported, yet --- Cargo.lock | 271 +++++++- Cargo.toml | 9 +- src/main.rs | 20 + src/mpris_event_handler.rs | 1220 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1507 insertions(+), 13 deletions(-) create mode 100644 src/mpris_event_handler.rs diff --git a/Cargo.lock b/Cargo.lock index 0ad27773..37957ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,65 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.0", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -171,6 +230,30 @@ dependencies = [ "syn", ] +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -248,6 +331,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -1210,6 +1306,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1861,8 +1963,11 @@ dependencies = [ "sha1", "sysinfo", "thiserror 2.0.16", + "time", "tokio", "url", + "zbus 4.4.0", + "zvariant 4.2.0", ] [[package]] @@ -1985,7 +2090,7 @@ dependencies = [ "sha1", "thiserror 2.0.16", "tokio", - "zbus", + "zbus 5.11.0", ] [[package]] @@ -2205,6 +2310,19 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2633,6 +2751,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2660,6 +2789,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.0", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4865,6 +5008,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.0" @@ -4889,6 +5042,39 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.11.0" @@ -4903,7 +5089,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", @@ -4912,9 +5098,22 @@ dependencies = [ "uds_windows", "windows-sys 0.60.2", "winnow", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.11.0", + "zbus_names 4.2.0", + "zvariant 5.7.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -4927,9 +5126,20 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.2.0", + "zvariant 5.7.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -4941,7 +5151,7 @@ dependencies = [ "serde", "static_assertions", "winnow", - "zvariant", + "zvariant 5.7.0", ] [[package]] @@ -5024,6 +5234,19 @@ dependencies = [ "syn", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.7.0" @@ -5034,8 +5257,21 @@ dependencies = [ "enumflags2", "serde", "winnow", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.7.0", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", ] [[package]] @@ -5048,7 +5284,18 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils", + "zvariant_utils 3.2.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 63a5927a..897fd035 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ repository = "https://github.com/librespot-org/librespot" edition = "2024" [features] -default = ["native-tls", "rodio-backend", "with-libmdns"] +default = ["native-tls", "rodio-backend", "with-libmdns", "with-mpris"] # TLS backends (mutually exclusive - compile-time checks in oauth/src/lib.rs) # Note: Feature validation is in oauth crate since it's compiled first in the dependency tree. @@ -133,6 +133,10 @@ with-dns-sd = ["librespot-discovery/with-dns-sd"] # data. passthrough-decoder = ["librespot-playback/passthrough-decoder"] +# MPRIS: Allow external tool to have access to playback +# status, metadata and to control the player. +with-mpris = ["dep:zbus", "dep:zvariant"] + [lib] name = "librespot" path = "src/lib.rs" @@ -181,7 +185,10 @@ tokio = { version = "1", features = [ "sync", "process", ] } +time = { version = "0.3", features = ["formatting"] } url = "2.2" +zbus = { version = "4", default-features = false, features = ["tokio"], optional = true } +zvariant = { version = "4", default-features = false, optional = true } [package.metadata.deb] maintainer = "Librespot Organization " diff --git a/src/main.rs b/src/main.rs index b824ea94..3e59e03b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,11 @@ use url::Url; mod player_event_handler; use player_event_handler::{EventHandler, run_program_on_sink_events}; +#[cfg(feature = "with-mpris")] +mod mpris_event_handler; +#[cfg(feature = "with-mpris")] +use mpris_event_handler::MprisEventHandler; + fn device_id(name: &str) -> String { HEXLOWER.encode(&Sha1::digest(name.as_bytes())) } @@ -1991,6 +1996,14 @@ async fn main() { } } + #[cfg(feature = "with-mpris")] + let mpris = MprisEventHandler::spawn(player.clone()) + .await + .unwrap_or_else(|e| { + error!("could not initialize MPRIS: {e}"); + exit(1); + }); + loop { tokio::select! { credentials = async { @@ -2044,6 +2057,10 @@ async fn main() { exit(1); } }; + + #[cfg(feature = "with-mpris")] + mpris.set_spirc(spirc_.clone()); + spirc = Some(spirc_); spirc_task = Some(Box::pin(spirc_task_)); @@ -2089,6 +2106,9 @@ async fn main() { let mut shutdown_tasks = tokio::task::JoinSet::new(); + #[cfg(feature = "with-mpris")] + shutdown_tasks.spawn(mpris.quit_and_join()); + // Shutdown spirc if necessary if let Some(spirc) = spirc { if let Err(e) = spirc.shutdown() { diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs new file mode 100644 index 00000000..61208913 --- /dev/null +++ b/src/mpris_event_handler.rs @@ -0,0 +1,1220 @@ +use std::{collections::HashMap, sync::Arc}; + +use librespot_connect::Spirc; +use log::{debug, warn}; +use thiserror::Error; +use time::format_description::well_known::Iso8601; +use tokio::sync::mpsc; +use zbus::connection; + +use librespot::{ + core::Error, + metadata::audio::UniqueFields, + playback::player::{Player, PlayerEvent}, +}; + +/// A playback state. +#[derive(Clone, Copy, Debug)] +enum PlaybackStatus { + /// A track is currently playing. + Playing, + + /// A track is currently paused. + Paused, + + /// There is no track currently playing. + Stopped, +} + +impl zvariant::Type for PlaybackStatus { + fn signature() -> zvariant::Signature<'static> { + zvariant::Signature::try_from("s").unwrap() + } +} + +impl TryFrom> for PlaybackStatus { + type Error = zvariant::Error; + + fn try_from(value: zvariant::Value<'_>) -> Result { + if let zvariant::Value::Str(s) = value { + match s.as_str() { + "Playing" => Ok(Self::Playing), + "Paused" => Ok(Self::Paused), + "Stopped" => Ok(Self::Stopped), + _ => Err(zvariant::Error::Message("invalid enum value".to_owned())), + } + } else { + Err(zvariant::Error::IncorrectType) + } + } +} + +impl From for zvariant::Value<'_> { + fn from(value: PlaybackStatus) -> Self { + let s = match value { + PlaybackStatus::Playing => "Playing", + PlaybackStatus::Paused => "Paused", + PlaybackStatus::Stopped => "Stopped", + }; + + s.into() + } +} + +/// A repeat / loop status +#[derive(Clone, Copy, Debug)] +enum LoopStatus { + /// The playback will stop when there are no more tracks to play + None, + + /// The current track will start again from the begining once it has finished playing + Track, + + /// The playback loops through a list of tracks + Playlist, +} + +impl zvariant::Type for LoopStatus { + fn signature() -> zvariant::Signature<'static> { + zvariant::Signature::try_from("s").unwrap() + } +} + +impl TryFrom> for LoopStatus { + type Error = zvariant::Error; + + fn try_from(value: zvariant::Value<'_>) -> Result { + if let zvariant::Value::Str(s) = value { + match s.as_str() { + "None" => Ok(Self::None), + "Track" => Ok(Self::Track), + "Playlist" => Ok(Self::Playlist), + _ => Err(zvariant::Error::Message("invalid enum value".to_owned())), + } + } else { + Err(zvariant::Error::IncorrectType) + } + } +} + +impl From for zvariant::Value<'_> { + fn from(value: LoopStatus) -> Self { + let s = match value { + LoopStatus::None => "None", + LoopStatus::Track => "Track", + LoopStatus::Playlist => "Playlist", + }; + + s.into() + } +} + +// /// Unique track identifier. +// /// +// /// If the media player implements the TrackList interface and allows +// /// the same track to appear multiple times in the tracklist, +// /// this must be unique within the scope of the tracklist. +// /// +// /// Note that this should be a valid D-Bus object id, although clients +// /// should not assume that any object is actually exported with any +// /// interfaces at that path. +// /// +// /// Media players may not use any paths starting with +// /// `/org/mpris` unless explicitly allowed by this specification. +// /// Such paths are intended to have special meaning, such as +// /// `/org/mpris/MediaPlayer2/TrackList/NoTrack` +// /// to indicate "no track". +// /// +// /// This is a D-Bus object id as that is the definitive way to have +// /// unique identifiers on D-Bus. It also allows for future optional +// /// expansions to the specification where tracks are exported to D-Bus +// /// with an interface similar to org.gnome.UPnP.MediaItem2. +// type TrackId = ...; + +// A playback rate +// +// This is a multiplier, so a value of 0.5 indicates that playback is +// happening at half speed, while 1.5 means that 1.5 seconds of "track time" +// is consumed every second. +type PlaybackRate = f64; + +// Audio volume level +// +// - 0.0 means mute. +// - 1.0 is a sensible maximum volume level (ex: 0dB). +// +// Note that the volume may be higher than 1.0, although generally +// clients should not attempt to set it above 1.0. +type Volume = f64; + +// Time in microseconds. +type TimeInUs = i64; + +struct MprisService {} + +#[zbus::interface(name = "org.mpris.MediaPlayer2")] +impl MprisService { + // Brings the media player's user interface to the front using any appropriate mechanism + // available. + // + // The media player may be unable to control how its user interface is displayed, or it may not + // have a graphical user interface at all. In this case, the `CanRaise` property is `false` and + // this method does nothing. + async fn raise(&self) { + debug!("org.mpris.MediaPlayer2::Raise"); + } + + // Causes the media player to stop running. + // + // The media player may refuse to allow clients to shut it down. In this case, the `CanQuit` + // property is `false` and this method does nothing. + // + // Note: Media players which can be D-Bus activated, or for which there is no sensibly easy way + // to terminate a running instance (via the main interface or a notification area icon for + // example) should allow clients to use this method. Otherwise, it should not be needed. + // + // If the media player does not have a UI, this should be implemented. + async fn quit(&self) { + debug!("org.mpris.MediaPlayer2::Quit"); + } + + // If `false`, calling `Quit` will have no effect, and may raise a `NotSupported` error. If + // `true`, calling `Quit` will cause the media application to attempt to quit (although it may + // still be prevented from quitting by the user, for example). + #[zbus(property)] + async fn can_quit(&self) -> bool { + debug!("org.mpris.MediaPlayer2::CanQuit"); + false + } + + // Whether the media player is occupying the fullscreen. + // + // This is typically used for videos. A value of `true` indicates that the media player is + // taking up the full screen. + // + // Media centre software may well have this value fixed to `true` + // + // If `CanSetFullscreen` is `true`, clients may set this property to `true` to tell the media + // player to enter fullscreen mode, or to `false` to return to windowed mode. + // + // If `CanSetFullscreen` is `false`, then attempting to set this property should have no + // effect, and may raise an error. However, even if it is `true`, the media player may still + // be unable to fulfil the request, in which case attempting to set this property will have no + // effect (but should not raise an error). + // + // Rationale: + // + // This allows remote control interfaces, such as LIRC or mobile devices like + // phones, to control whether a video is shown in fullscreen. + #[zbus(property)] + async fn fullscreen(&self) -> bool { + debug!("org.mpris.MediaPlayer2::Fullscreen"); + false + } + + #[zbus(property)] + async fn set_fullscreen(&self, _value: bool) { + debug!("org.mpris.MediaPlayer2::SetFullscreen"); + } + + // If `false`, attempting to set `Fullscreen` will have no effect, and may raise an error. If + // `true`, attempting to set `Fullscreen` will not raise an error, and (if it is different from + // the current value) will cause the media player to attempt to enter or exit fullscreen mode. + // + // Note that the media player may be unable to fulfil the request. In this case, the value will + // not change. If the media player knows in advance that it will not be able to fulfil the + // request, however, this property should be `false`. + // + // Rationale: + // + // This allows clients to choose whether to display controls for entering + // or exiting fullscreen mode. + #[zbus(property)] + async fn can_set_fullscreen(&self) -> bool { + debug!("org.mpris.MediaPlayer2::CanSetFullscreen"); + false + } + + // If `false`, calling `Raise` will have no effect, and may raise a NotSupported error. If + // `true`, calling `Raise` will cause the media application to attempt to bring its user + // interface to the front, although it may be prevented from doing so (by the window manager, + // for example). + #[zbus(property)] + async fn can_raise(&self) -> bool { + debug!("org.mpris.MediaPlayer2::CanRaise"); + false + } + + // Indicates whether the `/org/mpris/MediaPlayer2` object implements the + // `org.mpris.MediaPlayer2.TrackList` interface. + #[zbus(property)] + async fn has_track_list(&self) -> bool { + debug!("org.mpris.MediaPlayer2::HasTrackList"); + // TODO: Eventually implement + false + } + + // A friendly name to identify the media player to users. This should usually match the name + // found in .desktop files (eg: "VLC media player"). + #[zbus(property)] + async fn identity(&self) -> String { + debug!("org.mpris.MediaPlayer2::Identity"); + // TOOD: use name from config + "Librespot".to_owned() + } + + // The basename of an installed .desktop file which complies with the + // [Desktop entry specification](http://standards.freedesktop.org/desktop-entry-spec/latest/) + // with the `.desktop` extension stripped. + // + // Example: The desktop entry file is "/usr/share/applications/vlc.desktop", and this property + // contains "vlc" + // + #[zbus(property)] + async fn desktop_entry(&self) -> String { + debug!("org.mpris.MediaPlayer2::DesktopEntry"); + // FIXME: The spec doesn't say anything about the case when there is no .desktop. + // Is there any convention? Any value that common clients handle in a sane way? + "".to_owned() + } + + // The URI schemes supported by the media player. + // + // This can be viewed as protocols supported by the player in almost all cases. Almost every + // media player will include support for the `"file"` scheme. Other common schemes are + // `"http"` and `"rtsp"`. + // + // Note that URI schemes should be lower-case. + // + // Rationale: + // + // This is important for clients to know when using the editing + // capabilities of the Playlist interface, for example. + #[zbus(property)] + async fn supported_uri_schemes(&self) -> Vec { + debug!("org.mpris.MediaPlayer2::SupportedUriSchemes"); + vec![] + } + + // The mime-types supported by the media player. + // + // Mime-types should be in the standard format (eg: `audio/mpeg` or `application/ogg`). + // + // Rationale: + // + // This is important for clients to know when using the editing + // capabilities of the Playlist interface, for example. + #[zbus(property)] + async fn supported_mime_types(&self) -> Vec { + debug!("org.mpris.MediaPlayer2::SupportedMimeTypes"); + vec![] + } +} + +struct MprisPlayerService { + spirc: Option, + repeat: LoopStatus, + shuffle: bool, + playback_status: PlaybackStatus, + volume: f64, + metadata: HashMap, +} + +// This interface implements the methods for querying and providing basic +// control over what is currently playing. +#[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] +impl MprisPlayerService { + /// Skips to the next track in the tracklist. If there is no next track (and endless playback + /// and track repeat are both off), stop playback. + /// + /// If playback is paused or stopped, it remains that way. + /// + /// If self.can_go_next is `false`, attempting to call this method should have no effect. + async fn next(&self) { + if let Some(spirc) = &self.spirc { + let _ = spirc.next(); + } + } + + // Skips to the previous track in the tracklist. + // + // If there is no previous track (and endless playback and track repeat are both off), stop + // playback. + // + // If playback is paused or stopped, it remains that way. + // + // If `self.can_go_previous` is `false`, attempting to call this method should have no effect. + async fn previous(&self) { + if let Some(spirc) = &self.spirc { + let _ = spirc.prev(); + } + } + + // Pauses playback. + // + // If playback is already paused, this has no effect. + // + // Calling Play after this should cause playback to start again from the same position. + // + // If `self.can_pause` is `false`, attempting to call this method should have no effect. + async fn pause(&self) { + // FIXME: This should return an error if can_pause is false + if let Some(spirc) = &self.spirc { + let _ = spirc.pause(); + } + } + + // Pauses playback. + // + // If playback is already paused, resumes playback. + // + // If playback is stopped, starts playback. + // + // If `self.can_pause` is `false`, attempting to call this method should have no effect and + // raise an error. + async fn play_pause(&self) { + // FIXME: This should return an error if can_pause is false + if let Some(spirc) = &self.spirc { + let _ = spirc.play_pause(); + } + } + + // Stops playback. + // + // If playback is already stopped, this has no effect. + // + // Calling Play after this should cause playback to start again from the beginning of the + // track. + // + // If `CanControl` is `false`, attempting to call this method should have no effect and raise + // an error. + async fn stop(&self) { + // FIXME: This should return an error if can_control is false + if let Some(spirc) = &self.spirc { + let _ = spirc.pause(); + let _ = spirc.set_position_ms(0); + } + } + + // Starts or resumes playback. + // + // If already playing, this has no effect. + // + // If paused, playback resumes from the current position. + // + // If there is no track to play, this has no effect. + // + // If `self.can_play` is `false`, attempting to call this method should have no effect. + async fn play(&self) { + if let Some(spirc) = &self.spirc { + let _ = spirc.activate(); + let _ = spirc.play(); + } + } + + // Seeks forward in the current track by the specified number of microseconds. + // + // A negative value seeks back. If this would mean seeking back further than the start of the + // track, the position is set to 0. + // + // If the value passed in would mean seeking beyond the end of the track, acts like a call to + // Next. + // + // If the `self.can_seek` property is `false`, this has no effect. + // + // Arguments: + // + // * `offset`: The number of microseconds to seek forward. + async fn seek(&self, offset: TimeInUs) { + if let Some(spirc) = &self.spirc { + let _ = spirc.seek_offset((offset / 1000) as i32); + } + } + + // Sets the current track position in microseconds. + // + // If the Position argument is less than 0, do nothing. + // + // If the Position argument is greater than the track length, do nothing. + // + // If the `CanSeek` property is `false`, this has no effect. + // + // Rationale: + // + // The reason for having this method, rather than making `self.position` writable, is to + // include the `track_id` argument to avoid race conditions where a client tries to seek to + // a position when the track has already changed. + // + // Arguments: + // + // * `track_id`: The currently playing track's identifier. + // If this does not match the id of the currently-playing track, the call is + // ignored as "stale". + // `/org/mpris/MediaPlayer2/TrackList/NoTrack` is _not_ a valid value for this + // argument. + // * `position`: Track position in microseconds. This must be between 0 and `track_length`. + async fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, position: TimeInUs) { + // FIXME: handle track_id + if position < 0 { + return; + } + if let Some(spirc) = &self.spirc { + let _ = spirc.set_position_ms((position / 1000) as u32); + } + } + + // Opens the Uri given as an argument + // + // If the playback is stopped, starts playing + // + // If the uri scheme or the mime-type of the uri to open is not supported, this method does + // nothing and may raise an error. In particular, if the list of available uri schemes is + // empty, this method may not be implemented. + // + // Clients should not assume that the Uri has been opened as soon as this method returns. They + // should wait until the mpris:trackid field in the `Metadata` property changes. + // + // If the media player implements the TrackList interface, then the opened track should be made + // part of the tracklist, the `org.mpris.MediaPlayer2.TrackList.TrackAdded` or + // `org.mpris.MediaPlayer2.TrackList.TrackListReplaced` signal should be fired, as well as the + // `org.freedesktop.DBus.Properties.PropertiesChanged` signal on the tracklist interface. + // + // Arguments: + // + // * `uri`: Uri of the track to load. Its uri scheme should be an element of the + // `org.mpris.MediaPlayer2.SupportedUriSchemes` property and the mime-type should + // match one of the elements of the `org.mpris.MediaPlayer2.SupportedMimeTypes`. + async fn open_uri(&self, _uri: &str) -> zbus::fdo::Result<()> { + Err(zbus::fdo::Error::NotSupported( + "OpenUri not supported".to_owned(), + )) + } + + // The current playback status. + // + // May be "Playing", "Paused" or "Stopped". + #[zbus(property(emits_changed_signal = "true"))] + async fn playback_status(&self) -> PlaybackStatus { + self.playback_status + } + + // The current loop / repeat status + // + // May be: + // - "None" if the playback will stop when there are no more tracks to play + // - "Track" if the current track will start again from the begining once it has finished playing + // - "Playlist" if the playback loops through a list of tracks + // + // If `self.can_control` is `false`, attempting to set this property should have no effect and + // raise an error. + // + #[zbus(property(emits_changed_signal = "true"))] + async fn loop_status(&self) -> LoopStatus { + self.repeat + } + + #[zbus(property)] + async fn set_loop_status(&mut self, value: LoopStatus) -> zbus::fdo::Result<()> { + // TODO: implement, notify change + match value { + LoopStatus::None => { + if let Some(spirc) = &self.spirc { + let _ = spirc.repeat(false); + let _ = spirc.repeat_track(false); + } + } + LoopStatus::Track => { + if let Some(spirc) = &self.spirc { + let _ = spirc.repeat_track(true); + } + } + LoopStatus::Playlist => { + if let Some(spirc) = &self.spirc { + let _ = spirc.repeat(true); + } + } + } + + Ok(()) + } + + // The current playback rate. + // + // The value must fall in the range described by `MinimumRate` and `MaximumRate`, and must not + // be 0.0. If playback is paused, the `PlaybackStatus` property should be used to indicate + // this. A value of 0.0 should not be set by the client. If it is, the media player should + // act as though `Pause` was called. + // + // If the media player has no ability to play at speeds other than the normal playback rate, + // this must still be implemented, and must return 1.0. The `MinimumRate` and `MaximumRate` + // properties must also be set to 1.0. + // + // Not all values may be accepted by the media player. It is left to media player + // implementations to decide how to deal with values they cannot use; they may either ignore + // them or pick a "best fit" value. Clients are recommended to only use sensible fractions or + // multiples of 1 (eg: 0.5, 0.25, 1.5, 2.0, etc). + // + // Rationale: + // + // This allows clients to display (reasonably) accurate progress bars + // without having to regularly query the media player for the current + // position. + #[zbus(property(emits_changed_signal = "true"))] + async fn rate(&self) -> PlaybackRate { + 1.0 + } + + #[zbus(property)] + async fn set_rate(&mut self, _value: PlaybackRate) { + // ignore + } + + // A value of `false` indicates that playback is progressing linearly through a playlist, while + // `true` means playback is progressing through a playlist in some other order. + // + // If `CanControl` is `false`, attempting to set this property should have no effect and raise + // an error. + // + #[zbus(property(emits_changed_signal = "true"))] + async fn shuffle(&self) -> bool { + self.shuffle + } + + #[zbus(property)] + async fn set_shuffle(&mut self, value: bool) { + if let Some(spirc) = &self.spirc { + let _ = spirc.shuffle(value); + } + } + + // The metadata of the current element. + // + // If there is a current track, this must have a "mpris:trackid" entry (of D-Bus type "o") at + // the very least, which contains a D-Bus path that uniquely identifies this track. + // + // See the type documentation for more details. + #[zbus(property(emits_changed_signal = "true"))] + async fn metadata( + &self, + ) -> zbus::fdo::Result> { + let meta = if self.metadata.is_empty() { + let mut meta = HashMap::new(); + meta.insert( + "mpris:trackid".to_owned(), + zvariant::Str::from(" /org/mpris/MediaPlayer2/TrackList/NoTrack").into(), + ); + meta + } else { + self.metadata + .iter() + .map(|(k, v)| (k.clone(), v.try_clone().unwrap())) + .collect() + }; + Ok(meta) + } + + // The volume level. + // + // When setting, if a negative value is passed, the volume should be set to 0.0. + // + // If `CanControl` is `false`, attempting to set this property should have no effect and raise + // an error. + #[zbus(property(emits_changed_signal = "true"))] + async fn volume(&self) -> Volume { + self.volume + } + + #[zbus(property)] + async fn set_volume(&mut self, _value: Volume) -> zbus::fdo::Result<()> { + // TODO: implement + Err(zbus::fdo::Error::NotSupported( + "Player control not implemented".to_owned(), + )) + } + + // The current track position in microseconds, between 0 and the 'mpris:length' metadata entry + // (see Metadata). + // + // Note: If the media player allows it, the current playback position can be changed either the + // SetPosition method or the Seek method on this interface. If this is not the case, the + // `CanSeek` property is false, and setting this property has no effect and can raise an error. + // + // If the playback progresses in a way that is inconstistant with the `Rate` property, the + // `Seeked` signal is emited. + #[zbus(property(emits_changed_signal = "false"))] + async fn position(&self) -> zbus::fdo::Result { + // todo!("fetch up-to-date position from player") + Ok(0) + } + + // Note that the `Position` property is not writable intentionally, see + // the `set_position` method above. + // #[zbus(property)] + // async fn set_position(&self, _value: TimeInUs) -> zbus::fdo::Result<()> { + // // TODO: implement + // Err(zbus::fdo::Error::NotSupported("Player control not implemented".to_owned())) + // } + + // The minimum value which the `Rate` property can take. Clients should not attempt to set the + // `Rate` property below this value. + // + // Note that even if this value is 0.0 or negative, clients should not attempt to set the + // `Rate` property to 0.0. + // + // This value should always be 1.0 or less. + #[zbus(property(emits_changed_signal = "true"))] + async fn minimum_rate(&self) -> PlaybackRate { + // TODO: implement + 1.0 + } + + // The maximum value which the `Rate` property can take. Clients should not attempt to set the + // `Rate` property above this value. + // + // This value should always be 1.0 or greater. + #[zbus(property(emits_changed_signal = "true"))] + async fn maximum_rate(&self) -> PlaybackRate { + // TODO: implement + 1.0 + } + + // Whether the client can call the `Next` method on this interface and expect the current track + // to change. + // + // If it is unknown whether a call to `Next` will be successful (for example, when streaming + // tracks), this property should be set to `true`. + // + // If `CanControl` is `false`, this property should also be `false`. + // + // Rationale: + // + // Even when playback can generally be controlled, there may not + // always be a next track to move to. + #[zbus(property(emits_changed_signal = "true"))] + async fn can_go_next(&self) -> bool { + true + } + + // Whether the client can call the `Previous` method on this interface and expect the current + // track to change. + // + // If it is unknown whether a call to `Previous` will be successful (for example, when + // streaming tracks), this property should be set to `true`. + // + // If `CanControl` is `false`, this property should also be `false`. + // + // Rationale: + // + // Even when playback can generally be controlled, there may not + // always be a next previous to move to. + #[zbus(property(emits_changed_signal = "true"))] + async fn can_go_previous(&self) -> bool { + true + } + + // Whether playback can be started using `Play` or `PlayPause`. + // + // Note that this is related to whether there is a "current track": the value should not depend + // on whether the track is currently paused or playing. In fact, if a track is currently + // playing (and `CanControl` is `true`), this should be `true`. + // + // If `CanControl` is `false`, this property should also be `false`. + // + // Rationale: + // + // Even when playback can generally be controlled, it may not be + // possible to enter a "playing" state, for example if there is no + // "current track". + #[zbus(property(emits_changed_signal = "true"))] + async fn can_play(&self) -> bool { + !self.metadata.is_empty() + } + + // Whether playback can be paused using `Pause` or `PlayPause`. + // + // Note that this is an intrinsic property of the current track: its value should not depend on + // whether the track is currently paused or playing. In fact, if playback is currently paused + // (and `CanControl` is `true`), this should be `true`. + // + // + // If `CanControl` is `false`, this property should also be `false`. + // + // Rationale: + // + // Not all media is pausable: it may not be possible to pause some + // streamed media, for example. + #[zbus(property(emits_changed_signal = "true"))] + async fn can_pause(&self) -> bool { + !self.metadata.is_empty() + } + + // Whether the client can control the playback position using `Seek` and `SetPosition`. This + // may be different for different tracks. + // + // If `CanControl` is `false`, this property should also be `false`. + // + // Rationale: + // + // Not all media is seekable: it may not be possible to seek when + // playing some streamed media, for example. + #[zbus(property(emits_changed_signal = "true"))] + async fn can_seek(&self) -> bool { + true + } + + // Whether the media player may be controlled over this interface. + // + // This property is not expected to change, as it describes an intrinsic capability of the + // implementation. + // + // If this is `false`, clients should assume that all properties on this interface are + // read-only (and will raise errors if writing to them is attempted), no methods are + // implemented and all other properties starting with "can_" are also `false`. + // + // Rationale: + // + // This allows clients to determine whether to present and enable controls to the user in + // advance of attempting to call methods and write to properties. + #[zbus(property(emits_changed_signal = "const"))] + async fn can_control(&self) -> bool { + true + } + + // Indicates that the track position has changed in a way that is inconsistant with the current + // playing state. + // + // When this signal is not received, clients should assume that: + // - When playing, the position progresses according to the rate property. + // - When paused, it remains constant. + // + // This signal does not need to be emitted when playback starts or when the track changes, + // unless the track is starting at an unexpected position. An expected position would be the + // last known one when going from Paused to Playing, and 0 when going from Stopped to Playing. + // + // Arguments: + // + // * `position`: The new position, in microseconds. + #[zbus(signal)] + async fn seeked(signal_ctxt: &zbus::SignalContext<'_>, position: TimeInUs) -> zbus::Result<()>; + // FIXME: signal on appropriate player events! +} + +#[derive(Debug, Error)] +pub enum MprisError { + #[error("zbus error: {0}")] + DbusError(zbus::Error), +} + +impl From for Error { + fn from(err: MprisError) -> Self { + use MprisError::*; + match err { + DbusError(_) => Error::internal(err), + } + } +} + +impl From for MprisError { + fn from(err: zbus::Error) -> Self { + Self::DbusError(err) + } +} + +enum MprisCommand { + SetSpirc(Spirc), + Quit, +} + +pub struct MprisEventHandler { + cmd_tx: mpsc::UnboundedSender, + join_handle: tokio::task::JoinHandle<()>, +} + +impl MprisEventHandler { + pub async fn spawn(player: Arc) -> Result { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + + let mpris_service = MprisService {}; + let mpris_player_service = MprisPlayerService { + spirc: None, + // FIXME: obtain current values from Player + repeat: LoopStatus::None, + shuffle: false, + playback_status: PlaybackStatus::Stopped, + volume: 1.0, + metadata: HashMap::new(), + }; + + let connection = connection::Builder::session()? + // FIXME: retry with "org.mpris.MediaPlayer2.librespot.instance" + // on error + .name("org.mpris.MediaPlayer2.librespot")? + .serve_at("/org/mpris/MediaPlayer2", mpris_service)? + .serve_at("/org/mpris/MediaPlayer2", mpris_player_service)? + .build() + .await?; + + let mpris_task = MprisTask { + player, + connection, + cmd_rx, + }; + + let join_handle = tokio::spawn(mpris_task.run()); + + Ok(MprisEventHandler { + cmd_tx, + join_handle, + }) + } + + pub fn set_spirc(&self, spirc: Spirc) { + let _ = self.cmd_tx.send(MprisCommand::SetSpirc(spirc)); + } + + pub async fn quit_and_join(self) { + let _ = self.cmd_tx.send(MprisCommand::Quit); + let _ = self.join_handle.await; + } +} + +struct MprisTask { + player: Arc, + connection: zbus::Connection, + cmd_rx: mpsc::UnboundedReceiver, +} + +impl MprisTask { + async fn run(mut self) { + let mut player_events = self.player.get_player_event_channel(); + + loop { + tokio::select! { + Some(event) = player_events.recv() => { + if let Err(e) = self.handle_event(event).await { + warn!("Error handling PlayerEvent: {e}"); + } + } + + cmd = self.cmd_rx.recv() => { + match cmd { + Some(MprisCommand::SetSpirc(spirc)) => { + // TODO: Update playback status, metadata, etc (?) + self.mpris_player_iface().await + .get_mut().await + .spirc = Some(spirc); + + } + Some(MprisCommand::Quit) => break, + + // Keep running if the cmd sender was dropped + None => (), + } + } + + // If player_events yields None, shutdown + else => break, + } + } + + debug!("Shutting down MprisTask ..."); + } + + #[allow(dead_code)] + async fn mpris_iface(&self) -> zbus::object_server::InterfaceRef { + self.connection + .object_server() + .interface::<_, MprisService>("/org/mpris/MediaPlayer2") + .await + .expect("iface missing on object server") + } + + async fn mpris_player_iface(&self) -> zbus::object_server::InterfaceRef { + self.connection + .object_server() + .interface::<_, MprisPlayerService>("/org/mpris/MediaPlayer2") + .await + .expect("iface missing on object server") + } + + async fn handle_event(&self, event: PlayerEvent) -> zbus::Result<()> { + match event { + PlayerEvent::PlayRequestIdChanged { play_request_id: _ } => {} + PlayerEvent::TrackChanged { audio_item } => { + match audio_item.track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::TrackChanged: Invalid track id: {e}") + } + Ok(track_id) => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + + let meta = &mut iface.metadata; + meta.clear(); + + let mut trackid = String::new(); + trackid.push_str("/org/librespot/track/"); + trackid.push_str(&track_id); + meta.insert( + "mpris:trackid".into(), + zvariant::ObjectPath::try_from(trackid).unwrap().into(), + ); + + meta.insert( + "xesam:title".into(), + zvariant::Str::from(audio_item.name).into(), + ); + + if audio_item.covers.is_empty() { + meta.remove("mpris:artUrl"); + } else { + // TODO: Select image by size + let url = &audio_item.covers[0].url; + meta.insert("mpris.artUrl".into(), zvariant::Str::from(url).into()); + } + + meta.insert( + "mpris:length".into(), + (audio_item.duration_ms as i64 * 1000).into(), + ); + + match audio_item.unique_fields { + UniqueFields::Track { + artists, + album, + album_date, + album_artists, + popularity: _, + number, + disc_number, + } => { + let artists = artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>(); + meta.insert( + "xesam:artist".into(), + // try_to_owned only fails if the Value contains file + // descriptors, so the unwrap never panics here + zvariant::Value::from(artists).try_to_owned().unwrap(), + ); + + meta.insert( + "xesam:albumArtist".into(), + // try_to_owned only fails if the Value contains file + // descriptors, so the unwrap never panics here + zvariant::Value::from(&album_artists) + .try_to_owned() + .unwrap(), + ); + + meta.insert( + "xesam:album".into(), + zvariant::Str::from(album).into(), + ); + + meta.insert("xesam:trackNumber".into(), (number as i32).into()); + + meta.insert("xesam:discNumber".into(), (disc_number as i32).into()); + + meta.insert( + "xesam:contentCreated".into(), + zvariant::Str::from( + album_date.0.format(&Iso8601::DATE).unwrap(), + ) + .into(), + ); + } + UniqueFields::Episode { + description, + publish_time, + show_name, + } => { + meta.insert( + "xesam:album".into(), + zvariant::Str::from(show_name).into(), + ); + + meta.insert( + "xesam:comment".into(), + zvariant::Str::from(description).into(), + ); + + meta.insert( + "xesam:contentCreated".into(), + zvariant::Str::from( + publish_time.0.format(&Iso8601::DATE).unwrap(), + ) + .into(), + ); + } + } + + iface.metadata_changed(iface_ref.signal_context()).await?; + } + } + } + PlayerEvent::Stopped { track_id, .. } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {e}"), + Ok(track_id) => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + // TODO: Check if metadata changed, if so clear + let mut trackid = String::new(); + trackid.push_str("/org/librespot/track/"); + trackid.push_str(&track_id); + meta.insert( + "mpris:trackid".into(), + zvariant::ObjectPath::try_from(trackid).unwrap().into(), + ); + iface.metadata_changed(iface_ref.signal_context()).await?; + + iface.playback_status = PlaybackStatus::Stopped; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } + }, + PlayerEvent::Playing { + track_id, + // position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {e}"), + Ok(track_id) => { + // TODO: update position + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + // TODO: Check if metadata changed, if so clear + let mut trackid = String::new(); + trackid.push_str("/org/librespot/track/"); + trackid.push_str(&track_id); + meta.insert( + "mpris:trackid".into(), + zvariant::ObjectPath::try_from(trackid).unwrap().into(), + ); + iface.metadata_changed(iface_ref.signal_context()).await?; + + iface.playback_status = PlaybackStatus::Playing; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } + }, + PlayerEvent::Paused { + track_id, + // position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {e}"), + Ok(track_id) => { + // TODO: update position + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + // TODO: Check if metadata changed, if so clear + let mut trackid = String::new(); + trackid.push_str("/org/librespot/track/"); + trackid.push_str(&track_id); + meta.insert( + "mpris:trackid".into(), + zvariant::ObjectPath::try_from(trackid).unwrap().into(), + ); + iface.metadata_changed(iface_ref.signal_context()).await?; + + iface.playback_status = PlaybackStatus::Paused; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } + }, + PlayerEvent::Loading { .. } => {} + PlayerEvent::Preloading { .. } => {} + PlayerEvent::TimeToPreloadNextTrack { .. } => {} + PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::EndOfTrack: Invalid track id: {e}"), + Ok(_id) => { + // TODO: ? + } + }, + PlayerEvent::Unavailable { .. } => {} + PlayerEvent::VolumeChanged { + // volume + .. + } => { + // TODO: Handle volume + } + PlayerEvent::Seeked { + track_id, + // position_ms, + .. + } => match track_id.to_id() { + Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {e}"), + Ok(track_id) => { + // TODO: Update position + track_id + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + // TODO: Check if metadata changed, if so clear + let mut trackid = String::new(); + trackid.push_str("/org/librespot/track/"); + trackid.push_str(&track_id); + meta.insert( + "mpris:trackid".into(), + zvariant::ObjectPath::try_from(trackid).unwrap().into(), + ); + iface.metadata_changed(iface_ref.signal_context()).await?; + } + }, + PlayerEvent::PositionCorrection { + track_id, + // position_ms, + .. + } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::PositionCorrection: Invalid track id: {e}") + } + Ok(_id) => { + // TODO: Update position + track_id + } + }, + PlayerEvent::PositionChanged { .. } => { + // TODO + } + PlayerEvent::SessionConnected { .. } => {} + PlayerEvent::SessionDisconnected { .. } => {} + PlayerEvent::SessionClientChanged { .. } => {} + PlayerEvent::ShuffleChanged { shuffle } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + iface.shuffle = shuffle; + iface.shuffle_changed(iface_ref.signal_context()).await?; + } + PlayerEvent::RepeatChanged { context, track } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + if context { + iface.repeat = LoopStatus::Playlist; + } else if track { + iface.repeat = LoopStatus::Track; + } else { + iface.repeat = LoopStatus::None; + } + iface + .loop_status_changed(iface_ref.signal_context()) + .await?; + } + PlayerEvent::AutoPlayChanged { .. } => {} + PlayerEvent::FilterExplicitContentChanged { .. } => {} + } + + Ok(()) + } +} From 15e245916892eafb38b495308fbf87f54c6c96a6 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 12:09:15 +0200 Subject: [PATCH 06/29] feat(mpris): serve identity based on configured name --- src/main.rs | 2 +- src/mpris_event_handler.rs | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3e59e03b..51361212 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1997,7 +1997,7 @@ async fn main() { } #[cfg(feature = "with-mpris")] - let mpris = MprisEventHandler::spawn(player.clone()) + let mpris = MprisEventHandler::spawn(player.clone(), &setup.connect_config.name) .await .unwrap_or_else(|e| { error!("could not initialize MPRIS: {e}"); diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 61208913..0965131b 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -150,7 +150,9 @@ type Volume = f64; // Time in microseconds. type TimeInUs = i64; -struct MprisService {} +struct MprisService { + identity: String, +} #[zbus::interface(name = "org.mpris.MediaPlayer2")] impl MprisService { @@ -259,8 +261,7 @@ impl MprisService { #[zbus(property)] async fn identity(&self) -> String { debug!("org.mpris.MediaPlayer2::Identity"); - // TOOD: use name from config - "Librespot".to_owned() + self.identity.clone() } // The basename of an installed .desktop file which complies with the @@ -831,10 +832,12 @@ pub struct MprisEventHandler { } impl MprisEventHandler { - pub async fn spawn(player: Arc) -> Result { + pub async fn spawn(player: Arc, name: &str) -> Result { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let mpris_service = MprisService {}; + let mpris_service = MprisService { + identity: name.to_string(), + }; let mpris_player_service = MprisPlayerService { spirc: None, // FIXME: obtain current values from Player From f1ee77ff082c25f58cdd0c3ee9808594a25d1ff3 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 12:09:36 +0200 Subject: [PATCH 07/29] feat(mpris): Add set_volume handler --- src/mpris_event_handler.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 0965131b..bd27adf7 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -626,11 +626,17 @@ impl MprisPlayerService { } #[zbus(property)] - async fn set_volume(&mut self, _value: Volume) -> zbus::fdo::Result<()> { - // TODO: implement - Err(zbus::fdo::Error::NotSupported( - "Player control not implemented".to_owned(), - )) + async fn set_volume(&mut self, value: Volume) -> zbus::fdo::Result<()> { + if let Some(spirc) = &self.spirc { + // As of rust 1.45, cast is guaranteed to round to 0 and saturate. + // MPRIS volume is expected to range between 0 and 1, see + // https://specifications.freedesktop.org/mpris-spec/latest/Player_Interface.html#Simple-Type:Volume + let mapped_volume = (value * (u16::MAX as f64)).round() as u16; + spirc + .set_volume(mapped_volume) + .map_err(|err| zbus::fdo::Error::Failed(format!("{err}")))?; + } + Ok(()) } // The current track position in microseconds, between 0 and the 'mpris:length' metadata entry From 2e55999df355c4ef93ecb3f486b7979589541589 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 12:32:38 +0200 Subject: [PATCH 08/29] feat(mpris): Retry with pid specific name on NameTaken error --- src/mpris_event_handler.rs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index bd27adf7..f80269b1 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, process, sync::Arc}; use librespot_connect::Spirc; use log::{debug, warn}; @@ -838,11 +838,9 @@ pub struct MprisEventHandler { } impl MprisEventHandler { - pub async fn spawn(player: Arc, name: &str) -> Result { - let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - + fn connection_builder<'a>(identity: &str, name: &str) -> zbus::Result> { let mpris_service = MprisService { - identity: name.to_string(), + identity: identity.to_string(), }; let mpris_player_service = MprisPlayerService { spirc: None, @@ -854,14 +852,28 @@ impl MprisEventHandler { metadata: HashMap::new(), }; - let connection = connection::Builder::session()? - // FIXME: retry with "org.mpris.MediaPlayer2.librespot.instance" - // on error - .name("org.mpris.MediaPlayer2.librespot")? + connection::Builder::session()? + .name(name.to_string())? .serve_at("/org/mpris/MediaPlayer2", mpris_service)? - .serve_at("/org/mpris/MediaPlayer2", mpris_player_service)? + .serve_at("/org/mpris/MediaPlayer2", mpris_player_service) + } + + pub async fn spawn(player: Arc, name: &str) -> Result { + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + + let connection = Self::connection_builder(name, "org.mpris.MediaPlayer2.librespot")? .build() - .await?; + .await; + let connection = match connection { + Err(zbus::Error::NameTaken) => { + let pid_name = + format!("org.mpris.MediaPlayer2.librespot.instance{}", process::id()); + log::warn!("MPRIS: zbus name taken, trying with pid specific name: {pid_name}"); + + Self::connection_builder(name, &pid_name)?.build().await + } + _ => connection, + }?; let mpris_task = MprisTask { player, From 59767ce9f2821351f726667f9f2fe4d3d0fa9288 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 12:33:58 +0200 Subject: [PATCH 09/29] feat(player): Send current state of player for all new player listeners --- playback/src/player.rs | 46 +++++++++++++++++++++++++++++++++++++- src/mpris_event_handler.rs | 3 ++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/playback/src/player.rs b/playback/src/player.rs index a4a03ca3..cbbdb559 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -2163,7 +2163,51 @@ impl PlayerInternal { PlayerCommand::SetSession(session) => self.session = session, - PlayerCommand::AddEventSender(sender) => self.event_senders.push(sender), + PlayerCommand::AddEventSender(sender) => { + // Send current player state to new event listener + match self.state { + PlayerState::Loading { + ref track_id, + play_request_id, + .. + } => { + let _ = sender.send(PlayerEvent::Loading { + play_request_id, + track_id: track_id.clone(), + position_ms: 0, // TODO + }); + } + PlayerState::Paused { + ref track_id, + play_request_id, + stream_position_ms, + .. + } => { + let _ = sender.send(PlayerEvent::Paused { + play_request_id, + track_id: track_id.clone(), + position_ms: stream_position_ms, + }); + } + PlayerState::Playing { ref audio_item, .. } => { + let audio_item = Box::new(audio_item.clone()); + let _ = sender.send(PlayerEvent::TrackChanged { audio_item }); + } + PlayerState::EndOfTrack { + play_request_id, + ref track_id, + .. + } => { + let _ = sender.send(PlayerEvent::EndOfTrack { + play_request_id, + track_id: track_id.clone(), + }); + } + _ => (), + } + + self.event_senders.push(sender); + } PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback, diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index f80269b1..8978f723 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -844,7 +844,8 @@ impl MprisEventHandler { }; let mpris_player_service = MprisPlayerService { spirc: None, - // FIXME: obtain current values from Player + // Values are updated upon reception of first player state, right after MprisTask event + // handler registration repeat: LoopStatus::None, shuffle: false, playback_status: PlaybackStatus::Stopped, From 7710f6dd530c4c1ddb0b9bfe87bee6c397987e83 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 12:29:57 +0200 Subject: [PATCH 10/29] feat(mpris): Notify when volume changed --- src/mpris_event_handler.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 8978f723..cde32a8d 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -317,7 +317,7 @@ struct MprisPlayerService { repeat: LoopStatus, shuffle: bool, playback_status: PlaybackStatus, - volume: f64, + volume: u16, metadata: HashMap, } @@ -622,7 +622,7 @@ impl MprisPlayerService { // an error. #[zbus(property(emits_changed_signal = "true"))] async fn volume(&self) -> Volume { - self.volume + self.volume as f64 / u16::MAX as f64 } #[zbus(property)] @@ -849,7 +849,7 @@ impl MprisEventHandler { repeat: LoopStatus::None, shuffle: false, playback_status: PlaybackStatus::Stopped, - volume: 1.0, + volume: u16::MAX, metadata: HashMap::new(), }; @@ -1166,11 +1166,13 @@ impl MprisTask { } }, PlayerEvent::Unavailable { .. } => {} - PlayerEvent::VolumeChanged { - // volume - .. - } => { - // TODO: Handle volume + PlayerEvent::VolumeChanged { volume, .. } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + if iface.volume != volume { + iface.volume = volume; + iface.volume_changed(iface_ref.signal_context()).await?; + } } PlayerEvent::Seeked { track_id, From 3fb00143f998cb646d8d86a892af09903a1184af Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 14:11:12 +0200 Subject: [PATCH 11/29] fix(mpris): Remove done todo --- src/mpris_event_handler.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index cde32a8d..712e2fe8 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -516,7 +516,6 @@ impl MprisPlayerService { #[zbus(property)] async fn set_loop_status(&mut self, value: LoopStatus) -> zbus::fdo::Result<()> { - // TODO: implement, notify change match value { LoopStatus::None => { if let Some(spirc) = &self.spirc { From 51ec4c91c3d7942ed0b878981fc054b1ba3343a4 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 14:16:51 +0200 Subject: [PATCH 12/29] fix(mpris): Remove duplicated and commented function --- src/mpris_event_handler.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 712e2fe8..14cc4b55 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -653,14 +653,6 @@ impl MprisPlayerService { Ok(0) } - // Note that the `Position` property is not writable intentionally, see - // the `set_position` method above. - // #[zbus(property)] - // async fn set_position(&self, _value: TimeInUs) -> zbus::fdo::Result<()> { - // // TODO: implement - // Err(zbus::fdo::Error::NotSupported("Player control not implemented".to_owned())) - // } - // The minimum value which the `Rate` property can take. Clients should not attempt to set the // `Rate` property below this value. // From a28e6b4b5796afc2e82116ced84799e48a34c4b6 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 23 Sep 2025 14:26:16 +0200 Subject: [PATCH 13/29] fix(mpris): Add comment concerning non-support of setting playback rate --- src/mpris_event_handler.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 14cc4b55..aece9d8b 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -662,7 +662,7 @@ impl MprisPlayerService { // This value should always be 1.0 or less. #[zbus(property(emits_changed_signal = "true"))] async fn minimum_rate(&self) -> PlaybackRate { - // TODO: implement + // Setting minimum and maximum rate to 1 disallow client to set rate. 1.0 } @@ -672,7 +672,7 @@ impl MprisPlayerService { // This value should always be 1.0 or greater. #[zbus(property(emits_changed_signal = "true"))] async fn maximum_rate(&self) -> PlaybackRate { - // TODO: implement + // Setting minimum and maximum rate to 1 disallow client to set rate. 1.0 } From f2985e0f7334a0c24536589a8018187861fa8f24 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 25 Sep 2025 11:56:28 +0200 Subject: [PATCH 14/29] feat(mpris): Store metadata unserialized --- src/mpris_event_handler.rs | 553 +++++++++++++++++++++++-------------- 1 file changed, 341 insertions(+), 212 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index aece9d8b..1d1709c8 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -8,7 +8,8 @@ use tokio::sync::mpsc; use zbus::connection; use librespot::{ - core::Error, + core::date::Date, + core::{Error, SpotifyUri}, metadata::audio::UniqueFields, playback::player::{Player, PlayerEvent}, }; @@ -312,13 +313,257 @@ impl MprisService { } } +/// MPRIS-specific metadata +#[derive(Default, Clone)] +struct MprisMetadata { + /// D-Bus path: A unique identity for this track within the context of an MPRIS object (eg: tracklist). + track_id: Option, + /// 64-bit integer: The duration of the track in microseconds. + length: Option, + /// URI: The location of an image representing the track or album. Clients should not assume this will continue to exist when the media player stops giving out the URL. + art_url: Option, +} + +/// Common audio properties from the Xesam specification +#[derive(Default, Clone)] +struct XesamMetadata { + /// String: The album name. + album: Option, + /// List of Strings: The album artist(s). + album_artist: Option>, + /// List of Strings: The track artist(s). + artist: Option>, + /// String: The track lyrics. + as_text: Option, + /// Integer: The speed of the music, in beats per minute. + audio_bpm: Option, + /// Float: An automatically-generated rating, based on things such as how often it has been played. This should be in the range 0.0 to 1.0. + auto_rating: Option, + /// List of Strings: A (list of) freeform comment(s). + comment: Option>, + /// List of Strings: The composer(s) of the track. + composer: Option>, + /// Date/Time: When the track was created. Usually only the year component will be useful. + content_created: Option, + /// Integer: The disc number on the album that this track is from. + disc_number: Option, + /// Date/Time: When the track was first played. + first_used: Option, + /// List of Strings: The genre(s) of the track. + genre: Option>, + /// Date/Time: When the track was last played. + last_used: Option, + /// List of Strings: The lyricist(s) of the track. + lyricist: Option>, + /// String: The track title. + title: Option, + /// Integer: The track number on the album disc. + track_number: Option, + /// URI: The location of the media file. + url: Option, + /// Integer: The number of times the track has been played. + use_count: Option, + /// Float: A user-specified rating. This should be in the range 0.0 to 1.0. + user_rating: Option, +} + +impl From for XesamMetadata { + fn from(value: UniqueFields) -> Self { + let mut xesam = Self::default(); + + match value { + UniqueFields::Track { + artists, + album, + album_date, + album_artists, + popularity: _, + number, + disc_number, + } => { + let artists = artists + .0 + .into_iter() + .map(|a| a.name) + .collect::>(); + xesam.artist = Some(artists); + xesam.album_artist = Some(album_artists); + xesam.album = Some(album); + xesam.track_number = Some(number as i32); + xesam.disc_number = Some(disc_number as i32); + xesam.content_created = Some(album_date); + } + UniqueFields::Episode { + description, + publish_time, + show_name, + } => { + xesam.album = Some(show_name); + xesam.comment = Some(vec![description]); + xesam.content_created = Some(publish_time); + } + } + + xesam + } +} + +#[derive(Default, Clone)] +struct Metadata { + mpris: MprisMetadata, + xesam: XesamMetadata, +} + +impl TryInto> for Metadata { + type Error = zbus::Error; + + fn try_into(self) -> Result, Self::Error> { + let mut meta: HashMap = HashMap::new(); + + let track_id = self.mpris.track_id.map(|track_id| { + track_id + .to_id() + .map(|id| format!("/org/librespot/track/{id}")) + .ok() + }); + let track_id = track_id + .flatten() + .unwrap_or(" /org/mpris/MediaPlayer2/TrackList/NoTrack".to_string()); + meta.insert( + String::from("mpris:trackId"), + zvariant::ObjectPath::try_from(track_id)?.into(), + ); + + if let Some(length) = self.mpris.length { + meta.insert(String::from("mpris:length"), length.into()); + } + if let Some(art_url) = self.mpris.art_url { + meta.insert( + String::from("mpris:artUrl"), + zvariant::Str::from(art_url).into(), + ); + } + + if let Some(album) = self.xesam.album { + meta.insert( + String::from("xesam:album"), + zvariant::Str::from(album).into(), + ); + } + if let Some(album_artist) = self.xesam.album_artist { + meta.insert( + String::from("xesam:albumArtist"), + zvariant::Array::from(album_artist).try_into()?, + ); + } + if let Some(artist) = self.xesam.artist { + meta.insert( + String::from("xesam:artist"), + zvariant::Array::from(artist).try_into()?, + ); + } + if let Some(as_text) = self.xesam.as_text { + meta.insert( + String::from("xesam:asText"), + zvariant::Str::from(as_text).into(), + ); + } + if let Some(audio_bpm) = self.xesam.audio_bpm { + meta.insert(String::from("xesam:audioBPM"), audio_bpm.into()); + } + if let Some(auto_rating) = self.xesam.auto_rating { + meta.insert(String::from("xesam:autoRating"), auto_rating.into()); + } + if let Some(comment) = self.xesam.comment { + meta.insert( + String::from("xesam:comment"), + zvariant::Array::from(comment).try_into()?, + ); + } + if let Some(composer) = self.xesam.composer { + meta.insert( + String::from("xesam:composer"), + zvariant::Array::from(composer).try_into()?, + ); + } + if let Some(content_created) = self.xesam.content_created { + meta.insert( + String::from("xesam:contentCreated"), + zvariant::Str::from( + content_created + .format(&Iso8601::DEFAULT) + .map_err(|err| zvariant::Error::Message(format!("{err}")))?, + ) + .into(), + ); + } + if let Some(disc_number) = self.xesam.disc_number { + meta.insert(String::from("xesam:discNumber"), disc_number.into()); + } + if let Some(first_used) = self.xesam.first_used { + meta.insert( + String::from("xesam:firstUsed"), + zvariant::Str::from( + first_used + .format(&Iso8601::DEFAULT) + .map_err(|err| zvariant::Error::Message(format!("{err}")))?, + ) + .into(), + ); + } + if let Some(genre) = self.xesam.genre { + meta.insert( + String::from("xesam:genre"), + zvariant::Array::from(genre).try_into()?, + ); + } + if let Some(last_used) = self.xesam.last_used { + meta.insert( + String::from("xesam:lastUsed"), + zvariant::Str::from( + last_used + .format(&Iso8601::DEFAULT) + .map_err(|err| zvariant::Error::Message(format!("{err}")))?, + ) + .into(), + ); + } + if let Some(lyricist) = self.xesam.lyricist { + meta.insert( + String::from("xesam:lyricist"), + zvariant::Array::from(lyricist).try_into()?, + ); + } + if let Some(title) = self.xesam.title { + meta.insert( + String::from("xesam:title"), + zvariant::Str::from(title).into(), + ); + } + if let Some(track_number) = self.xesam.track_number { + meta.insert(String::from("xesam:trackNumber"), track_number.into()); + } + if let Some(url) = self.xesam.url { + meta.insert(String::from("xesam:url"), zvariant::Str::from(url).into()); + } + if let Some(use_count) = self.xesam.use_count { + meta.insert(String::from("xesam:useCount"), use_count.into()); + } + if let Some(user_rating) = self.xesam.user_rating { + meta.insert(String::from("xesam:userRating"), user_rating.into()); + } + + Ok(meta) + } +} + struct MprisPlayerService { spirc: Option, repeat: LoopStatus, shuffle: bool, playback_status: PlaybackStatus, volume: u16, - metadata: HashMap, + metadata: Metadata, } // This interface implements the methods for querying and providing basic @@ -597,20 +842,10 @@ impl MprisPlayerService { async fn metadata( &self, ) -> zbus::fdo::Result> { - let meta = if self.metadata.is_empty() { - let mut meta = HashMap::new(); - meta.insert( - "mpris:trackid".to_owned(), - zvariant::Str::from(" /org/mpris/MediaPlayer2/TrackList/NoTrack").into(), - ); - meta - } else { - self.metadata - .iter() - .map(|(k, v)| (k.clone(), v.try_clone().unwrap())) - .collect() - }; - Ok(meta) + self.metadata + .clone() + .try_into() + .map_err(zbus::fdo::Error::ZBus) } // The volume level. @@ -725,7 +960,7 @@ impl MprisPlayerService { // "current track". #[zbus(property(emits_changed_signal = "true"))] async fn can_play(&self) -> bool { - !self.metadata.is_empty() + self.metadata.mpris.track_id.is_some() } // Whether playback can be paused using `Pause` or `PlayPause`. @@ -743,7 +978,7 @@ impl MprisPlayerService { // streamed media, for example. #[zbus(property(emits_changed_signal = "true"))] async fn can_pause(&self) -> bool { - !self.metadata.is_empty() + self.metadata.mpris.track_id.is_some() } // Whether the client can control the playback position using `Seek` and `SetPosition`. This @@ -841,7 +1076,7 @@ impl MprisEventHandler { shuffle: false, playback_status: PlaybackStatus::Stopped, volume: u16::MAX, - metadata: HashMap::new(), + metadata: Metadata::default(), }; connection::Builder::session()? @@ -954,199 +1189,86 @@ impl MprisTask { match event { PlayerEvent::PlayRequestIdChanged { play_request_id: _ } => {} PlayerEvent::TrackChanged { audio_item } => { - match audio_item.track_id.to_id() { - Err(e) => { - warn!("PlayerEvent::TrackChanged: Invalid track id: {e}") - } - Ok(track_id) => { - let iface_ref = self.mpris_player_iface().await; - let mut iface = iface_ref.get_mut().await; + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; - let meta = &mut iface.metadata; - meta.clear(); + let meta = &mut iface.metadata; + *meta = Metadata::default(); - let mut trackid = String::new(); - trackid.push_str("/org/librespot/track/"); - trackid.push_str(&track_id); - meta.insert( - "mpris:trackid".into(), - zvariant::ObjectPath::try_from(trackid).unwrap().into(), - ); + meta.mpris.track_id = Some(audio_item.track_id); + meta.xesam.title = Some(audio_item.name); - meta.insert( - "xesam:title".into(), - zvariant::Str::from(audio_item.name).into(), - ); + // TODO: Select image by size + let url = &audio_item.covers[0].url; + meta.mpris.art_url = Some(String::from(url)); - if audio_item.covers.is_empty() { - meta.remove("mpris:artUrl"); - } else { - // TODO: Select image by size - let url = &audio_item.covers[0].url; - meta.insert("mpris.artUrl".into(), zvariant::Str::from(url).into()); - } + meta.mpris.length = Some(audio_item.duration_ms as i64 * 1000); - meta.insert( - "mpris:length".into(), - (audio_item.duration_ms as i64 * 1000).into(), - ); + meta.xesam = audio_item.unique_fields.into(); - match audio_item.unique_fields { - UniqueFields::Track { - artists, - album, - album_date, - album_artists, - popularity: _, - number, - disc_number, - } => { - let artists = artists - .0 - .into_iter() - .map(|a| a.name) - .collect::>(); - meta.insert( - "xesam:artist".into(), - // try_to_owned only fails if the Value contains file - // descriptors, so the unwrap never panics here - zvariant::Value::from(artists).try_to_owned().unwrap(), - ); - - meta.insert( - "xesam:albumArtist".into(), - // try_to_owned only fails if the Value contains file - // descriptors, so the unwrap never panics here - zvariant::Value::from(&album_artists) - .try_to_owned() - .unwrap(), - ); - - meta.insert( - "xesam:album".into(), - zvariant::Str::from(album).into(), - ); - - meta.insert("xesam:trackNumber".into(), (number as i32).into()); - - meta.insert("xesam:discNumber".into(), (disc_number as i32).into()); - - meta.insert( - "xesam:contentCreated".into(), - zvariant::Str::from( - album_date.0.format(&Iso8601::DATE).unwrap(), - ) - .into(), - ); - } - UniqueFields::Episode { - description, - publish_time, - show_name, - } => { - meta.insert( - "xesam:album".into(), - zvariant::Str::from(show_name).into(), - ); - - meta.insert( - "xesam:comment".into(), - zvariant::Str::from(description).into(), - ); - - meta.insert( - "xesam:contentCreated".into(), - zvariant::Str::from( - publish_time.0.format(&Iso8601::DATE).unwrap(), - ) - .into(), - ); - } - } - - iface.metadata_changed(iface_ref.signal_context()).await?; - } - } + iface.metadata_changed(iface_ref.signal_context()).await?; } - PlayerEvent::Stopped { track_id, .. } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {e}"), - Ok(track_id) => { - let iface_ref = self.mpris_player_iface().await; - let mut iface = iface_ref.get_mut().await; - let meta = &mut iface.metadata; + PlayerEvent::Stopped { track_id, .. } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; - // TODO: Check if metadata changed, if so clear - let mut trackid = String::new(); - trackid.push_str("/org/librespot/track/"); - trackid.push_str(&track_id); - meta.insert( - "mpris:trackid".into(), - zvariant::ObjectPath::try_from(trackid).unwrap().into(), - ); + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem iface.metadata_changed(iface_ref.signal_context()).await?; - - iface.playback_status = PlaybackStatus::Stopped; - iface - .playback_status_changed(iface_ref.signal_context()) - .await?; } - }, + + iface.playback_status = PlaybackStatus::Stopped; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } PlayerEvent::Playing { track_id, // position_ms, .. - } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {e}"), - Ok(track_id) => { - // TODO: update position - let iface_ref = self.mpris_player_iface().await; - let mut iface = iface_ref.get_mut().await; - let meta = &mut iface.metadata; + } => { + // TODO: update position + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; - // TODO: Check if metadata changed, if so clear - let mut trackid = String::new(); - trackid.push_str("/org/librespot/track/"); - trackid.push_str(&track_id); - meta.insert( - "mpris:trackid".into(), - zvariant::ObjectPath::try_from(trackid).unwrap().into(), - ); + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem iface.metadata_changed(iface_ref.signal_context()).await?; - - iface.playback_status = PlaybackStatus::Playing; - iface - .playback_status_changed(iface_ref.signal_context()) - .await?; } - }, + + iface.playback_status = PlaybackStatus::Playing; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } PlayerEvent::Paused { track_id, // position_ms, .. - } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {e}"), - Ok(track_id) => { - // TODO: update position - let iface_ref = self.mpris_player_iface().await; - let mut iface = iface_ref.get_mut().await; - let meta = &mut iface.metadata; + } => { + // TODO: update position + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; - // TODO: Check if metadata changed, if so clear - let mut trackid = String::new(); - trackid.push_str("/org/librespot/track/"); - trackid.push_str(&track_id); - meta.insert( - "mpris:trackid".into(), - zvariant::ObjectPath::try_from(trackid).unwrap().into(), - ); + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem iface.metadata_changed(iface_ref.signal_context()).await?; - - iface.playback_status = PlaybackStatus::Paused; - iface - .playback_status_changed(iface_ref.signal_context()) - .await?; } - }, + + iface.playback_status = PlaybackStatus::Paused; + iface + .playback_status_changed(iface_ref.signal_context()) + .await?; + } PlayerEvent::Loading { .. } => {} PlayerEvent::Preloading { .. } => {} PlayerEvent::TimeToPreloadNextTrack { .. } => {} @@ -1169,39 +1291,46 @@ impl MprisTask { track_id, // position_ms, .. - } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {e}"), - Ok(track_id) => { - // TODO: Update position + track_id - let iface_ref = self.mpris_player_iface().await; - let mut iface = iface_ref.get_mut().await; - let meta = &mut iface.metadata; + } => { + // TODO: Update position + track_id + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; - // TODO: Check if metadata changed, if so clear - let mut trackid = String::new(); - trackid.push_str("/org/librespot/track/"); - trackid.push_str(&track_id); - meta.insert( - "mpris:trackid".into(), - zvariant::ObjectPath::try_from(trackid).unwrap().into(), - ); + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem iface.metadata_changed(iface_ref.signal_context()).await?; } - }, + } PlayerEvent::PositionCorrection { track_id, // position_ms, .. - } => match track_id.to_id() { - Err(e) => { - warn!("PlayerEvent::PositionCorrection: Invalid track id: {e}") + } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem + iface.metadata_changed(iface_ref.signal_context()).await?; } - Ok(_id) => { - // TODO: Update position + track_id + } + PlayerEvent::PositionChanged { track_id, .. } => { + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + // TODO: Fetch all metadata from AudioItem + iface.metadata_changed(iface_ref.signal_context()).await?; } - }, - PlayerEvent::PositionChanged { .. } => { - // TODO } PlayerEvent::SessionConnected { .. } => {} PlayerEvent::SessionDisconnected { .. } => {} From 01f169e399d4c4c162cdb4094a990fe7d2f9878f Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 25 Sep 2025 15:33:18 +0200 Subject: [PATCH 15/29] feat(mpris): Add debug logging --- src/mpris_event_handler.rs | 48 ++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 1d1709c8..57505dd2 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -577,6 +577,7 @@ impl MprisPlayerService { /// /// If self.can_go_next is `false`, attempting to call this method should have no effect. async fn next(&self) { + log::debug!("org.mpris.MediaPlayer2.Player::Next"); if let Some(spirc) = &self.spirc { let _ = spirc.next(); } @@ -591,6 +592,7 @@ impl MprisPlayerService { // // If `self.can_go_previous` is `false`, attempting to call this method should have no effect. async fn previous(&self) { + log::debug!("org.mpris.MediaPlayer2.Player::Previous"); if let Some(spirc) = &self.spirc { let _ = spirc.prev(); } @@ -604,6 +606,7 @@ impl MprisPlayerService { // // If `self.can_pause` is `false`, attempting to call this method should have no effect. async fn pause(&self) { + debug!("org.mpris.MediaPlayer2.Player::Pause"); // FIXME: This should return an error if can_pause is false if let Some(spirc) = &self.spirc { let _ = spirc.pause(); @@ -619,6 +622,7 @@ impl MprisPlayerService { // If `self.can_pause` is `false`, attempting to call this method should have no effect and // raise an error. async fn play_pause(&self) { + debug!("org.mpris.MediaPlayer2.Player::PlayPause"); // FIXME: This should return an error if can_pause is false if let Some(spirc) = &self.spirc { let _ = spirc.play_pause(); @@ -635,6 +639,7 @@ impl MprisPlayerService { // If `CanControl` is `false`, attempting to call this method should have no effect and raise // an error. async fn stop(&self) { + debug!("org.mpris.MediaPlayer2.Player::Stop"); // FIXME: This should return an error if can_control is false if let Some(spirc) = &self.spirc { let _ = spirc.pause(); @@ -652,6 +657,7 @@ impl MprisPlayerService { // // If `self.can_play` is `false`, attempting to call this method should have no effect. async fn play(&self) { + debug!("org.mpris.MediaPlayer2.Player::Play"); if let Some(spirc) = &self.spirc { let _ = spirc.activate(); let _ = spirc.play(); @@ -672,6 +678,7 @@ impl MprisPlayerService { // // * `offset`: The number of microseconds to seek forward. async fn seek(&self, offset: TimeInUs) { + debug!("org.mpris.MediaPlayer2.Player::Seek({offset:?})"); if let Some(spirc) = &self.spirc { let _ = spirc.seek_offset((offset / 1000) as i32); } @@ -699,7 +706,8 @@ impl MprisPlayerService { // `/org/mpris/MediaPlayer2/TrackList/NoTrack` is _not_ a valid value for this // argument. // * `position`: Track position in microseconds. This must be between 0 and `track_length`. - async fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, position: TimeInUs) { + async fn set_position(&self, track_id: zbus::zvariant::ObjectPath<'_>, position: TimeInUs) { + debug!("org.mpris.MediaPlayer2.Player::SetPosition({track_id:?}, {position:?})"); // FIXME: handle track_id if position < 0 { return; @@ -730,7 +738,8 @@ impl MprisPlayerService { // * `uri`: Uri of the track to load. Its uri scheme should be an element of the // `org.mpris.MediaPlayer2.SupportedUriSchemes` property and the mime-type should // match one of the elements of the `org.mpris.MediaPlayer2.SupportedMimeTypes`. - async fn open_uri(&self, _uri: &str) -> zbus::fdo::Result<()> { + async fn open_uri(&self, uri: &str) -> zbus::fdo::Result<()> { + debug!("org.mpris.MediaPlayer2.Player::OpenUri({uri:?})"); Err(zbus::fdo::Error::NotSupported( "OpenUri not supported".to_owned(), )) @@ -741,6 +750,7 @@ impl MprisPlayerService { // May be "Playing", "Paused" or "Stopped". #[zbus(property(emits_changed_signal = "true"))] async fn playback_status(&self) -> PlaybackStatus { + debug!("org.mpris.MediaPlayer2.Player::PlaybackStatus"); self.playback_status } @@ -756,11 +766,13 @@ impl MprisPlayerService { // #[zbus(property(emits_changed_signal = "true"))] async fn loop_status(&self) -> LoopStatus { + debug!("org.mpris.MediaPlayer2.Player::LoopStatus"); self.repeat } #[zbus(property)] async fn set_loop_status(&mut self, value: LoopStatus) -> zbus::fdo::Result<()> { + debug!("org.mpris.MediaPlayer2.Player::LoopStatus({value:?})"); match value { LoopStatus::None => { if let Some(spirc) = &self.spirc { @@ -806,11 +818,13 @@ impl MprisPlayerService { // position. #[zbus(property(emits_changed_signal = "true"))] async fn rate(&self) -> PlaybackRate { + debug!("org.mpris.MediaPlayer2.Player::Rate"); 1.0 } #[zbus(property)] - async fn set_rate(&mut self, _value: PlaybackRate) { + async fn set_rate(&mut self, value: PlaybackRate) { + debug!("org.mpris.MediaPlayer2.Player::Rate({value:?})"); // ignore } @@ -822,11 +836,13 @@ impl MprisPlayerService { // #[zbus(property(emits_changed_signal = "true"))] async fn shuffle(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::Shuffle"); self.shuffle } #[zbus(property)] async fn set_shuffle(&mut self, value: bool) { + debug!("org.mpris.MediaPlayer2.Player::Shuffle({value:?})"); if let Some(spirc) = &self.spirc { let _ = spirc.shuffle(value); } @@ -842,6 +858,7 @@ impl MprisPlayerService { async fn metadata( &self, ) -> zbus::fdo::Result> { + debug!("org.mpris.MediaPlayer2.Player::Metadata"); self.metadata .clone() .try_into() @@ -856,11 +873,13 @@ impl MprisPlayerService { // an error. #[zbus(property(emits_changed_signal = "true"))] async fn volume(&self) -> Volume { + debug!("org.mpris.MediaPlayer2.Player::Volume"); self.volume as f64 / u16::MAX as f64 } #[zbus(property)] async fn set_volume(&mut self, value: Volume) -> zbus::fdo::Result<()> { + debug!("org.mpris.MediaPlayer2.Player::Volume({value})"); if let Some(spirc) = &self.spirc { // As of rust 1.45, cast is guaranteed to round to 0 and saturate. // MPRIS volume is expected to range between 0 and 1, see @@ -884,6 +903,7 @@ impl MprisPlayerService { // `Seeked` signal is emited. #[zbus(property(emits_changed_signal = "false"))] async fn position(&self) -> zbus::fdo::Result { + debug!("org.mpris.MediaPlayer2.Player::Position"); // todo!("fetch up-to-date position from player") Ok(0) } @@ -897,6 +917,7 @@ impl MprisPlayerService { // This value should always be 1.0 or less. #[zbus(property(emits_changed_signal = "true"))] async fn minimum_rate(&self) -> PlaybackRate { + debug!("org.mpris.MediaPlayer2.Player::MinimumRate"); // Setting minimum and maximum rate to 1 disallow client to set rate. 1.0 } @@ -907,6 +928,7 @@ impl MprisPlayerService { // This value should always be 1.0 or greater. #[zbus(property(emits_changed_signal = "true"))] async fn maximum_rate(&self) -> PlaybackRate { + debug!("org.mpris.MediaPlayer2.Player::MaximumRate"); // Setting minimum and maximum rate to 1 disallow client to set rate. 1.0 } @@ -925,6 +947,7 @@ impl MprisPlayerService { // always be a next track to move to. #[zbus(property(emits_changed_signal = "true"))] async fn can_go_next(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanGoNext"); true } @@ -942,6 +965,7 @@ impl MprisPlayerService { // always be a next previous to move to. #[zbus(property(emits_changed_signal = "true"))] async fn can_go_previous(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanGoPrevious"); true } @@ -960,6 +984,7 @@ impl MprisPlayerService { // "current track". #[zbus(property(emits_changed_signal = "true"))] async fn can_play(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanPlay"); self.metadata.mpris.track_id.is_some() } @@ -978,6 +1003,7 @@ impl MprisPlayerService { // streamed media, for example. #[zbus(property(emits_changed_signal = "true"))] async fn can_pause(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanPause"); self.metadata.mpris.track_id.is_some() } @@ -992,6 +1018,7 @@ impl MprisPlayerService { // playing some streamed media, for example. #[zbus(property(emits_changed_signal = "true"))] async fn can_seek(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanSeek"); true } @@ -1010,6 +1037,7 @@ impl MprisPlayerService { // advance of attempting to call methods and write to properties. #[zbus(property(emits_changed_signal = "const"))] async fn can_control(&self) -> bool { + debug!("org.mpris.MediaPlayer2.Player::CanControl"); true } @@ -1095,7 +1123,7 @@ impl MprisEventHandler { Err(zbus::Error::NameTaken) => { let pid_name = format!("org.mpris.MediaPlayer2.librespot.instance{}", process::id()); - log::warn!("MPRIS: zbus name taken, trying with pid specific name: {pid_name}"); + warn!("zbus name taken, trying with pid specific name: {pid_name}"); Self::connection_builder(name, &pid_name)?.build().await } @@ -1216,7 +1244,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } @@ -1238,7 +1266,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } @@ -1260,7 +1288,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } @@ -1300,7 +1328,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } } @@ -1316,7 +1344,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } } @@ -1328,7 +1356,7 @@ impl MprisTask { if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); - // TODO: Fetch all metadata from AudioItem + warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } } From 5e8fc7d75db5ae8b64a95493bd95971cc3fab063 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 25 Sep 2025 17:13:25 +0200 Subject: [PATCH 16/29] feat(mpris): Send biggest art url --- src/mpris_event_handler.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 57505dd2..9ef9552f 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1226,9 +1226,15 @@ impl MprisTask { meta.mpris.track_id = Some(audio_item.track_id); meta.xesam.title = Some(audio_item.name); - // TODO: Select image by size - let url = &audio_item.covers[0].url; - meta.mpris.art_url = Some(String::from(url)); + // Choose biggest cover + if let Some(url) = audio_item + .covers + .iter() + .max_by(|a, b| (a.size as u8).cmp(&(b.size as u8))) + .map(|cover| &cover.url) + { + meta.mpris.art_url = Some(String::from(url)); + } meta.mpris.length = Some(audio_item.duration_ms as i64 * 1000); From 97b88aaac53c5160649d596d71462a090a08e139 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 25 Sep 2025 17:22:07 +0200 Subject: [PATCH 17/29] feat(mpris): Update track id on EndOfTrack --- src/mpris_event_handler.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 9ef9552f..bcdb674a 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1306,12 +1306,19 @@ impl MprisTask { PlayerEvent::Loading { .. } => {} PlayerEvent::Preloading { .. } => {} PlayerEvent::TimeToPreloadNextTrack { .. } => {} - PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::EndOfTrack: Invalid track id: {e}"), - Ok(_id) => { - // TODO: ? + PlayerEvent::EndOfTrack { track_id, .. } => { + // TODO: Update position + let iface_ref = self.mpris_player_iface().await; + let mut iface = iface_ref.get_mut().await; + let meta = &mut iface.metadata; + + if meta.mpris.track_id.as_ref() != Some(&track_id) { + *meta = Metadata::default(); + meta.mpris.track_id = Some(track_id); + warn!("Missed TrackChanged event, metadata missing"); + iface.metadata_changed(iface_ref.signal_context()).await?; } - }, + } PlayerEvent::Unavailable { .. } => {} PlayerEvent::VolumeChanged { volume, .. } => { let iface_ref = self.mpris_player_iface().await; @@ -1326,7 +1333,7 @@ impl MprisTask { // position_ms, .. } => { - // TODO: Update position + track_id + // TODO: Update position let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; let meta = &mut iface.metadata; From 2fff1536fa8a284818a2686f40be1cc031a656cc Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 09:59:36 +0200 Subject: [PATCH 18/29] feat(player): Add position update option --- src/main.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 51361212..9c1c794c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,6 +275,7 @@ async fn get_setup() -> Setup { #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH: &str = "passthrough"; const PASSWORD: &str = "password"; + const POSITION_UPDATE: &str = "position-update"; const PROXY: &str = "proxy"; const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; @@ -320,6 +321,7 @@ async fn get_setup() -> Setup { #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH_SHORT: &str = "P"; const PASSWORD_SHORT: &str = "p"; + const POSITION_UPDATE_SHORT: &str = ""; // no short flag const EMIT_SINK_EVENTS_SHORT: &str = "Q"; const QUIET_SHORT: &str = "q"; const INITIAL_VOLUME_SHORT: &str = "R"; @@ -630,6 +632,12 @@ async fn get_setup() -> Setup { "Knee width (dB) of the dynamic limiter from 0.0 to 10.0. Defaults to 5.0.", "KNEE", ) + .optopt( + POSITION_UPDATE_SHORT, + POSITION_UPDATE, + "Update position interval in ms", + "POSITION_UPDATE", + ) .optopt( ZEROCONF_PORT_SHORT, ZEROCONF_PORT, @@ -1805,6 +1813,22 @@ async fn get_setup() -> Setup { }, }; + let position_update_interval = opt_str(POSITION_UPDATE).as_deref().map(|position_update| { + match position_update.parse::() { + Ok(value) => Duration::from_millis(value), + _ => { + invalid_error_msg( + POSITION_UPDATE, + POSITION_UPDATE_SHORT, + position_update, + "Integer value in ms", + "None", + ); + exit(1); + } + } + }); + #[cfg(feature = "passthrough-decoder")] let passthrough = opt_present(PASSTHROUGH); #[cfg(not(feature = "passthrough-decoder"))] @@ -1823,7 +1847,7 @@ async fn get_setup() -> Setup { normalisation_release_cf, normalisation_knee_db, ditherer, - position_update_interval: None, + position_update_interval, } }; From b0e0393b87b150610aa9193eec481729b0ad6cdb Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 07:27:26 +0200 Subject: [PATCH 19/29] feat(mpris): Get position from player and provide it to MPRIS --- src/mpris_event_handler.rs | 70 +++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index bcdb674a..6640e832 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, process, sync::Arc}; +use std::{collections::HashMap, process, sync::Arc, time::Instant}; use librespot_connect::Spirc; use log::{debug, warn}; @@ -557,12 +557,27 @@ impl TryInto> for Metadata { } } +struct Position { + ms: u32, + last_update: Instant, +} + +impl From for Position { + fn from(value: u32) -> Self { + Self { + ms: value, + last_update: Instant::now(), + } + } +} + struct MprisPlayerService { spirc: Option, repeat: LoopStatus, shuffle: bool, playback_status: PlaybackStatus, volume: u16, + position: Option, metadata: Metadata, } @@ -904,8 +919,15 @@ impl MprisPlayerService { #[zbus(property(emits_changed_signal = "false"))] async fn position(&self) -> zbus::fdo::Result { debug!("org.mpris.MediaPlayer2.Player::Position"); - // todo!("fetch up-to-date position from player") - Ok(0) + + self.position + .as_ref() + .map(|position| { + let corrected = (position.ms as u128) + .saturating_add(position.last_update.elapsed().as_millis()); + corrected as i64 * 1000 + }) + .ok_or(zbus::fdo::Error::Failed(String::from("Got no position"))) } // The minimum value which the `Rate` property can take. Clients should not attempt to set the @@ -1104,6 +1126,7 @@ impl MprisEventHandler { shuffle: false, playback_status: PlaybackStatus::Stopped, volume: u16::MAX, + position: None, metadata: Metadata::default(), }; @@ -1261,12 +1284,14 @@ impl MprisTask { } PlayerEvent::Playing { track_id, - // position_ms, + position_ms, .. } => { - // TODO: update position let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; + + iface.position = Some(Position::from(position_ms)); + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { @@ -1283,12 +1308,14 @@ impl MprisTask { } PlayerEvent::Paused { track_id, - // position_ms, + position_ms, .. } => { - // TODO: update position let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; + + iface.position = Some(Position::from(position_ms)); + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { @@ -1307,15 +1334,20 @@ impl MprisTask { PlayerEvent::Preloading { .. } => {} PlayerEvent::TimeToPreloadNextTrack { .. } => {} PlayerEvent::EndOfTrack { track_id, .. } => { - // TODO: Update position let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; let meta = &mut iface.metadata; - if meta.mpris.track_id.as_ref() != Some(&track_id) { + if meta.mpris.track_id.as_ref() == Some(&track_id) { + iface.position = meta + .mpris + .length + .map(|length| Position::from((length as f64 / 1000.) as u32)); + } else { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); + iface.position = None; iface.metadata_changed(iface_ref.signal_context()).await?; } } @@ -1330,12 +1362,14 @@ impl MprisTask { } PlayerEvent::Seeked { track_id, - // position_ms, + position_ms, .. } => { - // TODO: Update position let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; + + iface.position = Some(Position::from(position_ms)); + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { @@ -1347,11 +1381,14 @@ impl MprisTask { } PlayerEvent::PositionCorrection { track_id, - // position_ms, + position_ms, .. } => { let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; + + iface.position = Some(Position::from(position_ms)); + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { @@ -1361,9 +1398,16 @@ impl MprisTask { iface.metadata_changed(iface_ref.signal_context()).await?; } } - PlayerEvent::PositionChanged { track_id, .. } => { + PlayerEvent::PositionChanged { + track_id, + position_ms, + .. + } => { let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; + + iface.position = Some(Position::from(position_ms)); + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { From 4c37f2b8f4425f5934f37d4b541c9b7f565e38d6 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 08:11:26 +0200 Subject: [PATCH 20/29] feat(mpris): Check track_id when setting position --- src/mpris_event_handler.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 6640e832..7b7914f3 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, process, sync::Arc, time::Instant}; use librespot_connect::Spirc; -use log::{debug, warn}; +use log::{debug, info, warn}; use thiserror::Error; use time::format_description::well_known::Iso8601; use tokio::sync::mpsc; @@ -723,12 +723,21 @@ impl MprisPlayerService { // * `position`: Track position in microseconds. This must be between 0 and `track_length`. async fn set_position(&self, track_id: zbus::zvariant::ObjectPath<'_>, position: TimeInUs) { debug!("org.mpris.MediaPlayer2.Player::SetPosition({track_id:?}, {position:?})"); - // FIXME: handle track_id if position < 0 { return; } if let Some(spirc) = &self.spirc { - let _ = spirc.set_position_ms((position / 1000) as u32); + let current_track_id = self.metadata.mpris.track_id.as_ref().and_then(|track_id| { + track_id + .to_id() + .ok() + .map(|id| format!("/org/librespot/track/{id}")) + }); + if current_track_id.as_deref() == Some(track_id.as_str()) { + let _ = spirc.set_position_ms((position / 1000) as u32); + } else { + info!("SetPosition on wrong trackId, ignoring as stale"); + } } } From a3680792fdd055d47f7332344a51a20c67f638ed Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 15:24:45 +0200 Subject: [PATCH 21/29] chore(mpris): Remove useless comment --- src/mpris_event_handler.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 7b7914f3..b3d50af9 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1207,7 +1207,6 @@ impl MprisTask { cmd = self.cmd_rx.recv() => { match cmd { Some(MprisCommand::SetSpirc(spirc)) => { - // TODO: Update playback status, metadata, etc (?) self.mpris_player_iface().await .get_mut().await .spirc = Some(spirc); From 274d0015007ee988ea413e55b9b13c5a5b34ebaf Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 08:50:21 +0200 Subject: [PATCH 22/29] feat(mpris): Add support for desktop entry --- src/main.rs | 2 +- src/mpris_event_handler.rs | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9c1c794c..81ffb358 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2021,7 +2021,7 @@ async fn main() { } #[cfg(feature = "with-mpris")] - let mpris = MprisEventHandler::spawn(player.clone(), &setup.connect_config.name) + let mpris = MprisEventHandler::spawn(player.clone(), &setup.connect_config.name, None) .await .unwrap_or_else(|e| { error!("could not initialize MPRIS: {e}"); diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index b3d50af9..76731b2e 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -153,6 +153,7 @@ type TimeInUs = i64; struct MprisService { identity: String, + desktop_entry: Option, } #[zbus::interface(name = "org.mpris.MediaPlayer2")] @@ -277,7 +278,7 @@ impl MprisService { debug!("org.mpris.MediaPlayer2::DesktopEntry"); // FIXME: The spec doesn't say anything about the case when there is no .desktop. // Is there any convention? Any value that common clients handle in a sane way? - "".to_owned() + self.desktop_entry.clone().unwrap_or_default() } // The URI schemes supported by the media player. @@ -1123,9 +1124,14 @@ pub struct MprisEventHandler { } impl MprisEventHandler { - fn connection_builder<'a>(identity: &str, name: &str) -> zbus::Result> { + fn connection_builder<'a>( + identity: &str, + name: &str, + desktop_entry: Option<&str>, + ) -> zbus::Result> { let mpris_service = MprisService { identity: identity.to_string(), + desktop_entry: desktop_entry.map(|desktop_entry| desktop_entry.to_string()), }; let mpris_player_service = MprisPlayerService { spirc: None, @@ -1145,19 +1151,26 @@ impl MprisEventHandler { .serve_at("/org/mpris/MediaPlayer2", mpris_player_service) } - pub async fn spawn(player: Arc, name: &str) -> Result { + pub async fn spawn( + player: Arc, + name: &str, + desktop_entry: Option<&str>, + ) -> Result { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let connection = Self::connection_builder(name, "org.mpris.MediaPlayer2.librespot")? - .build() - .await; + let connection = + Self::connection_builder(name, "org.mpris.MediaPlayer2.librespot", desktop_entry)? + .build() + .await; let connection = match connection { Err(zbus::Error::NameTaken) => { let pid_name = format!("org.mpris.MediaPlayer2.librespot.instance{}", process::id()); warn!("zbus name taken, trying with pid specific name: {pid_name}"); - Self::connection_builder(name, &pid_name)?.build().await + Self::connection_builder(name, &pid_name, desktop_entry)? + .build() + .await } _ => connection, }?; From f6481fd996fce278675e05c53b7715b616459884 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 09:11:55 +0200 Subject: [PATCH 23/29] feat(mpris): Return error when trying to play/pause in wrong context --- src/mpris_event_handler.rs | 42 +++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 76731b2e..e96ec924 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -621,11 +621,16 @@ impl MprisPlayerService { // Calling Play after this should cause playback to start again from the same position. // // If `self.can_pause` is `false`, attempting to call this method should have no effect. - async fn pause(&self) { + async fn pause(&self) -> zbus::fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::Pause"); - // FIXME: This should return an error if can_pause is false - if let Some(spirc) = &self.spirc { - let _ = spirc.pause(); + match (&self.spirc, &self.metadata.mpris.track_id) { + (Some(spirc), Some(_)) => spirc + .pause() + .map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))), + (Some(_), None) => { + zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) + } + _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), } } @@ -637,11 +642,16 @@ impl MprisPlayerService { // // If `self.can_pause` is `false`, attempting to call this method should have no effect and // raise an error. - async fn play_pause(&self) { + async fn play_pause(&self) -> zbus::fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::PlayPause"); - // FIXME: This should return an error if can_pause is false - if let Some(spirc) = &self.spirc { - let _ = spirc.play_pause(); + match (&self.spirc, &self.metadata.mpris.track_id) { + (Some(spirc), Some(_)) => spirc + .play_pause() + .map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))), + (Some(_), None) => { + zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) + } + _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), } } @@ -656,7 +666,6 @@ impl MprisPlayerService { // an error. async fn stop(&self) { debug!("org.mpris.MediaPlayer2.Player::Stop"); - // FIXME: This should return an error if can_control is false if let Some(spirc) = &self.spirc { let _ = spirc.pause(); let _ = spirc.set_position_ms(0); @@ -672,12 +681,25 @@ impl MprisPlayerService { // If there is no track to play, this has no effect. // // If `self.can_play` is `false`, attempting to call this method should have no effect. - async fn play(&self) { + async fn play(&self) -> zbus::fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::Play"); if let Some(spirc) = &self.spirc { let _ = spirc.activate(); let _ = spirc.play(); } + match (&self.spirc, &self.metadata.mpris.track_id) { + (Some(spirc), Some(_)) => { + let result: Result<(), Error> = (|| { + spirc.activate()?; + spirc.play() + })(); + result.map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))) + } + (Some(_), None) => { + zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) + } + _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), + } } // Seeks forward in the current track by the specified number of microseconds. From 55c91bd65eb2d8d321442295bb54fe059f1ea3bc Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Tue, 30 Sep 2025 09:26:10 +0200 Subject: [PATCH 24/29] feat(mpris): Signal when position changed --- src/mpris_event_handler.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index e96ec924..e04909ad 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1111,7 +1111,6 @@ impl MprisPlayerService { // * `position`: The new position, in microseconds. #[zbus(signal)] async fn seeked(signal_ctxt: &zbus::SignalContext<'_>, position: TimeInUs) -> zbus::Result<()>; - // FIXME: signal on appropriate player events! } #[derive(Debug, Error)] @@ -1413,8 +1412,10 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - let meta = &mut iface.metadata; + MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) + .await?; + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); @@ -1432,8 +1433,10 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - let meta = &mut iface.metadata; + MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) + .await?; + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); @@ -1451,8 +1454,10 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - let meta = &mut iface.metadata; + MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) + .await?; + let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); From a7a65b0da209a6418c70be19d2a62045e8d6ce75 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Wed, 1 Oct 2025 07:31:40 +0200 Subject: [PATCH 25/29] chore(mpris): alias zbus::fdo::{Error, Result} for readability --- src/mpris_event_handler.rs | 55 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index e04909ad..88c4997b 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -5,7 +5,7 @@ use log::{debug, info, warn}; use thiserror::Error; use time::format_description::well_known::Iso8601; use tokio::sync::mpsc; -use zbus::connection; +use zbus::{connection, fdo}; use librespot::{ core::date::Date, @@ -621,16 +621,14 @@ impl MprisPlayerService { // Calling Play after this should cause playback to start again from the same position. // // If `self.can_pause` is `false`, attempting to call this method should have no effect. - async fn pause(&self) -> zbus::fdo::Result<()> { + async fn pause(&self) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::Pause"); match (&self.spirc, &self.metadata.mpris.track_id) { (Some(spirc), Some(_)) => spirc .pause() - .map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))), - (Some(_), None) => { - zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) - } - _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), + .map_err(|err| fdo::Error::Failed(format!("{err}"))), + (Some(_), None) => fdo::Result::Err(fdo::Error::Failed(String::from("No track"))), + _ => fdo::Result::Err(fdo::Error::Failed(String::from("Can't play/pause"))), } } @@ -642,16 +640,14 @@ impl MprisPlayerService { // // If `self.can_pause` is `false`, attempting to call this method should have no effect and // raise an error. - async fn play_pause(&self) -> zbus::fdo::Result<()> { + async fn play_pause(&self) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::PlayPause"); match (&self.spirc, &self.metadata.mpris.track_id) { (Some(spirc), Some(_)) => spirc .play_pause() - .map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))), - (Some(_), None) => { - zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) - } - _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), + .map_err(|err| fdo::Error::Failed(format!("{err}"))), + (Some(_), None) => fdo::Result::Err(fdo::Error::Failed(String::from("No track"))), + _ => fdo::Result::Err(fdo::Error::Failed(String::from("Can't play/pause"))), } } @@ -681,7 +677,7 @@ impl MprisPlayerService { // If there is no track to play, this has no effect. // // If `self.can_play` is `false`, attempting to call this method should have no effect. - async fn play(&self) -> zbus::fdo::Result<()> { + async fn play(&self) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::Play"); if let Some(spirc) = &self.spirc { let _ = spirc.activate(); @@ -693,12 +689,10 @@ impl MprisPlayerService { spirc.activate()?; spirc.play() })(); - result.map_err(|err| zbus::fdo::Error::Failed(format!("{err}"))) + result.map_err(|err| fdo::Error::Failed(format!("{err}"))) } - (Some(_), None) => { - zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("No track"))) - } - _ => zbus::fdo::Result::Err(zbus::fdo::Error::Failed(String::from("Can't play/pause"))), + (Some(_), None) => fdo::Result::Err(fdo::Error::Failed(String::from("No track"))), + _ => fdo::Result::Err(fdo::Error::Failed(String::from("Can't play/pause"))), } } @@ -785,11 +779,9 @@ impl MprisPlayerService { // * `uri`: Uri of the track to load. Its uri scheme should be an element of the // `org.mpris.MediaPlayer2.SupportedUriSchemes` property and the mime-type should // match one of the elements of the `org.mpris.MediaPlayer2.SupportedMimeTypes`. - async fn open_uri(&self, uri: &str) -> zbus::fdo::Result<()> { + async fn open_uri(&self, uri: &str) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::OpenUri({uri:?})"); - Err(zbus::fdo::Error::NotSupported( - "OpenUri not supported".to_owned(), - )) + Err(fdo::Error::NotSupported("OpenUri not supported".to_owned())) } // The current playback status. @@ -818,7 +810,7 @@ impl MprisPlayerService { } #[zbus(property)] - async fn set_loop_status(&mut self, value: LoopStatus) -> zbus::fdo::Result<()> { + async fn set_loop_status(&mut self, value: LoopStatus) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::LoopStatus({value:?})"); match value { LoopStatus::None => { @@ -904,12 +896,9 @@ impl MprisPlayerService { #[zbus(property(emits_changed_signal = "true"))] async fn metadata( &self, - ) -> zbus::fdo::Result> { + ) -> fdo::Result> { debug!("org.mpris.MediaPlayer2.Player::Metadata"); - self.metadata - .clone() - .try_into() - .map_err(zbus::fdo::Error::ZBus) + self.metadata.clone().try_into().map_err(fdo::Error::ZBus) } // The volume level. @@ -925,7 +914,7 @@ impl MprisPlayerService { } #[zbus(property)] - async fn set_volume(&mut self, value: Volume) -> zbus::fdo::Result<()> { + async fn set_volume(&mut self, value: Volume) -> fdo::Result<()> { debug!("org.mpris.MediaPlayer2.Player::Volume({value})"); if let Some(spirc) = &self.spirc { // As of rust 1.45, cast is guaranteed to round to 0 and saturate. @@ -934,7 +923,7 @@ impl MprisPlayerService { let mapped_volume = (value * (u16::MAX as f64)).round() as u16; spirc .set_volume(mapped_volume) - .map_err(|err| zbus::fdo::Error::Failed(format!("{err}")))?; + .map_err(|err| fdo::Error::Failed(format!("{err}")))?; } Ok(()) } @@ -949,7 +938,7 @@ impl MprisPlayerService { // If the playback progresses in a way that is inconstistant with the `Rate` property, the // `Seeked` signal is emited. #[zbus(property(emits_changed_signal = "false"))] - async fn position(&self) -> zbus::fdo::Result { + async fn position(&self) -> fdo::Result { debug!("org.mpris.MediaPlayer2.Player::Position"); self.position @@ -959,7 +948,7 @@ impl MprisPlayerService { .saturating_add(position.last_update.elapsed().as_millis()); corrected as i64 * 1000 }) - .ok_or(zbus::fdo::Error::Failed(String::from("Got no position"))) + .ok_or(fdo::Error::Failed(String::from("Got no position"))) } // The minimum value which the `Rate` property can take. Clients should not attempt to set the From 4f551583980a8c327bd1a4828709b1d974160b7e Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Wed, 1 Oct 2025 07:51:31 +0200 Subject: [PATCH 26/29] feat(player): Allow for stopped event without track_id --- playback/src/player.rs | 18 ++++++++++++------ src/mpris_event_handler.rs | 4 ++-- src/player_event_handler.rs | 17 +++++++++++------ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/playback/src/player.rs b/playback/src/player.rs index cbbdb559..9fdb33f1 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -142,8 +142,8 @@ pub enum PlayerEvent { }, // Fired when the player is stopped (e.g. by issuing a "stop" command to the player). Stopped { - play_request_id: u64, - track_id: SpotifyUri, + play_request_id: Option, + track_id: Option, }, // The player is delayed by loading a track. Loading { @@ -267,7 +267,8 @@ impl PlayerEvent { play_request_id, .. } | Stopped { - play_request_id, .. + play_request_id: Some(play_request_id), + .. } | PositionCorrection { play_request_id, .. @@ -1541,8 +1542,8 @@ impl PlayerInternal { self.ensure_sink_stopped(false); self.send_event(PlayerEvent::Stopped { - track_id, - play_request_id, + track_id: Some(track_id), + play_request_id: Some(play_request_id), }); self.state = PlayerState::Stopped; } @@ -2203,7 +2204,12 @@ impl PlayerInternal { track_id: track_id.clone(), }); } - _ => (), + PlayerState::Invalid | PlayerState::Stopped => { + let _ = sender.send(PlayerEvent::Stopped { + play_request_id: None, + track_id: None, + }); + } } self.event_senders.push(sender); diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 88c4997b..17536e80 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -1301,9 +1301,9 @@ impl MprisTask { let mut iface = iface_ref.get_mut().await; let meta = &mut iface.metadata; - if meta.mpris.track_id.as_ref() != Some(&track_id) { + if meta.mpris.track_id.as_ref() != track_id.as_ref() { *meta = Metadata::default(); - meta.mpris.track_id = Some(track_id); + meta.mpris.track_id = track_id; warn!("Missed TrackChanged event, metadata missing"); iface.metadata_changed(iface_ref.signal_context()).await?; } diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 22f237e3..179c17c4 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -109,13 +109,18 @@ impl EventHandler { } } } - PlayerEvent::Stopped { track_id, .. } => match track_id.to_id() { - Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {e}"), - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "stopped".to_string()); - env_vars.insert("TRACK_ID", id); + PlayerEvent::Stopped { track_id, .. } => { + env_vars.insert("PLAYER_EVENT", "stopped".to_string()); + match track_id.map(|track_id| track_id.to_id()) { + Some(Err(e)) => { + warn!("PlayerEvent::Stopped: Invalid track id: {e}") + } + Some(Ok(id)) => { + env_vars.insert("TRACK_ID", id); + } + None => {} } - }, + } PlayerEvent::Playing { track_id, position_ms, From 40ec0a3440b3b713d6ae67d743a1a0b1264fdc40 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Wed, 1 Oct 2025 08:00:36 +0200 Subject: [PATCH 27/29] feat(player): Rename position update interval option --- src/main.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 81ffb358..6fcd017e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,7 +275,7 @@ async fn get_setup() -> Setup { #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH: &str = "passthrough"; const PASSWORD: &str = "password"; - const POSITION_UPDATE: &str = "position-update"; + const POSITION_UPDATE_INTERVAL: &str = "position-update-interval"; const PROXY: &str = "proxy"; const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; @@ -321,7 +321,7 @@ async fn get_setup() -> Setup { #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH_SHORT: &str = "P"; const PASSWORD_SHORT: &str = "p"; - const POSITION_UPDATE_SHORT: &str = ""; // no short flag + const POSITION_UPDATE_INTERVAL_SHORT: &str = ""; // no short flag const EMIT_SINK_EVENTS_SHORT: &str = "Q"; const QUIET_SHORT: &str = "q"; const INITIAL_VOLUME_SHORT: &str = "R"; @@ -633,9 +633,9 @@ async fn get_setup() -> Setup { "KNEE", ) .optopt( - POSITION_UPDATE_SHORT, - POSITION_UPDATE, - "Update position interval in ms", + POSITION_UPDATE_INTERVAL_SHORT, + POSITION_UPDATE_INTERVAL, + "Maximum interval in ms for player to send a position event. Defaults to no forced position update.", "POSITION_UPDATE", ) .optopt( @@ -1813,21 +1813,21 @@ async fn get_setup() -> Setup { }, }; - let position_update_interval = opt_str(POSITION_UPDATE).as_deref().map(|position_update| { - match position_update.parse::() { + let position_update_interval = opt_str(POSITION_UPDATE_INTERVAL).as_deref().map( + |position_update| match position_update.parse::() { Ok(value) => Duration::from_millis(value), _ => { invalid_error_msg( - POSITION_UPDATE, - POSITION_UPDATE_SHORT, + POSITION_UPDATE_INTERVAL, + POSITION_UPDATE_INTERVAL_SHORT, position_update, "Integer value in ms", "None", ); exit(1); } - } - }); + }, + ); #[cfg(feature = "passthrough-decoder")] let passthrough = opt_present(PASSTHROUGH); From 280cf1b6005e219d12c7222f705b8db87c5027b3 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 2 Oct 2025 09:33:20 +0200 Subject: [PATCH 28/29] feat(mpris): Upgrade to zbus 5 --- Cargo.lock | 272 ++----------------------------------- Cargo.toml | 4 +- src/mpris_event_handler.rs | 56 +++----- 3 files changed, 38 insertions(+), 294 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37957ecc..86074dcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,65 +160,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.2", - "slab", - "windows-sys 0.61.0", -] - -[[package]] -name = "async-lock" -version = "3.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.2", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -230,30 +171,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.2", - "signal-hook-registry", - "slab", - "windows-sys 0.61.0", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.89" @@ -331,19 +248,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -1306,12 +1210,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -1966,8 +1864,8 @@ dependencies = [ "time", "tokio", "url", - "zbus 4.4.0", - "zvariant 4.2.0", + "zbus", + "zvariant", ] [[package]] @@ -2090,7 +1988,7 @@ dependencies = [ "sha1", "thiserror 2.0.16", "tokio", - "zbus 5.11.0", + "zbus", ] [[package]] @@ -2310,19 +2208,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nix" version = "0.30.1" @@ -2751,17 +2636,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs1" version = "0.7.5" @@ -2789,20 +2663,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.0", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -5008,16 +4868,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "yoke" version = "0.8.0" @@ -5042,39 +4892,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-process", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix 0.29.0", - "ordered-stream", - "rand 0.8.5", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tokio", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.11.0" @@ -5089,7 +4906,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "nix", "ordered-stream", "serde", "serde_repr", @@ -5098,22 +4915,9 @@ dependencies = [ "uds_windows", "windows-sys 0.60.2", "winnow", - "zbus_macros 5.11.0", - "zbus_names 4.2.0", - "zvariant 5.7.0", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils 2.1.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -5126,20 +4930,9 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zbus_names 4.2.0", - "zvariant 5.7.0", - "zvariant_utils 3.2.1", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -5151,7 +4944,7 @@ dependencies = [ "serde", "static_assertions", "winnow", - "zvariant 5.7.0", + "zvariant", ] [[package]] @@ -5234,19 +5027,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - [[package]] name = "zvariant" version = "5.7.0" @@ -5257,21 +5037,8 @@ dependencies = [ "enumflags2", "serde", "winnow", - "zvariant_derive 5.7.0", - "zvariant_utils 3.2.1", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -5284,18 +5051,7 @@ dependencies = [ "proc-macro2", "quote", "syn", - "zvariant_utils 3.2.1", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "zvariant_utils", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 897fd035..f74ac287 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,8 +187,8 @@ tokio = { version = "1", features = [ ] } time = { version = "0.3", features = ["formatting"] } url = "2.2" -zbus = { version = "4", default-features = false, features = ["tokio"], optional = true } -zvariant = { version = "4", default-features = false, optional = true } +zbus = { version = "5", default-features = false, features = ["tokio"], optional = true } +zvariant = { version = "5", default-features = false, optional = true } [package.metadata.deb] maintainer = "Librespot Organization " diff --git a/src/mpris_event_handler.rs b/src/mpris_event_handler.rs index 17536e80..97447260 100644 --- a/src/mpris_event_handler.rs +++ b/src/mpris_event_handler.rs @@ -5,7 +5,8 @@ use log::{debug, info, warn}; use thiserror::Error; use time::format_description::well_known::Iso8601; use tokio::sync::mpsc; -use zbus::{connection, fdo}; +use zbus::{connection, fdo, object_server::SignalEmitter}; +use zvariant::Type; use librespot::{ core::date::Date, @@ -15,7 +16,7 @@ use librespot::{ }; /// A playback state. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Type)] enum PlaybackStatus { /// A track is currently playing. Playing, @@ -27,12 +28,6 @@ enum PlaybackStatus { Stopped, } -impl zvariant::Type for PlaybackStatus { - fn signature() -> zvariant::Signature<'static> { - zvariant::Signature::try_from("s").unwrap() - } -} - impl TryFrom> for PlaybackStatus { type Error = zvariant::Error; @@ -63,7 +58,7 @@ impl From for zvariant::Value<'_> { } /// A repeat / loop status -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Type)] enum LoopStatus { /// The playback will stop when there are no more tracks to play None, @@ -75,12 +70,6 @@ enum LoopStatus { Playlist, } -impl zvariant::Type for LoopStatus { - fn signature() -> zvariant::Signature<'static> { - zvariant::Signature::try_from("s").unwrap() - } -} - impl TryFrom> for LoopStatus { type Error = zvariant::Error; @@ -1099,7 +1088,7 @@ impl MprisPlayerService { // // * `position`: The new position, in microseconds. #[zbus(signal)] - async fn seeked(signal_ctxt: &zbus::SignalContext<'_>, position: TimeInUs) -> zbus::Result<()>; + async fn seeked(signal_emitter: &SignalEmitter<'_>, position: TimeInUs) -> zbus::Result<()>; } #[derive(Debug, Error)] @@ -1294,7 +1283,7 @@ impl MprisTask { meta.xesam = audio_item.unique_fields.into(); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } PlayerEvent::Stopped { track_id, .. } => { let iface_ref = self.mpris_player_iface().await; @@ -1305,12 +1294,12 @@ impl MprisTask { *meta = Metadata::default(); meta.mpris.track_id = track_id; warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } iface.playback_status = PlaybackStatus::Stopped; iface - .playback_status_changed(iface_ref.signal_context()) + .playback_status_changed(iface_ref.signal_emitter()) .await?; } PlayerEvent::Playing { @@ -1329,12 +1318,12 @@ impl MprisTask { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } iface.playback_status = PlaybackStatus::Playing; iface - .playback_status_changed(iface_ref.signal_context()) + .playback_status_changed(iface_ref.signal_emitter()) .await?; } PlayerEvent::Paused { @@ -1353,12 +1342,12 @@ impl MprisTask { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } iface.playback_status = PlaybackStatus::Paused; iface - .playback_status_changed(iface_ref.signal_context()) + .playback_status_changed(iface_ref.signal_emitter()) .await?; } PlayerEvent::Loading { .. } => {} @@ -1379,7 +1368,7 @@ impl MprisTask { meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); iface.position = None; - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } } PlayerEvent::Unavailable { .. } => {} @@ -1388,7 +1377,7 @@ impl MprisTask { let mut iface = iface_ref.get_mut().await; if iface.volume != volume { iface.volume = volume; - iface.volume_changed(iface_ref.signal_context()).await?; + iface.volume_changed(iface_ref.signal_emitter()).await?; } } PlayerEvent::Seeked { @@ -1401,7 +1390,7 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) + MprisPlayerService::seeked(iface_ref.signal_emitter(), position_ms as i64 * 1000) .await?; let meta = &mut iface.metadata; @@ -1409,7 +1398,7 @@ impl MprisTask { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } } PlayerEvent::PositionCorrection { @@ -1422,7 +1411,7 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) + MprisPlayerService::seeked(iface_ref.signal_emitter(), position_ms as i64 * 1000) .await?; let meta = &mut iface.metadata; @@ -1430,7 +1419,7 @@ impl MprisTask { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } } PlayerEvent::PositionChanged { @@ -1443,15 +1432,14 @@ impl MprisTask { iface.position = Some(Position::from(position_ms)); - MprisPlayerService::seeked(iface_ref.signal_context(), position_ms as i64 * 1000) - .await?; + iface_ref.seeked(position_ms as i64 * 1000).await?; let meta = &mut iface.metadata; if meta.mpris.track_id.as_ref() != Some(&track_id) { *meta = Metadata::default(); meta.mpris.track_id = Some(track_id); warn!("Missed TrackChanged event, metadata missing"); - iface.metadata_changed(iface_ref.signal_context()).await?; + iface.metadata_changed(iface_ref.signal_emitter()).await?; } } PlayerEvent::SessionConnected { .. } => {} @@ -1461,7 +1449,7 @@ impl MprisTask { let iface_ref = self.mpris_player_iface().await; let mut iface = iface_ref.get_mut().await; iface.shuffle = shuffle; - iface.shuffle_changed(iface_ref.signal_context()).await?; + iface.shuffle_changed(iface_ref.signal_emitter()).await?; } PlayerEvent::RepeatChanged { context, track } => { let iface_ref = self.mpris_player_iface().await; @@ -1474,7 +1462,7 @@ impl MprisTask { iface.repeat = LoopStatus::None; } iface - .loop_status_changed(iface_ref.signal_context()) + .loop_status_changed(iface_ref.signal_emitter()) .await?; } PlayerEvent::AutoPlayChanged { .. } => {} From f5cf1871425c94896bff651afb08081746437322 Mon Sep 17 00:00:00 2001 From: Paul Fariello Date: Thu, 2 Oct 2025 08:47:17 +0200 Subject: [PATCH 29/29] feat(player): Add position_ms in Loading event --- playback/src/player.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/playback/src/player.rs b/playback/src/player.rs index 9fdb33f1..e5fd0c1b 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -680,6 +680,7 @@ enum PlayerState { play_request_id: u64, start_playback: bool, loader: Pin> + Send>>, + position_ms: u32, }, Paused { track_id: SpotifyUri, @@ -1227,6 +1228,7 @@ impl Future for PlayerInternal { ref track_id, start_playback, play_request_id, + .. } = self.state { // The loader may be terminated if we are trying to load the same track @@ -2021,6 +2023,7 @@ impl PlayerInternal { play_request_id, start_playback: play, loader, + position_ms, }; Ok(()) @@ -2170,12 +2173,13 @@ impl PlayerInternal { PlayerState::Loading { ref track_id, play_request_id, + position_ms, .. } => { let _ = sender.send(PlayerEvent::Loading { play_request_id, track_id: track_id.clone(), - position_ms: 0, // TODO + position_ms, }); } PlayerState::Paused {