diff --git a/Cargo.toml b/Cargo.toml index 6cc3de2..fbcfda9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ dbus-crossroads = { version = "0.5.0", optional = true } zbus = { version = "3.9", optional = true } zvariant = { version = "3.10", optional = true } pollster = { version = "0.3", optional = true } +thiserror = "1.0" [features] default = ["use_dbus"] diff --git a/README.md b/README.md index df7f316..d52a43b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ A cross-platform library for handling OS media controls and metadata. One abstra ```shell # In one shell $ cd souvlaki -$ cargo run --example example +$ cargo run --example window # In another shell $ playerctl metadata diff --git a/examples/example.rs b/examples/window.rs similarity index 100% rename from examples/example.rs rename to examples/window.rs diff --git a/src/lib.rs b/src/lib.rs index eec7e93..74e054b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ pub struct MediaMetadata<'a> { } /// Events sent by the OS media controls. -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Debug)] pub enum MediaControlEvent { Play, Pause, @@ -42,6 +42,8 @@ pub enum MediaControlEvent { SeekBy(SeekDirection, Duration), /// Set the position/progress of the currently playing media item. SetPosition(MediaPosition), + /// Sets the volume from 0.0 to 1.0. + SetVolume(f64), /// Open the URI in the media player. OpenUri(String), diff --git a/src/platform/mpris/dbus.rs b/src/platform/mpris/dbus.rs deleted file mode 100644 index 0c7ac7f..0000000 --- a/src/platform/mpris/dbus.rs +++ /dev/null @@ -1,469 +0,0 @@ -use std::collections::HashMap; -use std::convert::From; -use std::convert::{TryFrom, TryInto}; -use std::sync::{mpsc, Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use crate::{ - MediaControlEvent, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, SeekDirection, -}; - -/// A platform-specific error. -#[derive(Debug)] -pub struct Error; - -/// A handle to OS media controls. -pub struct MediaControls { - thread: Option, - dbus_name: String, - friendly_name: String, -} - -struct ServiceThreadHandle { - event_channel: mpsc::Sender, - thread: JoinHandle<()>, -} - -#[derive(Clone, PartialEq, Eq, Debug)] -enum InternalEvent { - ChangeMetadata(OwnedMetadata), - ChangePlayback(MediaPlayback), - Kill, -} - -#[derive(Debug)] -struct ServiceState { - metadata: OwnedMetadata, - metadata_dict: HashMap>>, - playback_status: MediaPlayback, -} - -impl ServiceState { - fn set_metadata(&mut self, metadata: OwnedMetadata) { - self.metadata_dict = create_metadata_dict(&metadata); - self.metadata = metadata; - } - - fn get_playback_status(&self) -> &'static str { - match self.playback_status { - MediaPlayback::Playing { .. } => "Playing", - MediaPlayback::Paused { .. } => "Paused", - MediaPlayback::Stopped => "Stopped", - } - } -} - -fn create_metadata_dict(metadata: &OwnedMetadata) -> HashMap>> { - let mut dict = HashMap::>>::new(); - - let mut insert = |k: &str, v| dict.insert(k.to_string(), Variant(v)); - - let OwnedMetadata { - ref title, - ref album, - ref artist, - ref cover_url, - ref duration, - } = metadata; - - // TODO: this is just a workaround to enable SetPosition. - let path = Path::new("/").unwrap(); - - // MPRIS - insert("mpris:trackid", Box::new(path)); - - if let Some(length) = duration { - insert("mpris:length", Box::new(*length)); - } - if let Some(cover_url) = cover_url { - insert("mpris:artUrl", Box::new(cover_url.clone())); - } - - // Xesam - if let Some(title) = title { - insert("xesam:title", Box::new(title.clone())); - } - if let Some(artist) = artist { - insert("xesam:artist", Box::new(vec![artist.clone()])); - } - if let Some(album) = album { - insert("xesam:album", Box::new(album.clone())); - } - - dict -} - -#[derive(Clone, PartialEq, Eq, Debug, Default)] -struct OwnedMetadata { - pub title: Option, - pub album: Option, - pub artist: Option, - pub cover_url: Option, - pub duration: Option, -} - -impl From> for OwnedMetadata { - fn from(other: MediaMetadata) -> Self { - OwnedMetadata { - title: other.title.map(|s| s.to_string()), - artist: other.artist.map(|s| s.to_string()), - album: other.album.map(|s| s.to_string()), - cover_url: other.cover_url.map(|s| s.to_string()), - duration: other.duration.map(|d| d.as_micros().try_into().unwrap()), - } - } -} - -impl MediaControls { - /// Create media controls with the specified config. - pub fn new(config: PlatformConfig) -> Result { - let PlatformConfig { - dbus_name, - display_name, - .. - } = config; - - Ok(Self { - thread: None, - dbus_name: dbus_name.to_string(), - friendly_name: display_name.to_string(), - }) - } - - /// Attach the media control events to a handler. - pub fn attach(&mut self, event_handler: F) -> Result<(), Error> - where - F: Fn(MediaControlEvent) + Send + 'static, - { - self.detach()?; - - let dbus_name = self.dbus_name.clone(); - let friendly_name = self.friendly_name.clone(); - let (event_channel, rx) = mpsc::channel(); - - self.thread = Some(ServiceThreadHandle { - event_channel, - thread: thread::spawn(move || { - run_service(dbus_name, friendly_name, event_handler, rx).unwrap() - }), - }); - Ok(()) - } - /// Detach the event handler. - pub fn detach(&mut self) -> Result<(), Error> { - if let Some(ServiceThreadHandle { - event_channel, - thread, - }) = self.thread.take() - { - event_channel.send(InternalEvent::Kill).unwrap(); - thread.join().unwrap(); - } - Ok(()) - } - - /// Set the current playback status. - pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { - self.send_internal_event(InternalEvent::ChangePlayback(playback)); - Ok(()) - } - - /// Set the metadata of the currently playing media item. - pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { - self.send_internal_event(InternalEvent::ChangeMetadata(metadata.into())); - Ok(()) - } - - // TODO: result - fn send_internal_event(&mut self, event: InternalEvent) { - let channel = &self.thread.as_ref().unwrap().event_channel; - channel.send(event).unwrap(); - } -} - -use dbus::arg::{RefArg, Variant}; -use dbus::blocking::Connection; -use dbus::channel::{MatchingReceiver, Sender}; -use dbus::ffidisp::stdintf::org_freedesktop_dbus::PropertiesPropertiesChanged; -use dbus::message::SignalArgs; -use dbus::Path; -use dbus_crossroads::Crossroads; -use dbus_crossroads::IfaceBuilder; - -fn run_service( - dbus_name: String, - friendly_name: String, - event_handler: F, - event_channel: mpsc::Receiver, -) -> Result<(), dbus::Error> -where - F: Fn(MediaControlEvent) + Send + 'static, -{ - let event_handler = Arc::new(Mutex::new(event_handler)); - let seeked_signal = Arc::new(Mutex::new(None)); - - let c = Connection::new_session()?; - c.request_name( - format!("org.mpris.MediaPlayer2.{}", dbus_name), - false, - true, - false, - )?; - - let mut cr = Crossroads::new(); - - let app_interface = cr.register("org.mpris.MediaPlayer2", { - let event_handler = event_handler.clone(); - - move |b| { - b.property("Identity") - .get(move |_, _| Ok(friendly_name.clone())); - - register_method(b, &event_handler, "Raise", MediaControlEvent::Raise); - register_method(b, &event_handler, "Quit", MediaControlEvent::Quit); - - // TODO: allow user to set these properties - b.property("CanQuit") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanRaise") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("HasTracklist") - .get(|_, _| Ok(false)) - .emits_changed_true(); - b.property("SupportedUriSchemes") - .get(move |_, _| Ok(&[] as &[String])) - .emits_changed_true(); - b.property("SupportedMimeTypes") - .get(move |_, _| Ok(&[] as &[String])) - .emits_changed_true(); - } - }); - - let mut state = ServiceState { - metadata: Default::default(), - metadata_dict: HashMap::new(), - playback_status: MediaPlayback::Stopped, - }; - - state.set_metadata(Default::default()); - - let state = Arc::new(Mutex::new(state)); - - let player_interface = cr.register("org.mpris.MediaPlayer2.Player", |b| { - register_method(b, &event_handler, "Next", MediaControlEvent::Next); - register_method(b, &event_handler, "Previous", MediaControlEvent::Previous); - register_method(b, &event_handler, "Pause", MediaControlEvent::Pause); - register_method(b, &event_handler, "PlayPause", MediaControlEvent::Toggle); - register_method(b, &event_handler, "Stop", MediaControlEvent::Stop); - register_method(b, &event_handler, "Play", MediaControlEvent::Play); - - b.method("Seek", ("Offset",), (), { - let event_handler = event_handler.clone(); - - move |ctx, _, (offset,): (i64,)| { - let abs_offset = offset.abs() as u64; - let direction = if offset > 0 { - SeekDirection::Forward - } else { - SeekDirection::Backward - }; - - (event_handler.lock().unwrap())(MediaControlEvent::SeekBy( - direction, - Duration::from_micros(abs_offset), - )); - ctx.push_msg(ctx.make_signal("Seeked", ())); - Ok(()) - } - }); - - b.method("SetPosition", ("TrackId", "Position"), (), { - let state = state.clone(); - let event_handler = event_handler.clone(); - - move |_, _, (_trackid, position): (Path, i64)| { - let state = state.lock().unwrap(); - - // According to the MPRIS specification: - - // TODO: If the TrackId argument is not the same as the current - // trackid, the call is ignored as stale. - // (Maybe it should be optional?) - - if let Some(duration) = state.metadata.duration { - // If the Position argument is greater than the track length, do nothing. - if position > duration { - return Ok(()); - } - } - - // If the Position argument is less than 0, do nothing. - if let Ok(position) = u64::try_from(position) { - let position = Duration::from_micros(position); - - (event_handler.lock().unwrap())(MediaControlEvent::SetPosition(MediaPosition( - position, - ))); - } - Ok(()) - } - }); - - b.method("OpenUri", ("Uri",), (), { - let event_handler = event_handler.clone(); - - move |_, _, (uri,): (String,)| { - (event_handler.lock().unwrap())(MediaControlEvent::OpenUri(uri)); - Ok(()) - } - }); - - *seeked_signal.lock().unwrap() = Some(b.signal::<(String,), _>("Seeked", ("x",)).msg_fn()); - - b.property("PlaybackStatus") - .get({ - let state = state.clone(); - move |_, _| { - let state = state.lock().unwrap(); - Ok(state.get_playback_status().to_string()) - } - }) - .emits_changed_true(); - - b.property("Rate").get(|_, _| Ok(1.0)).emits_changed_true(); - - b.property("Metadata") - .get({ - let state = state.clone(); - move |_, _| Ok(create_metadata_dict(&state.lock().unwrap().metadata)) - }) - .emits_changed_true(); - - b.property("Volume") - .get(|_, _| Ok(1.0)) - .set(|_, _, _| Ok(Some(1.0))) - .emits_changed_true(); - - b.property("Position").get({ - let state = state.clone(); - move |_, _| { - let state = state.lock().unwrap(); - let progress: i64 = match state.playback_status { - MediaPlayback::Playing { - progress: Some(progress), - } - | MediaPlayback::Paused { - progress: Some(progress), - } => progress.0.as_micros(), - _ => 0, - } - .try_into() - .unwrap(); - Ok(progress) - } - }); - - b.property("MinimumRate") - .get(|_, _| Ok(1.0)) - .emits_changed_true(); - b.property("MaximumRate") - .get(|_, _| Ok(1.0)) - .emits_changed_true(); - - b.property("CanGoNext") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanGoPrevious") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanPlay") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanPause") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanSeek") - .get(|_, _| Ok(true)) - .emits_changed_true(); - b.property("CanControl") - .get(|_, _| Ok(true)) - .emits_changed_true(); - }); - - cr.insert( - "/org/mpris/MediaPlayer2", - &[app_interface, player_interface], - (), - ); - - c.start_receive( - dbus::message::MatchRule::new_method_call(), - Box::new(move |msg, conn| { - cr.handle_message(msg, conn).unwrap(); - true - }), - ); - - loop { - if let Ok(event) = event_channel.recv_timeout(Duration::from_millis(10)) { - if event == InternalEvent::Kill { - break; - } - - let mut changed_properties = HashMap::new(); - - match event { - InternalEvent::ChangeMetadata(metadata) => { - let mut state = state.lock().unwrap(); - state.set_metadata(metadata); - changed_properties.insert( - "Metadata".to_owned(), - Variant(state.metadata_dict.box_clone()), - ); - } - InternalEvent::ChangePlayback(playback) => { - let mut state = state.lock().unwrap(); - state.playback_status = playback; - changed_properties.insert( - "PlaybackStatus".to_owned(), - Variant(Box::new(state.get_playback_status().to_string())), - ); - } - _ => (), - } - - let properties_changed = PropertiesPropertiesChanged { - interface_name: "org.mpris.MediaPlayer2.Player".to_owned(), - changed_properties, - invalidated_properties: Vec::new(), - }; - - c.send( - properties_changed.to_emit_message(&Path::new("/org/mpris/MediaPlayer2").unwrap()), - ) - .unwrap(); - } - c.process(Duration::from_millis(1000))?; - } - - Ok(()) -} - -fn register_method( - b: &mut IfaceBuilder<()>, - event_handler: &Arc>, - name: &'static str, - event: MediaControlEvent, -) where - F: Fn(MediaControlEvent) + Send + 'static, -{ - let event_handler = event_handler.clone(); - - b.method(name, (), (), move |_, _, _: ()| { - (event_handler.lock().unwrap())(event.clone()); - Ok(()) - }); -} diff --git a/src/platform/mpris/dbus/interfaces.rs b/src/platform/mpris/dbus/interfaces.rs new file mode 100644 index 0000000..f8527bf --- /dev/null +++ b/src/platform/mpris/dbus/interfaces.rs @@ -0,0 +1,236 @@ +use std::{ + convert::{TryFrom, TryInto}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use dbus::Path; +use dbus_crossroads::{Crossroads, IfaceBuilder}; + +use crate::{MediaControlEvent, MediaPlayback, MediaPosition, SeekDirection}; + +use super::{create_metadata_dict, ServiceState}; + +// TODO: This type is super messed up, but it's the only way to get seeking working properly +// on graphical media controls using dbus-crossroads. +pub type SeekedSignal = + Arc, &(String,)) -> dbus::Message + Send + Sync>>>>; + +pub fn register_methods( + state: &Arc>, + event_handler: &Arc>, + friendly_name: String, + seeked_signal: SeekedSignal, +) -> Crossroads +where + F: Fn(MediaControlEvent) + Send + 'static, +{ + let mut cr = Crossroads::new(); + let app_interface = cr.register("org.mpris.MediaPlayer2", { + let event_handler = event_handler.clone(); + + move |b| { + b.property("Identity") + .get(move |_, _| Ok(friendly_name.clone())); + + register_method(b, &event_handler, "Raise", MediaControlEvent::Raise); + register_method(b, &event_handler, "Quit", MediaControlEvent::Quit); + + // TODO: allow user to set these properties + b.property("CanQuit") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanRaise") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("HasTracklist") + .get(|_, _| Ok(false)) + .emits_changed_true(); + b.property("SupportedUriSchemes") + .get(move |_, _| Ok(&[] as &[String])) + .emits_changed_true(); + b.property("SupportedMimeTypes") + .get(move |_, _| Ok(&[] as &[String])) + .emits_changed_true(); + } + }); + + let player_interface = cr.register("org.mpris.MediaPlayer2.Player", |b| { + register_method(b, event_handler, "Next", MediaControlEvent::Next); + register_method(b, event_handler, "Previous", MediaControlEvent::Previous); + register_method(b, event_handler, "Pause", MediaControlEvent::Pause); + register_method(b, event_handler, "PlayPause", MediaControlEvent::Toggle); + register_method(b, event_handler, "Stop", MediaControlEvent::Stop); + register_method(b, event_handler, "Play", MediaControlEvent::Play); + + b.method("Seek", ("Offset",), (), { + let event_handler = event_handler.clone(); + + move |ctx, _, (offset,): (i64,)| { + let abs_offset = offset.unsigned_abs(); + let direction = if offset > 0 { + SeekDirection::Forward + } else { + SeekDirection::Backward + }; + + (event_handler.lock().unwrap())(MediaControlEvent::SeekBy( + direction, + Duration::from_micros(abs_offset), + )); + ctx.push_msg(ctx.make_signal("Seeked", ())); + Ok(()) + } + }); + + b.method("SetPosition", ("TrackId", "Position"), (), { + let state = state.clone(); + let event_handler = event_handler.clone(); + + move |_, _, (_trackid, position): (Path, i64)| { + let state = state.lock().unwrap(); + + // According to the MPRIS specification: + + // TODO: If the TrackId argument is not the same as the current + // trackid, the call is ignored as stale. + // (Maybe it should be optional?) + + if let Some(duration) = state.metadata.duration { + // If the Position argument is greater than the track length, do nothing. + if position > duration { + return Ok(()); + } + } + + // If the Position argument is less than 0, do nothing. + if let Ok(position) = u64::try_from(position) { + let position = Duration::from_micros(position); + + (event_handler.lock().unwrap())(MediaControlEvent::SetPosition(MediaPosition( + position, + ))); + } + Ok(()) + } + }); + + b.method("OpenUri", ("Uri",), (), { + let event_handler = event_handler.clone(); + + move |_, _, (uri,): (String,)| { + (event_handler.lock().unwrap())(MediaControlEvent::OpenUri(uri)); + Ok(()) + } + }); + + *seeked_signal.lock().unwrap() = Some(b.signal::<(String,), _>("Seeked", ("x",)).msg_fn()); + + b.property("PlaybackStatus") + .get({ + let state = state.clone(); + move |_, _| { + let state = state.lock().unwrap(); + Ok(state.get_playback_status().to_string()) + } + }) + .emits_changed_true(); + + b.property("Rate").get(|_, _| Ok(1.0)).emits_changed_true(); + + b.property("Metadata") + .get({ + let state = state.clone(); + move |_, _| Ok(create_metadata_dict(&state.lock().unwrap().metadata)) + }) + .emits_changed_true(); + + b.property("Volume") + .get({ + let state = state.clone(); + move |_, _| { + let state = state.lock().unwrap(); + Ok(state.volume) + } + }) + .set({ + let event_handler = event_handler.clone(); + move |_, _, volume: f64| { + (event_handler.lock().unwrap())(MediaControlEvent::SetVolume(volume)); + Ok(Some(volume)) + } + }) + .emits_changed_true(); + + b.property("Position").get({ + let state = state.clone(); + move |_, _| { + let state = state.lock().unwrap(); + let progress: i64 = match state.playback_status { + MediaPlayback::Playing { + progress: Some(progress), + } + | MediaPlayback::Paused { + progress: Some(progress), + } => progress.0.as_micros(), + _ => 0, + } + .try_into() + .unwrap(); + Ok(progress) + } + }); + + b.property("MinimumRate") + .get(|_, _| Ok(1.0)) + .emits_changed_true(); + b.property("MaximumRate") + .get(|_, _| Ok(1.0)) + .emits_changed_true(); + + b.property("CanGoNext") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanGoPrevious") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanPlay") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanPause") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanSeek") + .get(|_, _| Ok(true)) + .emits_changed_true(); + b.property("CanControl") + .get(|_, _| Ok(true)) + .emits_changed_true(); + }); + + cr.insert( + "/org/mpris/MediaPlayer2", + &[app_interface, player_interface], + (), + ); + + seeked_signal.lock().ok(); + + cr +} + +fn register_method( + b: &mut IfaceBuilder<()>, + event_handler: &Arc>, + name: &'static str, + event: MediaControlEvent, +) where + F: Fn(MediaControlEvent) + Send + 'static, +{ + let event_handler = event_handler.clone(); + + b.method(name, (), (), move |_, _, _: ()| { + (event_handler.lock().unwrap())(event.clone()); + Ok(()) + }); +} diff --git a/src/platform/mpris/dbus/mod.rs b/src/platform/mpris/dbus/mod.rs new file mode 100644 index 0000000..66c18be --- /dev/null +++ b/src/platform/mpris/dbus/mod.rs @@ -0,0 +1,294 @@ +mod interfaces; + +use dbus::arg::{RefArg, Variant}; +use dbus::blocking::Connection; +use dbus::channel::{MatchingReceiver, Sender}; +use dbus::ffidisp::stdintf::org_freedesktop_dbus::PropertiesPropertiesChanged; +use dbus::message::SignalArgs; +use dbus::Path; +use std::collections::HashMap; +use std::convert::From; +use std::convert::TryInto; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crate::{MediaControlEvent, MediaMetadata, MediaPlayback, PlatformConfig}; + +/// A platform-specific error. +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("internal D-Bus error: {0}")] + DbusError(#[from] dbus::Error), + #[error("D-bus service thread not running. Run MediaControls::attach()")] + ThreadNotRunning, + // NOTE: For now this error is not very descriptive. For now we can't do much about it + // since the panic message returned by JoinHandle::join does not implement Debug/Display, + // thus we cannot print it, though perhaps there is another way. I will leave this error here, + // to at least be able to catch it, but it is preferable to have this thread *not panic* at all. + #[error("D-Bus service thread panicked")] + ThreadPanicked, +} + +/// A handle to OS media controls. +pub struct MediaControls { + thread: Option, + dbus_name: String, + friendly_name: String, +} + +struct ServiceThreadHandle { + event_channel: mpsc::Sender, + thread: JoinHandle>, +} + +#[derive(Clone, PartialEq, Debug)] +enum InternalEvent { + ChangeMetadata(OwnedMetadata), + ChangePlayback(MediaPlayback), + ChangeVolume(f64), + Kill, +} + +#[derive(Debug)] +struct ServiceState { + metadata: OwnedMetadata, + metadata_dict: HashMap>>, + playback_status: MediaPlayback, + volume: f64, +} + +impl ServiceState { + fn set_metadata(&mut self, metadata: OwnedMetadata) { + self.metadata_dict = create_metadata_dict(&metadata); + self.metadata = metadata; + } + + fn get_playback_status(&self) -> &'static str { + match self.playback_status { + MediaPlayback::Playing { .. } => "Playing", + MediaPlayback::Paused { .. } => "Paused", + MediaPlayback::Stopped => "Stopped", + } + } +} + +fn create_metadata_dict(metadata: &OwnedMetadata) -> HashMap>> { + let mut dict = HashMap::>>::new(); + + let mut insert = |k: &str, v| dict.insert(k.to_string(), Variant(v)); + + let OwnedMetadata { + ref title, + ref album, + ref artist, + ref cover_url, + ref duration, + } = metadata; + + // TODO: this is just a workaround to enable SetPosition. + let path = Path::new("/").unwrap(); + + // MPRIS + insert("mpris:trackid", Box::new(path)); + + if let Some(length) = duration { + insert("mpris:length", Box::new(*length)); + } + if let Some(cover_url) = cover_url { + insert("mpris:artUrl", Box::new(cover_url.clone())); + } + + // Xesam + if let Some(title) = title { + insert("xesam:title", Box::new(title.clone())); + } + if let Some(artist) = artist { + insert("xesam:artist", Box::new(vec![artist.clone()])); + } + if let Some(album) = album { + insert("xesam:album", Box::new(album.clone())); + } + + dict +} + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +struct OwnedMetadata { + pub title: Option, + pub album: Option, + pub artist: Option, + pub cover_url: Option, + pub duration: Option, +} + +impl From> for OwnedMetadata { + fn from(other: MediaMetadata) -> Self { + OwnedMetadata { + title: other.title.map(|s| s.to_string()), + artist: other.artist.map(|s| s.to_string()), + album: other.album.map(|s| s.to_string()), + cover_url: other.cover_url.map(|s| s.to_string()), + // TODO: This should probably not have an unwrap + duration: other.duration.map(|d| d.as_micros().try_into().unwrap()), + } + } +} + +impl MediaControls { + /// Create media controls with the specified config. + pub fn new(config: PlatformConfig) -> Result { + let PlatformConfig { + dbus_name, + display_name, + .. + } = config; + + Ok(Self { + thread: None, + dbus_name: dbus_name.to_string(), + friendly_name: display_name.to_string(), + }) + } + + /// Attach the media control events to a handler. + pub fn attach(&mut self, event_handler: F) -> Result<(), Error> + where + F: Fn(MediaControlEvent) + Send + 'static, + { + self.detach()?; + + let dbus_name = self.dbus_name.clone(); + let friendly_name = self.friendly_name.clone(); + let (event_channel, rx) = mpsc::channel(); + + // Check if the connection can be created BEFORE spawning the new thread + let conn = Connection::new_session()?; + let name = format!("org.mpris.MediaPlayer2.{}", dbus_name); + conn.request_name(name, false, true, false)?; + + self.thread = Some(ServiceThreadHandle { + event_channel, + thread: thread::spawn(move || run_service(conn, friendly_name, event_handler, rx)), + }); + Ok(()) + } + + /// Detach the event handler. + pub fn detach(&mut self) -> Result<(), Error> { + if let Some(ServiceThreadHandle { + event_channel, + thread, + }) = self.thread.take() + { + // We don't care about the result of this event, since we immedieately + // check if the thread has panicked on the next line. + event_channel.send(InternalEvent::Kill).ok(); + // One error in case the thread panics, and the other one in case the + // thread has returned an error. + thread.join().map_err(|_| Error::ThreadPanicked)??; + } + Ok(()) + } + + /// Set the current playback status. + pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { + self.send_internal_event(InternalEvent::ChangePlayback(playback)) + } + + /// Set the metadata of the currently playing media item. + pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { + self.send_internal_event(InternalEvent::ChangeMetadata(metadata.into())) + } + + /// Set the volume level (0.0-1.0) (Only available on MPRIS) + pub fn set_volume(&mut self, volume: f64) -> Result<(), Error> { + self.send_internal_event(InternalEvent::ChangeVolume(volume)) + } + + fn send_internal_event(&mut self, event: InternalEvent) -> Result<(), Error> { + let thread = &self.thread.as_ref().ok_or(Error::ThreadNotRunning)?; + thread + .event_channel + .send(event) + .map_err(|_| Error::ThreadPanicked) + } +} + +fn run_service( + conn: Connection, + friendly_name: String, + event_handler: F, + event_channel: mpsc::Receiver, +) -> Result<(), Error> +where + F: Fn(MediaControlEvent) + Send + 'static, +{ + let state = Arc::new(Mutex::new(ServiceState { + metadata: Default::default(), + metadata_dict: create_metadata_dict(&Default::default()), + playback_status: MediaPlayback::Stopped, + volume: 1.0, + })); + let event_handler = Arc::new(Mutex::new(event_handler)); + let seeked_signal = Arc::new(Mutex::new(None)); + + let mut cr = interfaces::register_methods(&state, &event_handler, friendly_name, seeked_signal); + + conn.start_receive( + dbus::message::MatchRule::new_method_call(), + Box::new(move |msg, conn| { + cr.handle_message(msg, conn).unwrap(); + true + }), + ); + + loop { + if let Ok(event) = event_channel.recv_timeout(Duration::from_millis(10)) { + if event == InternalEvent::Kill { + break; + } + + let mut changed_properties = HashMap::new(); + + match event { + InternalEvent::ChangeMetadata(metadata) => { + let mut state = state.lock().unwrap(); + state.set_metadata(metadata); + changed_properties.insert( + "Metadata".to_owned(), + Variant(state.metadata_dict.box_clone()), + ); + } + InternalEvent::ChangePlayback(playback) => { + let mut state = state.lock().unwrap(); + state.playback_status = playback; + changed_properties.insert( + "PlaybackStatus".to_owned(), + Variant(Box::new(state.get_playback_status().to_string())), + ); + } + InternalEvent::ChangeVolume(volume) => { + let mut state = state.lock().unwrap(); + state.volume = volume; + changed_properties.insert("Volume".to_owned(), Variant(Box::new(volume))); + } + _ => (), + } + + let properties_changed = PropertiesPropertiesChanged { + interface_name: "org.mpris.MediaPlayer2.Player".to_owned(), + changed_properties, + invalidated_properties: Vec::new(), + }; + + conn.send( + properties_changed.to_emit_message(&Path::new("/org/mpris/MediaPlayer2").unwrap()), + ) + .ok(); + } + conn.process(Duration::from_millis(1000))?; + } + + Ok(()) +} diff --git a/src/platform/mpris/zbus.rs b/src/platform/mpris/zbus.rs index b5ca004..d77a44f 100644 --- a/src/platform/mpris/zbus.rs +++ b/src/platform/mpris/zbus.rs @@ -29,10 +29,11 @@ struct ServiceThreadHandle { thread: JoinHandle<()>, } -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Debug)] enum InternalEvent { ChangeMetadata(OwnedMetadata), ChangePlayback(MediaPlayback), + ChangeVolume(f64), Kill, } @@ -40,6 +41,7 @@ enum InternalEvent { struct ServiceState { metadata: OwnedMetadata, playback_status: MediaPlayback, + volume: f64, } #[derive(Clone, PartialEq, Eq, Debug, Default)] @@ -125,6 +127,12 @@ impl MediaControls { Ok(()) } + /// Set the volume level (0.0 - 1.0) (Only available on MPRIS) + pub fn set_volume(&mut self, volume: f64) -> Result<(), Error> { + self.send_internal_event(InternalEvent::ChangeVolume(volume)); + Ok(()) + } + // TODO: result fn send_internal_event(&mut self, event: InternalEvent) { let channel = &self.thread.as_ref().unwrap().event_channel; @@ -307,7 +315,12 @@ impl PlayerInterface { #[dbus_interface(property)] fn volume(&self) -> f64 { - 1.0 + self.state.volume + } + + #[dbus_interface(property)] + fn set_volume(&self, volume: f64) { + self.send_event(MediaControlEvent::SetVolume(volume)); } #[dbus_interface(property)] @@ -381,6 +394,7 @@ async fn run_service( state: ServiceState { metadata: OwnedMetadata::default(), playback_status: MediaPlayback::Stopped, + volume: 1.0, }, event_handler, }; @@ -416,7 +430,11 @@ async fn run_service( interface.state.playback_status = playback; interface.playback_status_changed(&ctxt).await?; } - _ => (), + InternalEvent::ChangeVolume(volume) => { + interface.state.volume = volume; + interface.volume_changed(&ctxt).await?; + } + InternalEvent::Kill => (), } } } diff --git a/tests/playerctl_script.sh b/tests/playerctl_script.sh new file mode 100755 index 0000000..e7a93e0 --- /dev/null +++ b/tests/playerctl_script.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# This scripts requires playerctl and dbus-send + +alias playerctl="playerctl -p my_player " + +playerctl metadata +playerctl play +playerctl pause +playerctl play-pause +playerctl next +playerctl previous +playerctl stop +playerctl position 30 +playerctl position 10- +playerctl position 10+ +playerctl volume 0.5 +playerctl open "https://testlink.com" +# TODO: Shuffle and repeat. +# playerctl shuffle +# playerctl repeat + +# The following are commands not supported by playerctl, thus we use dbus-send +call() { + dbus-send --dest=org.mpris.MediaPlayer2.my_player --print-reply /org/mpris/MediaPlayer2 "$1" +} + +call org.mpris.MediaPlayer2.Raise +call org.mpris.MediaPlayer2.Quit