├── rustfmt.toml ├── .gitignore ├── rust-toolchain.toml ├── src ├── platform │ ├── mpris │ │ ├── dbus │ │ │ ├── mod.rs │ │ │ ├── interfaces.rs │ │ │ └── controls.rs │ │ ├── mod.rs │ │ └── zbus.rs │ ├── mod.rs │ ├── empty │ │ └── mod.rs │ ├── windows │ │ └── mod.rs │ └── macos │ │ └── mod.rs ├── config.rs └── lib.rs ├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── build.yml ├── tests └── playerctl_script.sh ├── examples ├── detach_on_drop.rs ├── window.rs └── print_events.rs ├── LICENSE ├── Cargo.toml ├── CHANGELOG.md └── README.md /rustfmt.toml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.67" 3 | -------------------------------------------------------------------------------- /src/platform/mpris/dbus/mod.rs: -------------------------------------------------------------------------------- 1 | mod interfaces; 2 | 3 | mod controls; 4 | pub use controls::MediaControls; 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.rs text 4 | *.toml text 5 | *.md text 6 | *.sh text 7 | 8 | *.png binary 9 | *.jpg binary 10 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::c_void; 2 | 3 | /// OS-specific configuration needed to create media controls. 4 | #[derive(Debug)] 5 | pub struct PlatformConfig<'a> { 6 | /// The name to be displayed to the user. (*Required on Linux*) 7 | pub display_name: &'a str, 8 | /// Should follow [the D-Bus spec](https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus). (*Required on Linux*) 9 | pub dbus_name: &'a str, 10 | /// An HWND. (*Required on Windows*) 11 | pub hwnd: Option<*mut c_void>, 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release on tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | release: 10 | permissions: write-all 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set env 15 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 16 | - name: Install ripgrep 17 | run: sudo apt-get install -y ripgrep 18 | - name: Generate release notes 19 | run: rg --color=never -N -u --multiline --multiline-dotall '## \['${RELEASE_VERSION}'\]\n\n(.*?)^## \[' -or '$1' CHANGELOG.md > RELEASE_NOTES.md 20 | - uses: softprops/action-gh-release@v2 21 | with: 22 | body_path: RELEASE_NOTES.md 23 | -------------------------------------------------------------------------------- /tests/playerctl_script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This scripts requires playerctl and dbus-send 3 | 4 | alias playerctl="playerctl -p my_player " 5 | 6 | playerctl metadata 7 | playerctl play 8 | playerctl pause 9 | playerctl play-pause 10 | playerctl next 11 | playerctl previous 12 | playerctl stop 13 | playerctl position 30 14 | playerctl position 10- 15 | playerctl position 10+ 16 | playerctl volume 0.5 17 | playerctl open "https://testlink.com" 18 | # TODO: Shuffle and repeat. 19 | # playerctl shuffle 20 | # playerctl repeat 21 | 22 | # The following are commands not supported by playerctl, thus we use dbus-send 23 | call() { 24 | dbus-send --dest=org.mpris.MediaPlayer2.my_player --print-reply /org/mpris/MediaPlayer2 "$1" 25 | } 26 | 27 | call org.mpris.MediaPlayer2.Raise 28 | call org.mpris.MediaPlayer2.Quit 29 | -------------------------------------------------------------------------------- /src/platform/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_inception)] 2 | pub use self::platform::*; 3 | 4 | #[cfg(target_os = "windows")] 5 | #[path = "windows/mod.rs"] 6 | mod platform; 7 | 8 | #[cfg(any(target_os = "macos", target_os = "ios"))] 9 | #[path = "macos/mod.rs"] 10 | mod platform; 11 | 12 | #[cfg(all( 13 | unix, 14 | not(any(target_os = "macos", target_os = "ios", target_os = "android")) 15 | ))] 16 | #[path = "mpris/mod.rs"] 17 | mod platform; 18 | 19 | #[cfg(all( 20 | not(target_os = "linux"), 21 | not(target_os = "netbsd"), 22 | not(target_os = "freebsd"), 23 | not(target_os = "openbsd"), 24 | not(target_os = "dragonfly"), 25 | not(target_os = "windows"), 26 | not(target_os = "macos"), 27 | not(target_os = "ios") 28 | ))] 29 | #[path = "empty/mod.rs"] 30 | mod platform; 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [macOS-latest, windows-latest, ubuntu-latest] 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: dtolnay/rust-toolchain@1.67 20 | - uses: Swatinem/rust-cache@v2 21 | - name: Install Dependencies 22 | run: | 23 | sudo apt-get update 24 | sudo apt-get install -y pkg-config libdbus-1-dev libfontconfig1-dev 25 | if: ${{ runner.os == 'Linux' }} 26 | - name: Build 27 | run: cargo build --release --all-targets --verbose 28 | - name: Build zbus 29 | run: cargo build --release --all-targets --verbose --no-default-features --features=use_zbus 30 | if: ${{ runner.os == 'Linux' }} 31 | -------------------------------------------------------------------------------- /examples/detach_on_drop.rs: -------------------------------------------------------------------------------- 1 | use souvlaki::{MediaControls, PlatformConfig}; 2 | use std::thread::sleep; 3 | use std::time::Duration; 4 | 5 | fn main() { 6 | { 7 | #[cfg(not(target_os = "windows"))] 8 | let hwnd = None; 9 | 10 | #[cfg(target_os = "windows")] 11 | let hwnd = { 12 | use raw_window_handle::Win32WindowHandle; 13 | 14 | let handle: Win32WindowHandle = unimplemented!(); 15 | Some(handle.hwnd) 16 | }; 17 | 18 | let config = PlatformConfig { 19 | dbus_name: "my_player", 20 | display_name: "My Player", 21 | hwnd, 22 | }; 23 | 24 | let mut controls = MediaControls::new(config).unwrap(); 25 | 26 | controls.attach(|_| println!("Received message")).unwrap(); 27 | println!("Attached"); 28 | 29 | for i in 0..5 { 30 | println!("Main thread sleeping: {}/4", i); 31 | sleep(Duration::from_secs(1)); 32 | } 33 | } 34 | println!("Dropped and detached"); 35 | sleep(Duration::from_secs(2)); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aldo Acevedo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/platform/empty/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{MediaControlEvent, MediaMetadata, MediaPlayback, PlatformConfig}; 2 | 3 | /// A platform-specific error. 4 | #[derive(Debug)] 5 | pub struct Error; 6 | 7 | impl std::fmt::Display for Error { 8 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 9 | write!(f, "Error") 10 | } 11 | } 12 | 13 | impl std::error::Error for Error {} 14 | 15 | /// A handle to OS media controls. 16 | pub struct MediaControls; 17 | 18 | impl MediaControls { 19 | /// Create media controls with the specified config. 20 | pub fn new(_config: PlatformConfig) -> Result { 21 | Ok(Self) 22 | } 23 | 24 | /// Attach the media control events to a handler. 25 | pub fn attach(&mut self, _event_handler: F) -> Result<(), Error> 26 | where 27 | F: Fn(MediaControlEvent) + Send + 'static, 28 | { 29 | Ok(()) 30 | } 31 | 32 | /// Detach the event handler. 33 | pub fn detach(&mut self) -> Result<(), Error> { 34 | Ok(()) 35 | } 36 | 37 | /// Set the current playback status. 38 | pub fn set_playback(&mut self, _playback: MediaPlayback) -> Result<(), Error> { 39 | Ok(()) 40 | } 41 | 42 | /// Set the metadata of the currently playing media item. 43 | pub fn set_metadata(&mut self, _metadata: MediaMetadata) -> Result<(), Error> { 44 | Ok(()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/platform/mpris/mod.rs: -------------------------------------------------------------------------------- 1 | #![cfg(all(unix, not(target_os = "macos")))] 2 | 3 | #[cfg(not(any(feature = "dbus", feature = "zbus")))] 4 | compile_error!("either feature \"dbus\" or feature \"zbus\" are required"); 5 | 6 | #[cfg(all(feature = "dbus", feature = "zbus"))] 7 | compile_error!("feature \"dbus\" and feature \"zbus\" are mutually exclusive"); 8 | 9 | #[cfg(feature = "zbus")] 10 | mod zbus; 11 | #[cfg(feature = "zbus")] 12 | pub use self::zbus::*; 13 | #[cfg(feature = "zbus")] 14 | extern crate zbus as zbus_crate; 15 | 16 | #[cfg(feature = "dbus")] 17 | mod dbus; 18 | #[cfg(feature = "dbus")] 19 | pub use self::dbus::*; 20 | #[cfg(feature = "dbus")] 21 | extern crate dbus as dbus_crate; 22 | 23 | /// A platform-specific error. 24 | #[derive(thiserror::Error, Debug)] 25 | pub enum Error { 26 | #[error("internal D-Bus error: {0}")] 27 | #[cfg(feature = "dbus")] 28 | DbusError(#[from] dbus_crate::Error), 29 | #[error("internal D-Bus error: {0}")] 30 | #[cfg(feature = "zbus")] 31 | DbusError(#[from] zbus_crate::Error), 32 | #[error("D-bus service thread not running. Run MediaControls::attach()")] 33 | ThreadNotRunning, 34 | // NOTE: For now this error is not very descriptive. For now we can't do much about it 35 | // since the panic message returned by JoinHandle::join does not implement Debug/Display, 36 | // thus we cannot print it, though perhaps there is another way. I will leave this error here, 37 | // to at least be able to catch it, but it is preferable to have this thread *not panic* at all. 38 | #[error("D-Bus service thread panicked")] 39 | ThreadPanicked, 40 | } 41 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "souvlaki" 3 | version = "0.8.3" 4 | authors = ["Sinono3 "] 5 | edition = "2018" 6 | description = "A cross-platform media key and metadata handling library." 7 | repository = "https://github.com/Sinono3/souvlaki" 8 | documentation = "https://docs.rs/souvlaki" 9 | license = "MIT" 10 | rust-version = "1.67" 11 | 12 | [target.'cfg(target_os = "windows")'.dependencies.windows] 13 | version = "0.44" 14 | features = [ 15 | "Foundation", 16 | "Media", 17 | "Win32_Foundation", 18 | "Win32_System_WinRT", 19 | "Storage_Streams", 20 | ] 21 | 22 | [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] 23 | block = "0.1.6" 24 | cocoa = "0.24.0" 25 | core-graphics = "0.22.2" 26 | dispatch = "0.2.0" 27 | objc = "0.2.7" 28 | base64 = "0.22.1" 29 | 30 | [target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies] 31 | dbus = { version = "0.9.5", optional = true } 32 | dbus-crossroads = { version = "0.5.0", optional = true } 33 | zbus = { version = "3.9", optional = true } 34 | zvariant = { version = "3.10", optional = true } 35 | pollster = { version = "0.3", optional = true } 36 | thiserror = "1.0" 37 | 38 | [features] 39 | default = ["use_dbus"] 40 | use_dbus = ["dbus", "dbus-crossroads"] 41 | use_zbus = ["zbus", "zvariant", "pollster"] 42 | 43 | [dev-dependencies] 44 | winit = "0.27.0" 45 | raw-window-handle = "0.5.0" 46 | 47 | [target.'cfg(target_os = "windows")'.dev-dependencies.windows] 48 | version = "0.44" 49 | features = [ 50 | "Win32_Foundation", 51 | "Win32_Graphics_Gdi", 52 | "Win32_System_LibraryLoader", 53 | "Win32_UI_WindowsAndMessaging" 54 | ] 55 | 56 | [package.metadata.docs.rs] 57 | default-target = "x86_64-unknown-linux-gnu" 58 | targets = ["x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | Types of changes: 9 | 10 | - `Added` for new features. 11 | - `Changed` for changes in existing functionality. 12 | - `Deprecated` for soon-to-be removed features. 13 | - `Removed` for now removed features. 14 | - `Fixed` for any bug fixes. 15 | - `Security` in case of vulnerabilities. 16 | 17 | ## [Unreleased] 18 | 19 | ## [0.8.3] 20 | 21 | ### Added 22 | 23 | - rustfmt.toml to enforce formatting 24 | 25 | ### Changed 26 | 27 | - Update README/docs to clarify MacOS Usage (#60 thank you! @ILikeTeaALot) 28 | 29 | ### Fixed 30 | 31 | - Standardized line ending behavior in Git 32 | - Implement error for all error types (#63 thank you! @nick42d) 33 | - Exclude dbus on Android (#59 thank you! @will3942) 34 | 35 | ## [0.8.2] 36 | 37 | ### Fixed 38 | 39 | - Failing iOS import (#57 Thank you @will3942!) 40 | 41 | ## [0.8.1] 42 | 43 | ### Fixed 44 | 45 | - Android platform module error 46 | - Linux CI failing due to missing dependency 47 | 48 | ## [0.8.0] 49 | 50 | ### Added 51 | 52 | - iOS support (#55 Thank you @XMLHexagram !) 53 | - Error handling for zbus, and unify dbus/zbus Error type (#52 Thank you @taoky !) 54 | 55 | ### Fixed 56 | 57 | - Clippy lints (#51 Thank you @LucasFA !) 58 | 59 | ## [0.7.3] 60 | 61 | ### Added 62 | 63 | - Documentation for `MediaControlEvent::SetVolume` #47 64 | 65 | ## [0.7.2] 66 | 67 | ### Changed 68 | 69 | - Bumped MSRV to 1.67 70 | 71 | ## [0.7.1] 72 | 73 | ### Added 74 | 75 | - MSRV in Cargo.toml and rust-toolchain (#46) 76 | - CHANGELOG.md (#45) 77 | 78 | ### Changed 79 | 80 | - Lowered MSRV back to 1.60 from 1.74 (#46) 81 | - Updated CI to support MSRV (#46) 82 | - Updated CI dependencies 83 | 84 | ## [0.7.0] 85 | 86 | ### Added 87 | 88 | - Implemented volume control on Linux (#42) 89 | 90 | ### Changed 91 | 92 | - Refactored D-Bus module (#42) 93 | -------------------------------------------------------------------------------- /examples/window.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::mpsc, thread::sleep, time::Duration}; 2 | 3 | use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig}; 4 | use winit::{ 5 | event::{Event, WindowEvent}, 6 | event_loop::{ControlFlow, EventLoop}, 7 | window::WindowBuilder, 8 | }; 9 | 10 | struct TestApp { 11 | playing: bool, 12 | song_index: u8, 13 | } 14 | 15 | fn main() { 16 | let event_loop = EventLoop::new(); 17 | #[allow(unused_variables)] 18 | let window = WindowBuilder::new().build(&event_loop).unwrap(); 19 | 20 | #[cfg(not(target_os = "windows"))] 21 | let hwnd = None; 22 | 23 | #[cfg(target_os = "windows")] 24 | let hwnd = { 25 | use raw_window_handle::{HasRawWindowHandle, RawWindowHandle}; 26 | 27 | let handle = match window.raw_window_handle() { 28 | RawWindowHandle::Win32(h) => h, 29 | _ => unreachable!(), 30 | }; 31 | Some(handle.hwnd) 32 | }; 33 | 34 | let config = PlatformConfig { 35 | dbus_name: "my_player", 36 | display_name: "My Player", 37 | hwnd, 38 | }; 39 | 40 | let mut controls = MediaControls::new(config).unwrap(); 41 | 42 | let (tx, rx) = mpsc::sync_channel(32); 43 | let mut app = TestApp { 44 | playing: true, 45 | song_index: 0, 46 | }; 47 | 48 | controls.attach(move |e| tx.send(e).unwrap()).unwrap(); 49 | controls 50 | .set_playback(MediaPlayback::Playing { progress: None }) 51 | .unwrap(); 52 | controls 53 | .set_metadata(MediaMetadata { 54 | title: Some("When The Sun Hits"), 55 | album: Some("Souvlaki"), 56 | artist: Some("Slowdive"), 57 | duration: Some(Duration::from_secs_f64(4.0 * 60.0 + 50.0)), 58 | cover_url: Some("https://c.pxhere.com/photos/34/c1/souvlaki_authentic_greek_greek_food_mezes-497780.jpg!d"), 59 | }) 60 | .unwrap(); 61 | 62 | event_loop.run(move |event, _, control_flow| { 63 | *control_flow = ControlFlow::Poll; 64 | 65 | match event { 66 | Event::WindowEvent { 67 | event: WindowEvent::CloseRequested, 68 | .. 69 | } => *control_flow = ControlFlow::Exit, 70 | Event::MainEventsCleared => { 71 | let mut change = false; 72 | 73 | for event in rx.try_iter() { 74 | match event { 75 | MediaControlEvent::Toggle => app.playing = !app.playing, 76 | MediaControlEvent::Play => app.playing = true, 77 | MediaControlEvent::Pause => app.playing = false, 78 | MediaControlEvent::Next => app.song_index = app.song_index.wrapping_add(1), 79 | MediaControlEvent::Previous => { 80 | app.song_index = app.song_index.wrapping_sub(1) 81 | } 82 | MediaControlEvent::Stop => app.playing = false, 83 | _ => (), 84 | } 85 | change = true; 86 | } 87 | sleep(Duration::from_millis(50)); 88 | 89 | if change { 90 | controls 91 | .set_playback(if app.playing { 92 | MediaPlayback::Playing { progress: None } 93 | } else { 94 | MediaPlayback::Paused { progress: None } 95 | }) 96 | .unwrap(); 97 | 98 | eprintln!( 99 | "{} (song {})", 100 | if app.playing { "Playing" } else { "Paused" }, 101 | app.song_index 102 | ); 103 | } 104 | } 105 | _ => (), 106 | } 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | mod config; 4 | mod platform; 5 | 6 | use std::{fmt::Debug, time::Duration}; 7 | 8 | pub use config::*; 9 | pub use platform::{Error, MediaControls}; 10 | 11 | /// The status of media playback. 12 | #[derive(Clone, PartialEq, Eq, Debug)] 13 | pub enum MediaPlayback { 14 | Stopped, 15 | Paused { progress: Option }, 16 | Playing { progress: Option }, 17 | } 18 | 19 | /// The metadata of a media item. 20 | #[derive(Clone, PartialEq, Eq, Debug, Default)] 21 | pub struct MediaMetadata<'a> { 22 | pub title: Option<&'a str>, 23 | pub album: Option<&'a str>, 24 | pub artist: Option<&'a str>, 25 | /// Very platform specific. As of now, Souvlaki leaves it up to the user to change the URL depending on the platform. 26 | /// 27 | /// For Linux, we follow the MPRIS specification, which actually doesn't say much cover art apart from what's in [here](https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/#mpris:arturl). It only says that local files should start with `file://` and that it should be an UTF-8 string, which is enforced by Rust. Maybe you can look in the source code of desktop managers such as GNOME or KDE, since these read the field to display it on their media player controls. 28 | /// 29 | /// For Windows, we use the _SystemMediaTransportControlsDisplayUpdater_, which has [a thumbnail property](https://learn.microsoft.com/en-us/uwp/api/windows.media.systemmediatransportcontrolsdisplayupdater.thumbnail?view=winrt-22621#windows-media-systemmediatransportcontrolsdisplayupdater-thumbnail). It accepts multiple formats, but we choose to create it using an URI. If setting an URL starting with `file://`, the file is automatically loaded by souvlaki. 30 | /// 31 | /// For MacOS, you can look into [these lines](https://github.com/Sinono3/souvlaki/blob/384539fe83e8bf5c966192ba28e9405e3253619b/src/platform/macos/mod.rs#L131-L137) of the implementation. These lines refer to creating an [MPMediaItemArtwork](https://developer.apple.com/documentation/mediaplayer/mpmediaitemartwork) object. 32 | pub cover_url: Option<&'a str>, 33 | pub duration: Option, 34 | } 35 | 36 | /// Events sent by the OS media controls. 37 | #[derive(Clone, PartialEq, Debug)] 38 | pub enum MediaControlEvent { 39 | Play, 40 | Pause, 41 | Toggle, 42 | Next, 43 | Previous, 44 | Stop, 45 | 46 | /// Seek forward or backward by an undetermined amount. 47 | Seek(SeekDirection), 48 | /// Seek forward or backward by a certain amount. 49 | SeekBy(SeekDirection, Duration), 50 | /// Set the position/progress of the currently playing media item. 51 | SetPosition(MediaPosition), 52 | /// Sets the volume. The value is intended to be from 0.0 to 1.0. 53 | /// But other values are also accepted. **It is up to the user to 54 | /// set constraints on this value.** 55 | /// **NOTE**: If the volume event was received and correctly handled, 56 | /// the user must call [`MediaControls::set_volume`]. Note that 57 | /// this must be done only with the MPRIS backend. 58 | SetVolume(f64), 59 | /// Open the URI in the media player. 60 | OpenUri(String), 61 | 62 | /// Bring the media player's user interface to the front using any appropriate mechanism available. 63 | Raise, 64 | /// Shut down the media player. 65 | Quit, 66 | } 67 | 68 | /// An instant in a media item. 69 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] 70 | pub struct MediaPosition(pub Duration); 71 | 72 | /// The direction to seek in. 73 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 74 | pub enum SeekDirection { 75 | Forward, 76 | Backward, 77 | } 78 | 79 | impl Drop for MediaControls { 80 | fn drop(&mut self) { 81 | // Ignores errors if there are any. 82 | self.detach().ok(); 83 | } 84 | } 85 | 86 | impl Debug for MediaControls { 87 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 88 | f.write_str("MediaControls")?; 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /examples/print_events.rs: -------------------------------------------------------------------------------- 1 | use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, PlatformConfig}; 2 | 3 | fn main() { 4 | #[cfg(not(target_os = "windows"))] 5 | let hwnd = None; 6 | 7 | #[cfg(target_os = "windows")] 8 | let (hwnd, _dummy_window) = { 9 | let dummy_window = windows::DummyWindow::new().unwrap(); 10 | let handle = Some(dummy_window.handle.0 as _); 11 | (handle, dummy_window) 12 | }; 13 | 14 | let config = PlatformConfig { 15 | dbus_name: "my_player", 16 | display_name: "My Player", 17 | hwnd, 18 | }; 19 | 20 | let mut controls = MediaControls::new(config).unwrap(); 21 | 22 | // The closure must be Send and have a static lifetime. 23 | controls 24 | .attach(|event: MediaControlEvent| println!("Event received: {:?}", event)) 25 | .unwrap(); 26 | 27 | // Update the media metadata. 28 | controls 29 | .set_metadata(MediaMetadata { 30 | title: Some("Souvlaki Space Station"), 31 | artist: Some("Slowdive"), 32 | album: Some("Souvlaki"), 33 | ..Default::default() 34 | }) 35 | .unwrap(); 36 | 37 | // Your actual logic goes here. 38 | loop { 39 | std::thread::sleep(std::time::Duration::from_millis(100)); 40 | 41 | // this must be run repeatedly by your program to ensure 42 | // the Windows event queue is processed by your application 43 | #[cfg(target_os = "windows")] 44 | windows::pump_event_queue(); 45 | } 46 | 47 | // The controls automatically detach on drop. 48 | } 49 | 50 | // demonstrates how to make a minimal window to allow use of media keys on the command line 51 | #[cfg(target_os = "windows")] 52 | mod windows { 53 | use std::io::Error; 54 | use std::mem; 55 | 56 | use windows::core::PCWSTR; 57 | use windows::w; 58 | use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; 59 | use windows::Win32::System::LibraryLoader::GetModuleHandleW; 60 | use windows::Win32::UI::WindowsAndMessaging::{ 61 | CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetAncestor, 62 | IsDialogMessageW, PeekMessageW, RegisterClassExW, TranslateMessage, GA_ROOT, MSG, 63 | PM_REMOVE, WINDOW_EX_STYLE, WINDOW_STYLE, WM_QUIT, WNDCLASSEXW, 64 | }; 65 | 66 | pub struct DummyWindow { 67 | pub handle: HWND, 68 | } 69 | 70 | impl DummyWindow { 71 | pub fn new() -> Result { 72 | let class_name = w!("SimpleTray"); 73 | 74 | let handle_result = unsafe { 75 | let instance = GetModuleHandleW(None) 76 | .map_err(|e| (format!("Getting module handle failed: {e}")))?; 77 | 78 | let wnd_class = WNDCLASSEXW { 79 | cbSize: mem::size_of::() as u32, 80 | hInstance: instance, 81 | lpszClassName: PCWSTR::from(class_name), 82 | lpfnWndProc: Some(Self::wnd_proc), 83 | ..Default::default() 84 | }; 85 | 86 | if RegisterClassExW(&wnd_class) == 0 { 87 | return Err(format!( 88 | "Registering class failed: {}", 89 | Error::last_os_error() 90 | )); 91 | } 92 | 93 | let handle = CreateWindowExW( 94 | WINDOW_EX_STYLE::default(), 95 | class_name, 96 | w!(""), 97 | WINDOW_STYLE::default(), 98 | 0, 99 | 0, 100 | 0, 101 | 0, 102 | None, 103 | None, 104 | instance, 105 | None, 106 | ); 107 | 108 | if handle.0 == 0 { 109 | Err(format!( 110 | "Message only window creation failed: {}", 111 | Error::last_os_error() 112 | )) 113 | } else { 114 | Ok(handle) 115 | } 116 | }; 117 | 118 | handle_result.map(|handle| DummyWindow { handle }) 119 | } 120 | extern "system" fn wnd_proc( 121 | hwnd: HWND, 122 | msg: u32, 123 | wparam: WPARAM, 124 | lparam: LPARAM, 125 | ) -> LRESULT { 126 | unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } 127 | } 128 | } 129 | 130 | impl Drop for DummyWindow { 131 | fn drop(&mut self) { 132 | unsafe { 133 | DestroyWindow(self.handle); 134 | } 135 | } 136 | } 137 | 138 | pub fn pump_event_queue() -> bool { 139 | unsafe { 140 | let mut msg: MSG = std::mem::zeroed(); 141 | let mut has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool(); 142 | while msg.message != WM_QUIT && has_message { 143 | if !IsDialogMessageW(GetAncestor(msg.hwnd, GA_ROOT), &msg).as_bool() { 144 | TranslateMessage(&msg); 145 | DispatchMessageW(&msg); 146 | } 147 | 148 | has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool(); 149 | } 150 | 151 | msg.message == WM_QUIT 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Souvlaki [![Crates.io](https://img.shields.io/crates/v/souvlaki.svg)](https://crates.io/crates/souvlaki) [![Docs](https://docs.rs/souvlaki/badge.svg)](https://docs.rs/souvlaki) [![CI](https://github.com/Sinono3/souvlaki/actions/workflows/build.yml/badge.svg)](https://github.com/Sinono3/souvlaki/actions/workflows/build.yml) 4 | 5 | A cross-platform library for handling OS media controls and metadata. One abstraction for Linux, MacOS/iOS, Windows. 6 | 7 | ## Supported platforms 8 | 9 | - Linux (via MPRIS) 10 | - MacOS/iOS 11 | - Windows 12 | 13 | ## Windows 14 | 15 | - Update metadata:\ 16 | ![image](https://user-images.githubusercontent.com/8389938/106080661-4a515e80-60f6-11eb-81e0-81ab0eda5188.png) 17 | - Play and pause polling.\ 18 | ![play_pause](https://user-images.githubusercontent.com/8389938/106080917-bdf36b80-60f6-11eb-98b5-f3071ae3eab6.gif) 19 | 20 | ## Linux 21 | 22 | - GNOME: \ 23 | ![GNOME](https://user-images.githubusercontent.com/59307989/150836249-3270c4fc-78b9-4b8d-8d50-dd030b72b631.png) 24 | 25 | - playerctl: 26 | ```shell 27 | # In one shell 28 | $ cd souvlaki 29 | $ cargo run --example window 30 | 31 | # In another shell 32 | $ playerctl metadata 33 | my_player xesam:artist Slowdive 34 | my_player xesam:album Souvlaki 35 | my_player mpris:artUrl https://c.pxhere.com/photos/34/c1/souvlaki_authentic_greek_greek_food_mezes-497780.jpg!d 36 | my_player mpris:trackid '/' 37 | my_player mpris:length 290000000 38 | my_player xesam:title When The Sun Hits 39 | ``` 40 | 41 | ## MacOS 42 | 43 | - Control Center:\ 44 | ![Control Center](https://user-images.githubusercontent.com/434125/171526539-ecb07a74-5dc5-4f4b-8305-4a99d4d5c31c.png) 45 | - Now Playing:\ 46 | ![Now Playing](https://user-images.githubusercontent.com/434125/171526759-9232be58-63ed-4eea-ac15-aa50258d8254.png) 47 | 48 | ## Requirements 49 | 50 | Minimum supported Rust version is 1.67. 51 | 52 | ## Usage 53 | 54 | The main struct is `MediaControls`. In order to create this struct you need a `PlatformConfig`. This struct contains all of the platform-specific requirements for spawning media controls. Here are the differences between the platforms: 55 | 56 | - MacOS: No config needed, but requires an AppDelegate/winit event loop (an open window is not required.) ([#23](https://github.com/Sinono3/souvlaki/issues/23)) 57 | - iOS: No config needed. 58 | - Linux: 59 | - `dbus_name`: The way your player will appear on D-Bus. It should follow [the D-Bus specification](https://dbus.freedesktop.org/doc/dbus-specification.html#message-protocol-names-bus). 60 | - `display_name`: This could be however you want. It's the name that will be shown to the users. 61 | - Windows: 62 | - `hwnd`: In this platform, a window needs to be opened to create media controls. The argument required is an `HWND`, a value of type `*mut c_void`. This value can be extracted when you open a window in your program, for example using the `raw_window_handle` in winit. 63 | 64 | ### Linux backends: D-Bus and `zbus` 65 | 66 | When using the library on Linux, the default backend is `dbus-crossroads`. This backend has some issues with consistency in general, but is more stable and uses the native D-Bus library behind the scenes. The zbus backend however, is more modern and is written in pure Rust. It spawns another thread and stars an async `pollster` runtime, handling the incoming MPRIS messages. 67 | 68 | To enable the zbus backend, in your Cargo.toml, set `default-features` to false and enable the `use_zbus` feature: 69 | 70 | ```toml 71 | souvlaki = { version = "", default-features = false, features = ["use_zbus"] } 72 | ``` 73 | 74 | 75 | **Note:** If you think there's a better way of using the zbus library regarding the async runtime in another thread, feel free to leave a PR or issue. 76 | 77 | ## Example 78 | 79 | ```rust 80 | use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, PlatformConfig}; 81 | 82 | fn main() { 83 | #[cfg(not(target_os = "windows"))] 84 | let hwnd = None; 85 | 86 | #[cfg(target_os = "windows")] 87 | let hwnd = { 88 | use raw_window_handle::windows::WindowsHandle; 89 | 90 | let handle: WindowsHandle = unimplemented!(); 91 | Some(handle.hwnd) 92 | }; 93 | 94 | let config = PlatformConfig { 95 | dbus_name: "my_player", 96 | display_name: "My Player", 97 | hwnd, 98 | }; 99 | 100 | let mut controls = MediaControls::new(config).unwrap(); 101 | 102 | // The closure must be Send and have a static lifetime. 103 | controls 104 | .attach(|event: MediaControlEvent| println!("Event received: {:?}", event)) 105 | .unwrap(); 106 | 107 | // Update the media metadata. 108 | controls 109 | .set_metadata(MediaMetadata { 110 | title: Some("Souvlaki Space Station"), 111 | artist: Some("Slowdive"), 112 | album: Some("Souvlaki"), 113 | ..Default::default() 114 | }) 115 | .unwrap(); 116 | 117 | // Your actual logic goes here. 118 | loop { 119 | std::thread::sleep(std::time::Duration::from_secs(1)); 120 | } 121 | 122 | // The controls automatically detach on drop. 123 | } 124 | ``` 125 | 126 | [Check out this example here.](https://github.com/Sinono3/souvlaki/blob/master/examples/print_events.rs) 127 | 128 | ## Thanks 💗 129 | 130 | - To [jpochyla](https://github.com/jpochyla) for being a contributor to library architecture and the sole developer of MacOS support. 131 | -------------------------------------------------------------------------------- /src/platform/mpris/dbus/interfaces.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | convert::{TryFrom, TryInto}, 3 | sync::{Arc, Mutex}, 4 | time::Duration, 5 | }; 6 | 7 | use dbus::Path; 8 | use dbus_crossroads::{Crossroads, IfaceBuilder}; 9 | 10 | use crate::{MediaControlEvent, MediaPlayback, MediaPosition, SeekDirection}; 11 | 12 | use super::controls::{create_metadata_dict, ServiceState}; 13 | 14 | // TODO: This type is super messed up, but it's the only way to get seeking working properly 15 | // on graphical media controls using dbus-crossroads. 16 | pub type SeekedSignal = 17 | Arc, &(String,)) -> dbus::Message + Send + Sync>>>>; 18 | 19 | pub fn register_methods( 20 | state: &Arc>, 21 | event_handler: &Arc>, 22 | friendly_name: String, 23 | seeked_signal: SeekedSignal, 24 | ) -> Crossroads 25 | where 26 | F: Fn(MediaControlEvent) + Send + 'static, 27 | { 28 | let mut cr = Crossroads::new(); 29 | let app_interface = cr.register("org.mpris.MediaPlayer2", { 30 | let event_handler = event_handler.clone(); 31 | 32 | move |b| { 33 | b.property("Identity") 34 | .get(move |_, _| Ok(friendly_name.clone())); 35 | 36 | register_method(b, &event_handler, "Raise", MediaControlEvent::Raise); 37 | register_method(b, &event_handler, "Quit", MediaControlEvent::Quit); 38 | 39 | // TODO: allow user to set these properties 40 | b.property("CanQuit") 41 | .get(|_, _| Ok(true)) 42 | .emits_changed_true(); 43 | b.property("CanRaise") 44 | .get(|_, _| Ok(true)) 45 | .emits_changed_true(); 46 | b.property("HasTracklist") 47 | .get(|_, _| Ok(false)) 48 | .emits_changed_true(); 49 | b.property("SupportedUriSchemes") 50 | .get(move |_, _| Ok(&[] as &[String])) 51 | .emits_changed_true(); 52 | b.property("SupportedMimeTypes") 53 | .get(move |_, _| Ok(&[] as &[String])) 54 | .emits_changed_true(); 55 | } 56 | }); 57 | 58 | let player_interface = cr.register("org.mpris.MediaPlayer2.Player", |b| { 59 | register_method(b, event_handler, "Next", MediaControlEvent::Next); 60 | register_method(b, event_handler, "Previous", MediaControlEvent::Previous); 61 | register_method(b, event_handler, "Pause", MediaControlEvent::Pause); 62 | register_method(b, event_handler, "PlayPause", MediaControlEvent::Toggle); 63 | register_method(b, event_handler, "Stop", MediaControlEvent::Stop); 64 | register_method(b, event_handler, "Play", MediaControlEvent::Play); 65 | 66 | b.method("Seek", ("Offset",), (), { 67 | let event_handler = event_handler.clone(); 68 | 69 | move |ctx, _, (offset,): (i64,)| { 70 | let abs_offset = offset.unsigned_abs(); 71 | let direction = if offset > 0 { 72 | SeekDirection::Forward 73 | } else { 74 | SeekDirection::Backward 75 | }; 76 | 77 | (event_handler.lock().unwrap())(MediaControlEvent::SeekBy( 78 | direction, 79 | Duration::from_micros(abs_offset), 80 | )); 81 | ctx.push_msg(ctx.make_signal("Seeked", ())); 82 | Ok(()) 83 | } 84 | }); 85 | 86 | b.method("SetPosition", ("TrackId", "Position"), (), { 87 | let state = state.clone(); 88 | let event_handler = event_handler.clone(); 89 | 90 | move |_, _, (_trackid, position): (Path, i64)| { 91 | let state = state.lock().unwrap(); 92 | 93 | // According to the MPRIS specification: 94 | 95 | // TODO: If the TrackId argument is not the same as the current 96 | // trackid, the call is ignored as stale. 97 | // (Maybe it should be optional?) 98 | 99 | if let Some(duration) = state.metadata.duration { 100 | // If the Position argument is greater than the track length, do nothing. 101 | if position > duration { 102 | return Ok(()); 103 | } 104 | } 105 | 106 | // If the Position argument is less than 0, do nothing. 107 | if let Ok(position) = u64::try_from(position) { 108 | let position = Duration::from_micros(position); 109 | 110 | (event_handler.lock().unwrap())(MediaControlEvent::SetPosition(MediaPosition( 111 | position, 112 | ))); 113 | } 114 | Ok(()) 115 | } 116 | }); 117 | 118 | b.method("OpenUri", ("Uri",), (), { 119 | let event_handler = event_handler.clone(); 120 | 121 | move |_, _, (uri,): (String,)| { 122 | (event_handler.lock().unwrap())(MediaControlEvent::OpenUri(uri)); 123 | Ok(()) 124 | } 125 | }); 126 | 127 | *seeked_signal.lock().unwrap() = Some(b.signal::<(String,), _>("Seeked", ("x",)).msg_fn()); 128 | 129 | b.property("PlaybackStatus") 130 | .get({ 131 | let state = state.clone(); 132 | move |_, _| { 133 | let state = state.lock().unwrap(); 134 | Ok(state.get_playback_status().to_string()) 135 | } 136 | }) 137 | .emits_changed_true(); 138 | 139 | b.property("Rate").get(|_, _| Ok(1.0)).emits_changed_true(); 140 | 141 | b.property("Metadata") 142 | .get({ 143 | let state = state.clone(); 144 | move |_, _| Ok(create_metadata_dict(&state.lock().unwrap().metadata)) 145 | }) 146 | .emits_changed_true(); 147 | 148 | b.property("Volume") 149 | .get({ 150 | let state = state.clone(); 151 | move |_, _| { 152 | let state = state.lock().unwrap(); 153 | Ok(state.volume) 154 | } 155 | }) 156 | .set({ 157 | let event_handler = event_handler.clone(); 158 | move |_, _, volume: f64| { 159 | (event_handler.lock().unwrap())(MediaControlEvent::SetVolume(volume)); 160 | Ok(Some(volume)) 161 | } 162 | }) 163 | .emits_changed_true(); 164 | 165 | b.property("Position").get({ 166 | let state = state.clone(); 167 | move |_, _| { 168 | let state = state.lock().unwrap(); 169 | let progress: i64 = match state.playback_status { 170 | MediaPlayback::Playing { 171 | progress: Some(progress), 172 | } 173 | | MediaPlayback::Paused { 174 | progress: Some(progress), 175 | } => progress.0.as_micros(), 176 | _ => 0, 177 | } 178 | .try_into() 179 | .unwrap(); 180 | Ok(progress) 181 | } 182 | }); 183 | 184 | b.property("MinimumRate") 185 | .get(|_, _| Ok(1.0)) 186 | .emits_changed_true(); 187 | b.property("MaximumRate") 188 | .get(|_, _| Ok(1.0)) 189 | .emits_changed_true(); 190 | 191 | b.property("CanGoNext") 192 | .get(|_, _| Ok(true)) 193 | .emits_changed_true(); 194 | b.property("CanGoPrevious") 195 | .get(|_, _| Ok(true)) 196 | .emits_changed_true(); 197 | b.property("CanPlay") 198 | .get(|_, _| Ok(true)) 199 | .emits_changed_true(); 200 | b.property("CanPause") 201 | .get(|_, _| Ok(true)) 202 | .emits_changed_true(); 203 | b.property("CanSeek") 204 | .get(|_, _| Ok(true)) 205 | .emits_changed_true(); 206 | b.property("CanControl") 207 | .get(|_, _| Ok(true)) 208 | .emits_changed_true(); 209 | }); 210 | 211 | cr.insert( 212 | "/org/mpris/MediaPlayer2", 213 | &[app_interface, player_interface], 214 | (), 215 | ); 216 | 217 | seeked_signal.lock().ok(); 218 | 219 | cr 220 | } 221 | 222 | fn register_method( 223 | b: &mut IfaceBuilder<()>, 224 | event_handler: &Arc>, 225 | name: &'static str, 226 | event: MediaControlEvent, 227 | ) where 228 | F: Fn(MediaControlEvent) + Send + 'static, 229 | { 230 | let event_handler = event_handler.clone(); 231 | 232 | b.method(name, (), (), move |_, _, _: ()| { 233 | (event_handler.lock().unwrap())(event.clone()); 234 | Ok(()) 235 | }); 236 | } 237 | -------------------------------------------------------------------------------- /src/platform/windows/mod.rs: -------------------------------------------------------------------------------- 1 | #![cfg(target_os = "windows")] 2 | 3 | use std::sync::{Arc, Mutex}; 4 | use std::time::Duration; 5 | use windows::core::{Error as WindowsError, HSTRING}; 6 | use windows::Foundation::{EventRegistrationToken, TimeSpan, TypedEventHandler, Uri}; 7 | use windows::Media::*; 8 | use windows::Storage::Streams::RandomAccessStreamReference; 9 | use windows::Win32::Foundation::HWND; 10 | use windows::Win32::System::WinRT::ISystemMediaTransportControlsInterop; 11 | 12 | use crate::{ 13 | MediaControlEvent, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, SeekDirection, 14 | }; 15 | 16 | /// A handle to OS media controls. 17 | pub struct MediaControls { 18 | controls: SystemMediaTransportControls, 19 | button_handler_token: Option, 20 | display_updater: SystemMediaTransportControlsDisplayUpdater, 21 | timeline_properties: SystemMediaTransportControlsTimelineProperties, 22 | } 23 | 24 | #[repr(i32)] 25 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 26 | enum SmtcPlayback { 27 | Stopped = 2, 28 | Playing = 3, 29 | Paused = 4, 30 | } 31 | 32 | /// A platform-specific error. 33 | #[derive(Debug)] 34 | pub struct Error(WindowsError); 35 | 36 | impl std::fmt::Display for Error { 37 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 38 | self.0.fmt(f) 39 | } 40 | } 41 | 42 | impl std::error::Error for Error {} 43 | 44 | impl From for Error { 45 | fn from(other: WindowsError) -> Error { 46 | Error(other) 47 | } 48 | } 49 | 50 | impl MediaControls { 51 | /// Create media controls with the specified config. 52 | pub fn new(config: PlatformConfig) -> Result { 53 | let interop: ISystemMediaTransportControlsInterop = windows::core::factory::< 54 | SystemMediaTransportControls, 55 | ISystemMediaTransportControlsInterop, 56 | >()?; 57 | let hwnd = config 58 | .hwnd 59 | .expect("Windows media controls require an HWND in MediaControlsOptions."); 60 | 61 | let controls: SystemMediaTransportControls = 62 | unsafe { interop.GetForWindow(HWND(hwnd as isize)) }?; 63 | let display_updater = controls.DisplayUpdater()?; 64 | let timeline_properties = SystemMediaTransportControlsTimelineProperties::new()?; 65 | 66 | Ok(Self { 67 | controls, 68 | display_updater, 69 | timeline_properties, 70 | button_handler_token: None, 71 | }) 72 | } 73 | 74 | /// Attach the media control events to a handler. 75 | pub fn attach(&mut self, event_handler: F) -> Result<(), Error> 76 | where 77 | F: Fn(MediaControlEvent) + Send + 'static, 78 | { 79 | self.controls.SetIsEnabled(true)?; 80 | self.controls.SetIsPlayEnabled(true)?; 81 | self.controls.SetIsPauseEnabled(true)?; 82 | self.controls.SetIsStopEnabled(true)?; 83 | self.controls.SetIsNextEnabled(true)?; 84 | self.controls.SetIsPreviousEnabled(true)?; 85 | self.controls.SetIsFastForwardEnabled(true)?; 86 | self.controls.SetIsRewindEnabled(true)?; 87 | 88 | // TODO: allow changing this 89 | self.display_updater.SetType(MediaPlaybackType::Music)?; 90 | 91 | let event_handler = Arc::new(Mutex::new(event_handler)); 92 | 93 | let button_handler = TypedEventHandler::new({ 94 | let event_handler = event_handler.clone(); 95 | 96 | move |_, args: &Option<_>| { 97 | let args: &SystemMediaTransportControlsButtonPressedEventArgs = 98 | args.as_ref().unwrap(); 99 | let button = args.Button()?; 100 | 101 | let event = if button == SystemMediaTransportControlsButton::Play { 102 | MediaControlEvent::Play 103 | } else if button == SystemMediaTransportControlsButton::Pause { 104 | MediaControlEvent::Pause 105 | } else if button == SystemMediaTransportControlsButton::Stop { 106 | MediaControlEvent::Stop 107 | } else if button == SystemMediaTransportControlsButton::Next { 108 | MediaControlEvent::Next 109 | } else if button == SystemMediaTransportControlsButton::Previous { 110 | MediaControlEvent::Previous 111 | } else if button == SystemMediaTransportControlsButton::FastForward { 112 | MediaControlEvent::Seek(SeekDirection::Forward) 113 | } else if button == SystemMediaTransportControlsButton::Rewind { 114 | MediaControlEvent::Seek(SeekDirection::Backward) 115 | } else { 116 | // Ignore unknown events 117 | return Ok(()); 118 | }; 119 | 120 | (event_handler.lock().unwrap())(event); 121 | Ok(()) 122 | } 123 | }); 124 | self.button_handler_token = Some(self.controls.ButtonPressed(&button_handler)?); 125 | 126 | let position_handler = TypedEventHandler::new({ 127 | move |_, args: &Option<_>| { 128 | let args: &PlaybackPositionChangeRequestedEventArgs = args.as_ref().unwrap(); 129 | let position = Duration::from(args.RequestedPlaybackPosition()?); 130 | 131 | (event_handler.lock().unwrap())(MediaControlEvent::SetPosition(MediaPosition( 132 | position, 133 | ))); 134 | Ok(()) 135 | } 136 | }); 137 | self.controls 138 | .PlaybackPositionChangeRequested(&position_handler)?; 139 | 140 | Ok(()) 141 | } 142 | 143 | /// Detach the event handler. 144 | pub fn detach(&mut self) -> Result<(), Error> { 145 | self.controls.SetIsEnabled(false)?; 146 | if let Some(button_handler_token) = self.button_handler_token { 147 | self.controls.RemoveButtonPressed(button_handler_token)?; 148 | } 149 | Ok(()) 150 | } 151 | 152 | /// Set the current playback status. 153 | pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { 154 | let status = match playback { 155 | MediaPlayback::Playing { .. } => SmtcPlayback::Playing as i32, 156 | MediaPlayback::Paused { .. } => SmtcPlayback::Paused as i32, 157 | MediaPlayback::Stopped => SmtcPlayback::Stopped as i32, 158 | }; 159 | self.controls 160 | .SetPlaybackStatus(MediaPlaybackStatus(status))?; 161 | 162 | let progress = match playback { 163 | MediaPlayback::Playing { 164 | progress: Some(progress), 165 | } 166 | | MediaPlayback::Paused { 167 | progress: Some(progress), 168 | } => TimeSpan::from(progress.0), 169 | _ => TimeSpan::default(), 170 | }; 171 | self.timeline_properties.SetPosition(progress)?; 172 | 173 | self.controls 174 | .UpdateTimelineProperties(&self.timeline_properties)?; 175 | Ok(()) 176 | } 177 | 178 | /// Set the metadata of the currently playing media item. 179 | pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { 180 | let properties = self.display_updater.MusicProperties()?; 181 | 182 | if let Some(title) = metadata.title { 183 | properties.SetTitle(&HSTRING::from(title))?; 184 | } 185 | if let Some(artist) = metadata.artist { 186 | properties.SetArtist(&HSTRING::from(artist))?; 187 | } 188 | if let Some(album) = metadata.album { 189 | properties.SetAlbumTitle(&HSTRING::from(album))?; 190 | } 191 | if let Some(url) = metadata.cover_url { 192 | let stream = if url.starts_with("file://") { 193 | // url is a file, load it manually 194 | let path = url.trim_start_matches("file://"); 195 | let loader = 196 | windows::Storage::StorageFile::GetFileFromPathAsync(&HSTRING::from(path))?; 197 | let results = loader.get()?; 198 | loader.Close()?; 199 | 200 | RandomAccessStreamReference::CreateFromFile(&results)? 201 | } else { 202 | RandomAccessStreamReference::CreateFromUri(&Uri::CreateUri(&HSTRING::from(url))?)? 203 | }; 204 | self.display_updater.SetThumbnail(&stream)?; 205 | } 206 | let duration = metadata.duration.unwrap_or_default(); 207 | self.timeline_properties.SetStartTime(TimeSpan::default())?; 208 | self.timeline_properties 209 | .SetMinSeekTime(TimeSpan::default())?; 210 | self.timeline_properties 211 | .SetEndTime(TimeSpan::from(duration))?; 212 | self.timeline_properties 213 | .SetMaxSeekTime(TimeSpan::from(duration))?; 214 | 215 | self.controls 216 | .UpdateTimelineProperties(&self.timeline_properties)?; 217 | self.display_updater.Update()?; 218 | Ok(()) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/platform/mpris/dbus/controls.rs: -------------------------------------------------------------------------------- 1 | use dbus::arg::{RefArg, Variant}; 2 | use dbus::blocking::Connection; 3 | use dbus::channel::{MatchingReceiver, Sender}; 4 | use dbus::ffidisp::stdintf::org_freedesktop_dbus::PropertiesPropertiesChanged; 5 | use dbus::message::SignalArgs; 6 | use dbus::Path; 7 | use std::collections::HashMap; 8 | use std::convert::From; 9 | use std::convert::TryInto; 10 | use std::sync::{mpsc, Arc, Mutex}; 11 | use std::thread::{self, JoinHandle}; 12 | use std::time::Duration; 13 | 14 | use super::super::Error; 15 | use crate::{MediaControlEvent, MediaMetadata, MediaPlayback, PlatformConfig}; 16 | 17 | /// A handle to OS media controls. 18 | pub struct MediaControls { 19 | thread: Option, 20 | dbus_name: String, 21 | friendly_name: String, 22 | } 23 | 24 | struct ServiceThreadHandle { 25 | event_channel: mpsc::Sender, 26 | thread: JoinHandle>, 27 | } 28 | 29 | #[derive(Clone, PartialEq, Debug)] 30 | enum InternalEvent { 31 | ChangeMetadata(OwnedMetadata), 32 | ChangePlayback(MediaPlayback), 33 | ChangeVolume(f64), 34 | Kill, 35 | } 36 | 37 | #[derive(Debug)] 38 | pub struct ServiceState { 39 | pub metadata: OwnedMetadata, 40 | pub metadata_dict: HashMap>>, 41 | pub playback_status: MediaPlayback, 42 | pub volume: f64, 43 | } 44 | 45 | impl ServiceState { 46 | pub fn set_metadata(&mut self, metadata: OwnedMetadata) { 47 | self.metadata_dict = create_metadata_dict(&metadata); 48 | self.metadata = metadata; 49 | } 50 | 51 | pub fn get_playback_status(&self) -> &'static str { 52 | match self.playback_status { 53 | MediaPlayback::Playing { .. } => "Playing", 54 | MediaPlayback::Paused { .. } => "Paused", 55 | MediaPlayback::Stopped => "Stopped", 56 | } 57 | } 58 | } 59 | 60 | pub fn create_metadata_dict(metadata: &OwnedMetadata) -> HashMap>> { 61 | let mut dict = HashMap::>>::new(); 62 | 63 | let mut insert = |k: &str, v| dict.insert(k.to_string(), Variant(v)); 64 | 65 | let OwnedMetadata { 66 | ref title, 67 | ref album, 68 | ref artist, 69 | ref cover_url, 70 | ref duration, 71 | } = metadata; 72 | 73 | // TODO: this is just a workaround to enable SetPosition. 74 | let path = Path::new("/").unwrap(); 75 | 76 | // MPRIS 77 | insert("mpris:trackid", Box::new(path)); 78 | 79 | if let Some(length) = duration { 80 | insert("mpris:length", Box::new(*length)); 81 | } 82 | if let Some(cover_url) = cover_url { 83 | insert("mpris:artUrl", Box::new(cover_url.clone())); 84 | } 85 | 86 | // Xesam 87 | if let Some(title) = title { 88 | insert("xesam:title", Box::new(title.clone())); 89 | } 90 | if let Some(artist) = artist { 91 | insert("xesam:artist", Box::new(vec![artist.clone()])); 92 | } 93 | if let Some(album) = album { 94 | insert("xesam:album", Box::new(album.clone())); 95 | } 96 | 97 | dict 98 | } 99 | 100 | #[derive(Clone, PartialEq, Eq, Debug, Default)] 101 | pub struct OwnedMetadata { 102 | pub title: Option, 103 | pub album: Option, 104 | pub artist: Option, 105 | pub cover_url: Option, 106 | pub duration: Option, 107 | } 108 | 109 | impl From> for OwnedMetadata { 110 | fn from(other: MediaMetadata) -> Self { 111 | OwnedMetadata { 112 | title: other.title.map(|s| s.to_string()), 113 | artist: other.artist.map(|s| s.to_string()), 114 | album: other.album.map(|s| s.to_string()), 115 | cover_url: other.cover_url.map(|s| s.to_string()), 116 | // TODO: This should probably not have an unwrap 117 | duration: other.duration.map(|d| d.as_micros().try_into().unwrap()), 118 | } 119 | } 120 | } 121 | 122 | impl MediaControls { 123 | /// Create media controls with the specified config. 124 | pub fn new(config: PlatformConfig) -> Result { 125 | let PlatformConfig { 126 | dbus_name, 127 | display_name, 128 | .. 129 | } = config; 130 | 131 | Ok(Self { 132 | thread: None, 133 | dbus_name: dbus_name.to_string(), 134 | friendly_name: display_name.to_string(), 135 | }) 136 | } 137 | 138 | /// Attach the media control events to a handler. 139 | pub fn attach(&mut self, event_handler: F) -> Result<(), Error> 140 | where 141 | F: Fn(MediaControlEvent) + Send + 'static, 142 | { 143 | self.detach()?; 144 | 145 | let dbus_name = self.dbus_name.clone(); 146 | let friendly_name = self.friendly_name.clone(); 147 | let (event_channel, rx) = mpsc::channel(); 148 | 149 | // Check if the connection can be created BEFORE spawning the new thread 150 | let conn = Connection::new_session()?; 151 | let name = format!("org.mpris.MediaPlayer2.{}", dbus_name); 152 | conn.request_name(name, false, true, false)?; 153 | 154 | self.thread = Some(ServiceThreadHandle { 155 | event_channel, 156 | thread: thread::spawn(move || run_service(conn, friendly_name, event_handler, rx)), 157 | }); 158 | Ok(()) 159 | } 160 | 161 | /// Detach the event handler. 162 | pub fn detach(&mut self) -> Result<(), Error> { 163 | if let Some(ServiceThreadHandle { 164 | event_channel, 165 | thread, 166 | }) = self.thread.take() 167 | { 168 | // We don't care about the result of this event, since we immedieately 169 | // check if the thread has panicked on the next line. 170 | event_channel.send(InternalEvent::Kill).ok(); 171 | // One error in case the thread panics, and the other one in case the 172 | // thread has returned an error. 173 | thread.join().map_err(|_| Error::ThreadPanicked)??; 174 | } 175 | Ok(()) 176 | } 177 | 178 | /// Set the current playback status. 179 | pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { 180 | self.send_internal_event(InternalEvent::ChangePlayback(playback)) 181 | } 182 | 183 | /// Set the metadata of the currently playing media item. 184 | pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { 185 | self.send_internal_event(InternalEvent::ChangeMetadata(metadata.into())) 186 | } 187 | 188 | /// Set the volume level (0.0-1.0) (Only available on MPRIS) 189 | pub fn set_volume(&mut self, volume: f64) -> Result<(), Error> { 190 | self.send_internal_event(InternalEvent::ChangeVolume(volume)) 191 | } 192 | 193 | fn send_internal_event(&mut self, event: InternalEvent) -> Result<(), Error> { 194 | let thread = &self.thread.as_ref().ok_or(Error::ThreadNotRunning)?; 195 | thread 196 | .event_channel 197 | .send(event) 198 | .map_err(|_| Error::ThreadPanicked) 199 | } 200 | } 201 | 202 | fn run_service( 203 | conn: Connection, 204 | friendly_name: String, 205 | event_handler: F, 206 | event_channel: mpsc::Receiver, 207 | ) -> Result<(), Error> 208 | where 209 | F: Fn(MediaControlEvent) + Send + 'static, 210 | { 211 | let state = Arc::new(Mutex::new(ServiceState { 212 | metadata: Default::default(), 213 | metadata_dict: create_metadata_dict(&Default::default()), 214 | playback_status: MediaPlayback::Stopped, 215 | volume: 1.0, 216 | })); 217 | let event_handler = Arc::new(Mutex::new(event_handler)); 218 | let seeked_signal = Arc::new(Mutex::new(None)); 219 | 220 | let mut cr = 221 | super::interfaces::register_methods(&state, &event_handler, friendly_name, seeked_signal); 222 | 223 | conn.start_receive( 224 | dbus::message::MatchRule::new_method_call(), 225 | Box::new(move |msg, conn| { 226 | cr.handle_message(msg, conn).unwrap(); 227 | true 228 | }), 229 | ); 230 | 231 | loop { 232 | if let Ok(event) = event_channel.recv_timeout(Duration::from_millis(10)) { 233 | if event == InternalEvent::Kill { 234 | break; 235 | } 236 | 237 | let mut changed_properties = HashMap::new(); 238 | 239 | match event { 240 | InternalEvent::ChangeMetadata(metadata) => { 241 | let mut state = state.lock().unwrap(); 242 | state.set_metadata(metadata); 243 | changed_properties.insert( 244 | "Metadata".to_owned(), 245 | Variant(state.metadata_dict.box_clone()), 246 | ); 247 | } 248 | InternalEvent::ChangePlayback(playback) => { 249 | let mut state = state.lock().unwrap(); 250 | state.playback_status = playback; 251 | changed_properties.insert( 252 | "PlaybackStatus".to_owned(), 253 | Variant(Box::new(state.get_playback_status().to_string())), 254 | ); 255 | } 256 | InternalEvent::ChangeVolume(volume) => { 257 | let mut state = state.lock().unwrap(); 258 | state.volume = volume; 259 | changed_properties.insert("Volume".to_owned(), Variant(Box::new(volume))); 260 | } 261 | _ => (), 262 | } 263 | 264 | let properties_changed = PropertiesPropertiesChanged { 265 | interface_name: "org.mpris.MediaPlayer2.Player".to_owned(), 266 | changed_properties, 267 | invalidated_properties: Vec::new(), 268 | }; 269 | 270 | conn.send( 271 | properties_changed.to_emit_message(&Path::new("/org/mpris/MediaPlayer2").unwrap()), 272 | ) 273 | .ok(); 274 | } 275 | conn.process(Duration::from_millis(1000))?; 276 | } 277 | 278 | Ok(()) 279 | } 280 | -------------------------------------------------------------------------------- /src/platform/macos/mod.rs: -------------------------------------------------------------------------------- 1 | #![cfg(any(target_os = "macos", target_os = "ios"))] 2 | #![allow(non_upper_case_globals)] 3 | 4 | #[cfg(target_os = "ios")] 5 | use std::fs; 6 | 7 | use std::{ 8 | sync::{ 9 | atomic::{AtomicUsize, Ordering}, 10 | Arc, 11 | }, 12 | time::Duration, 13 | }; 14 | 15 | use block::ConcreteBlock; 16 | use cocoa::{ 17 | base::{id, nil, NO, YES}, 18 | foundation::{NSInteger, NSString, NSUInteger}, 19 | }; 20 | use core_graphics::geometry::CGSize; 21 | 22 | use dispatch::{Queue, QueuePriority}; 23 | use objc::{class, msg_send, sel, sel_impl}; 24 | 25 | use crate::{MediaControlEvent, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig}; 26 | 27 | /// A platform-specific error. 28 | #[derive(Debug)] 29 | pub struct Error; 30 | 31 | impl std::fmt::Display for Error { 32 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { 33 | write!(f, "Error") 34 | } 35 | } 36 | 37 | impl std::error::Error for Error {} 38 | 39 | /// A handle to OS media controls. 40 | pub struct MediaControls; 41 | 42 | impl MediaControls { 43 | /// Create media controls with the specified config. 44 | pub fn new(_config: PlatformConfig) -> Result { 45 | Ok(Self) 46 | } 47 | 48 | /// Attach the media control events to a handler. 49 | pub fn attach(&mut self, event_handler: F) -> Result<(), Error> 50 | where 51 | F: Fn(MediaControlEvent) + Send + 'static, 52 | { 53 | unsafe { attach_command_handlers(Arc::new(event_handler)) }; 54 | Ok(()) 55 | } 56 | 57 | /// Detach the event handler. 58 | pub fn detach(&mut self) -> Result<(), Error> { 59 | unsafe { detach_command_handlers() }; 60 | Ok(()) 61 | } 62 | 63 | /// Set the current playback status. 64 | pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { 65 | unsafe { set_playback_status(playback) }; 66 | Ok(()) 67 | } 68 | 69 | /// Set the metadata of the currently playing media item. 70 | pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { 71 | unsafe { set_playback_metadata(metadata) }; 72 | Ok(()) 73 | } 74 | } 75 | 76 | // MPNowPlayingPlaybackState 77 | const MPNowPlayingPlaybackStatePlaying: NSUInteger = 1; 78 | const MPNowPlayingPlaybackStatePaused: NSUInteger = 2; 79 | const MPNowPlayingPlaybackStateStopped: NSUInteger = 3; 80 | 81 | // MPRemoteCommandHandlerStatus 82 | const MPRemoteCommandHandlerStatusSuccess: NSInteger = 0; 83 | 84 | extern "C" { 85 | static MPMediaItemPropertyTitle: id; // NSString 86 | static MPMediaItemPropertyArtist: id; // NSString 87 | static MPMediaItemPropertyAlbumTitle: id; // NSString 88 | static MPMediaItemPropertyArtwork: id; // NSString 89 | static MPMediaItemPropertyPlaybackDuration: id; // NSString 90 | static MPNowPlayingInfoPropertyElapsedPlaybackTime: id; // NSString 91 | } 92 | 93 | unsafe fn set_playback_status(playback: MediaPlayback) { 94 | let media_center: id = msg_send!(class!(MPNowPlayingInfoCenter), defaultCenter); 95 | let state = match playback { 96 | MediaPlayback::Stopped => MPNowPlayingPlaybackStateStopped, 97 | MediaPlayback::Paused { .. } => MPNowPlayingPlaybackStatePaused, 98 | MediaPlayback::Playing { .. } => MPNowPlayingPlaybackStatePlaying, 99 | }; 100 | let _: () = msg_send!(media_center, setPlaybackState: state); 101 | if let MediaPlayback::Paused { 102 | progress: Some(progress), 103 | } 104 | | MediaPlayback::Playing { 105 | progress: Some(progress), 106 | } = playback 107 | { 108 | set_playback_progress(progress.0); 109 | } 110 | } 111 | 112 | static GLOBAL_METADATA_COUNTER: AtomicUsize = AtomicUsize::new(1); 113 | 114 | unsafe fn set_playback_metadata(metadata: MediaMetadata) { 115 | let prev_counter = GLOBAL_METADATA_COUNTER.fetch_add(1, Ordering::SeqCst); 116 | let media_center: id = msg_send!(class!(MPNowPlayingInfoCenter), defaultCenter); 117 | let now_playing: id = msg_send!(class!(NSMutableDictionary), dictionary); 118 | if let Some(title) = metadata.title { 119 | let _: () = msg_send!(now_playing, setObject: ns_string(title) 120 | forKey: MPMediaItemPropertyTitle); 121 | } 122 | if let Some(artist) = metadata.artist { 123 | let _: () = msg_send!(now_playing, setObject: ns_string(artist) 124 | forKey: MPMediaItemPropertyArtist); 125 | } 126 | if let Some(album) = metadata.album { 127 | let _: () = msg_send!(now_playing, setObject: ns_string(album) 128 | forKey: MPMediaItemPropertyAlbumTitle); 129 | } 130 | if let Some(duration) = metadata.duration { 131 | let _: () = msg_send!(now_playing, setObject: ns_number(duration.as_secs_f64()) 132 | forKey: MPMediaItemPropertyPlaybackDuration); 133 | } 134 | if let Some(cover_url) = metadata.cover_url { 135 | let cover_url = cover_url.to_owned(); 136 | Queue::global(QueuePriority::Default).exec_async(move || { 137 | load_and_set_playback_artwork(cover_url, prev_counter + 1); 138 | }); 139 | } 140 | let _: () = msg_send!(media_center, setNowPlayingInfo: now_playing); 141 | } 142 | 143 | unsafe fn load_and_set_playback_artwork(url: String, for_counter: usize) { 144 | let (image, size) = load_image_from_url(&url); 145 | let artwork = mp_artwork(image, size); 146 | if GLOBAL_METADATA_COUNTER.load(Ordering::SeqCst) == for_counter { 147 | set_playback_artwork(artwork); 148 | } 149 | } 150 | 151 | unsafe fn set_playback_artwork(artwork: id) { 152 | let media_center: id = msg_send!(class!(MPNowPlayingInfoCenter), defaultCenter); 153 | let now_playing: id = msg_send!(class!(NSMutableDictionary), dictionary); 154 | let prev_now_playing: id = msg_send!(media_center, nowPlayingInfo); 155 | let _: () = msg_send!(now_playing, addEntriesFromDictionary: prev_now_playing); 156 | let _: () = msg_send!(now_playing, setObject: artwork 157 | forKey: MPMediaItemPropertyArtwork); 158 | let _: () = msg_send!(media_center, setNowPlayingInfo: now_playing); 159 | } 160 | 161 | unsafe fn set_playback_progress(progress: Duration) { 162 | let media_center: id = msg_send!(class!(MPNowPlayingInfoCenter), defaultCenter); 163 | let now_playing: id = msg_send!(class!(NSMutableDictionary), dictionary); 164 | let prev_now_playing: id = msg_send!(media_center, nowPlayingInfo); 165 | let _: () = msg_send!(now_playing, addEntriesFromDictionary: prev_now_playing); 166 | let _: () = msg_send!(now_playing, setObject: ns_number(progress.as_secs_f64()) 167 | forKey: MPNowPlayingInfoPropertyElapsedPlaybackTime); 168 | let _: () = msg_send!(media_center, setNowPlayingInfo: now_playing); 169 | } 170 | 171 | unsafe fn attach_command_handlers(handler: Arc) { 172 | let command_center: id = msg_send!(class!(MPRemoteCommandCenter), sharedCommandCenter); 173 | 174 | // togglePlayPauseCommand 175 | let play_pause_handler = ConcreteBlock::new({ 176 | let handler = handler.clone(); 177 | move |_event: id| -> NSInteger { 178 | (handler)(MediaControlEvent::Toggle); 179 | MPRemoteCommandHandlerStatusSuccess 180 | } 181 | }) 182 | .copy(); 183 | let cmd: id = msg_send!(command_center, togglePlayPauseCommand); 184 | let _: () = msg_send!(cmd, setEnabled: YES); 185 | let _: () = msg_send!(cmd, addTargetWithHandler: play_pause_handler); 186 | 187 | // playCommand 188 | let play_handler = ConcreteBlock::new({ 189 | let handler = handler.clone(); 190 | move |_event: id| -> NSInteger { 191 | (handler)(MediaControlEvent::Play); 192 | MPRemoteCommandHandlerStatusSuccess 193 | } 194 | }) 195 | .copy(); 196 | let cmd: id = msg_send!(command_center, playCommand); 197 | let _: () = msg_send!(cmd, setEnabled: YES); 198 | let _: () = msg_send!(cmd, addTargetWithHandler: play_handler); 199 | 200 | // pauseCommand 201 | let pause_handler = ConcreteBlock::new({ 202 | let handler = handler.clone(); 203 | move |_event: id| -> NSInteger { 204 | (handler)(MediaControlEvent::Pause); 205 | MPRemoteCommandHandlerStatusSuccess 206 | } 207 | }) 208 | .copy(); 209 | let cmd: id = msg_send!(command_center, pauseCommand); 210 | let _: () = msg_send!(cmd, setEnabled: YES); 211 | let _: () = msg_send!(cmd, addTargetWithHandler: pause_handler); 212 | 213 | // previousTrackCommand 214 | let previous_track_handler = ConcreteBlock::new({ 215 | let handler = handler.clone(); 216 | move |_event: id| -> NSInteger { 217 | (handler)(MediaControlEvent::Previous); 218 | MPRemoteCommandHandlerStatusSuccess 219 | } 220 | }) 221 | .copy(); 222 | let cmd: id = msg_send!(command_center, previousTrackCommand); 223 | let _: () = msg_send!(cmd, setEnabled: YES); 224 | let _: () = msg_send!(cmd, addTargetWithHandler: previous_track_handler); 225 | 226 | // nextTrackCommand 227 | let next_track_handler = ConcreteBlock::new({ 228 | let handler = handler.clone(); 229 | move |_event: id| -> NSInteger { 230 | (handler)(MediaControlEvent::Next); 231 | MPRemoteCommandHandlerStatusSuccess 232 | } 233 | }) 234 | .copy(); 235 | let cmd: id = msg_send!(command_center, nextTrackCommand); 236 | let _: () = msg_send!(cmd, setEnabled: YES); 237 | let _: () = msg_send!(cmd, addTargetWithHandler: next_track_handler); 238 | 239 | // changePlaybackPositionCommand 240 | let position_handler = ConcreteBlock::new({ 241 | let handler = handler.clone(); 242 | // event of type MPChangePlaybackPositionCommandEvent 243 | move |event: id| -> NSInteger { 244 | let position = *event.as_ref().unwrap().get_ivar::("_positionTime"); 245 | (handler)(MediaControlEvent::SetPosition(MediaPosition( 246 | Duration::from_secs_f64(position), 247 | ))); 248 | MPRemoteCommandHandlerStatusSuccess 249 | } 250 | }) 251 | .copy(); 252 | let cmd: id = msg_send!(command_center, changePlaybackPositionCommand); 253 | let _: () = msg_send!(cmd, setEnabled: YES); 254 | let _: () = msg_send!(cmd, addTargetWithHandler: position_handler); 255 | } 256 | 257 | unsafe fn detach_command_handlers() { 258 | let command_center: id = msg_send!(class!(MPRemoteCommandCenter), sharedCommandCenter); 259 | 260 | let cmd: id = msg_send!(command_center, togglePlayPauseCommand); 261 | let _: () = msg_send!(cmd, setEnabled: NO); 262 | let _: () = msg_send!(cmd, removeTarget: nil); 263 | 264 | let cmd: id = msg_send!(command_center, playCommand); 265 | let _: () = msg_send!(cmd, setEnabled: NO); 266 | let _: () = msg_send!(cmd, removeTarget: nil); 267 | 268 | let cmd: id = msg_send!(command_center, pauseCommand); 269 | let _: () = msg_send!(cmd, setEnabled: NO); 270 | let _: () = msg_send!(cmd, removeTarget: nil); 271 | 272 | let cmd: id = msg_send!(command_center, previousTrackCommand); 273 | let _: () = msg_send!(cmd, setEnabled: NO); 274 | let _: () = msg_send!(cmd, removeTarget: nil); 275 | 276 | let cmd: id = msg_send!(command_center, nextTrackCommand); 277 | let _: () = msg_send!(cmd, setEnabled: NO); 278 | let _: () = msg_send!(cmd, removeTarget: nil); 279 | 280 | let cmd: id = msg_send!(command_center, changePlaybackPositionCommand); 281 | let _: () = msg_send!(cmd, setEnabled: NO); 282 | let _: () = msg_send!(cmd, removeTarget: nil); 283 | } 284 | 285 | unsafe fn ns_string(value: &str) -> id { 286 | NSString::alloc(nil).init_str(value) 287 | } 288 | 289 | unsafe fn ns_number(value: f64) -> id { 290 | let number: id = msg_send!(class!(NSNumber), numberWithDouble: value); 291 | number 292 | } 293 | 294 | unsafe fn ns_url(value: &str) -> id { 295 | let url: id = msg_send!(class!(NSURL), URLWithString: ns_string(value)); 296 | url 297 | } 298 | 299 | #[cfg(target_os = "ios")] 300 | unsafe fn load_image_from_url(url: &str) -> (id, CGSize) { 301 | let image_data = fs::read(&url).unwrap(); 302 | let base64_data = base64::encode(image_data); 303 | let base64_ns_string = ns_string(&base64_data); 304 | 305 | let ns_data: id = msg_send!(class!(NSData), alloc); 306 | let ns_data: id = msg_send!(ns_data, initWithBase64EncodedString: base64_ns_string 307 | options: 0); 308 | if ns_data == nil { 309 | return (nil, CGSize::new(0.0, 0.0)); 310 | } 311 | let image: id = msg_send!(class!(UIImage), imageWithData: ns_data); 312 | if image == nil { 313 | return (nil, CGSize::new(0.0, 0.0)); 314 | } 315 | let size: CGSize = msg_send!(image, size); 316 | (image, size) 317 | } 318 | 319 | #[cfg(target_os = "macos")] 320 | unsafe fn load_image_from_url(url: &str) -> (id, CGSize) { 321 | let url = ns_url(url); 322 | let image: id = msg_send!(class!(NSImage), alloc); 323 | let image: id = msg_send!(image, initWithContentsOfURL: url); 324 | let size: CGSize = msg_send!(image, size); 325 | (image, CGSize::new(size.width, size.height)) 326 | } 327 | 328 | #[cfg(target_os = "ios")] 329 | unsafe fn mp_artwork(image: id, bounds: CGSize) -> id { 330 | let artwork: id = msg_send!(class!(MPMediaItemArtwork), alloc); 331 | let artwork: id = msg_send!(artwork, initWithImage: image); 332 | artwork 333 | } 334 | 335 | #[cfg(target_os = "macos")] 336 | unsafe fn mp_artwork(image: id, bounds: CGSize) -> id { 337 | let handler = ConcreteBlock::new(move |_size: CGSize| -> id { image }).copy(); 338 | let artwork: id = msg_send!(class!(MPMediaItemArtwork), alloc); 339 | let artwork: id = msg_send!(artwork, initWithBoundsSize: bounds 340 | requestHandler: handler); 341 | artwork 342 | } 343 | -------------------------------------------------------------------------------- /src/platform/mpris/zbus.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::convert::From; 3 | use std::convert::TryFrom; 4 | use std::convert::TryInto; 5 | use std::sync::{mpsc, Arc, Mutex}; 6 | use std::thread::{self, JoinHandle}; 7 | use std::time::Duration; 8 | 9 | use zbus::{dbus_interface, ConnectionBuilder, SignalContext}; 10 | use zvariant::{ObjectPath, Value}; 11 | 12 | use crate::{ 13 | MediaControlEvent, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, SeekDirection, 14 | }; 15 | 16 | use super::Error; 17 | 18 | /// A handle to OS media controls. 19 | pub struct MediaControls { 20 | thread: Option, 21 | dbus_name: String, 22 | friendly_name: String, 23 | } 24 | 25 | struct ServiceThreadHandle { 26 | event_channel: mpsc::Sender, 27 | thread: JoinHandle<()>, 28 | } 29 | 30 | #[derive(Clone, PartialEq, Debug)] 31 | enum InternalEvent { 32 | ChangeMetadata(OwnedMetadata), 33 | ChangePlayback(MediaPlayback), 34 | ChangeVolume(f64), 35 | Kill, 36 | } 37 | 38 | #[derive(Clone, Debug)] 39 | struct ServiceState { 40 | metadata: OwnedMetadata, 41 | playback_status: MediaPlayback, 42 | volume: f64, 43 | } 44 | 45 | #[derive(Clone, PartialEq, Eq, Debug, Default)] 46 | struct OwnedMetadata { 47 | pub title: Option, 48 | pub album: Option, 49 | pub artist: Option, 50 | pub cover_url: Option, 51 | pub duration: Option, 52 | } 53 | 54 | impl From> for OwnedMetadata { 55 | fn from(other: MediaMetadata) -> Self { 56 | OwnedMetadata { 57 | title: other.title.map(|s| s.to_string()), 58 | artist: other.artist.map(|s| s.to_string()), 59 | album: other.album.map(|s| s.to_string()), 60 | cover_url: other.cover_url.map(|s| s.to_string()), 61 | duration: other.duration.map(|d| d.as_micros().try_into().unwrap()), 62 | } 63 | } 64 | } 65 | 66 | impl MediaControls { 67 | /// Create media controls with the specified config. 68 | pub fn new(config: PlatformConfig) -> Result { 69 | let PlatformConfig { 70 | dbus_name, 71 | display_name, 72 | .. 73 | } = config; 74 | 75 | Ok(Self { 76 | thread: None, 77 | dbus_name: dbus_name.to_string(), 78 | friendly_name: display_name.to_string(), 79 | }) 80 | } 81 | 82 | /// Attach the media control events to a handler. 83 | pub fn attach(&mut self, event_handler: F) -> Result<(), Error> 84 | where 85 | F: Fn(MediaControlEvent) + Send + 'static, 86 | { 87 | self.detach()?; 88 | 89 | let dbus_name = self.dbus_name.clone(); 90 | let friendly_name = self.friendly_name.clone(); 91 | let event_handler = Arc::new(Mutex::new(event_handler)); 92 | let (event_channel, rx) = mpsc::channel(); 93 | 94 | self.thread = Some(ServiceThreadHandle { 95 | event_channel, 96 | thread: thread::spawn(move || { 97 | pollster::block_on(run_service(dbus_name, friendly_name, event_handler, rx)) 98 | .unwrap(); 99 | }), 100 | }); 101 | Ok(()) 102 | } 103 | /// Detach the event handler. 104 | pub fn detach(&mut self) -> Result<(), Error> { 105 | if let Some(ServiceThreadHandle { 106 | event_channel, 107 | thread, 108 | }) = self.thread.take() 109 | { 110 | event_channel.send(InternalEvent::Kill).ok(); 111 | thread.join().map_err(|_| Error::ThreadPanicked)?; 112 | } 113 | Ok(()) 114 | } 115 | 116 | /// Set the current playback status. 117 | pub fn set_playback(&mut self, playback: MediaPlayback) -> Result<(), Error> { 118 | self.send_internal_event(InternalEvent::ChangePlayback(playback))?; 119 | Ok(()) 120 | } 121 | 122 | /// Set the metadata of the currently playing media item. 123 | pub fn set_metadata(&mut self, metadata: MediaMetadata) -> Result<(), Error> { 124 | self.send_internal_event(InternalEvent::ChangeMetadata(metadata.into()))?; 125 | Ok(()) 126 | } 127 | 128 | /// Set the volume level (0.0 - 1.0) (Only available on MPRIS) 129 | pub fn set_volume(&mut self, volume: f64) -> Result<(), Error> { 130 | self.send_internal_event(InternalEvent::ChangeVolume(volume))?; 131 | Ok(()) 132 | } 133 | 134 | fn send_internal_event(&mut self, event: InternalEvent) -> Result<(), Error> { 135 | let channel = &self 136 | .thread 137 | .as_ref() 138 | .ok_or(Error::ThreadNotRunning)? 139 | .event_channel; 140 | channel.send(event).map_err(|_| Error::ThreadPanicked) 141 | } 142 | } 143 | 144 | struct AppInterface { 145 | friendly_name: String, 146 | event_handler: Arc>, 147 | } 148 | 149 | #[dbus_interface(name = "org.mpris.MediaPlayer2")] 150 | impl AppInterface { 151 | fn raise(&self) { 152 | self.send_event(MediaControlEvent::Raise); 153 | } 154 | fn quit(&self) { 155 | self.send_event(MediaControlEvent::Quit); 156 | } 157 | 158 | #[dbus_interface(property)] 159 | fn can_quit(&self) -> bool { 160 | true 161 | } 162 | 163 | #[dbus_interface(property)] 164 | fn can_raise(&self) -> bool { 165 | true 166 | } 167 | 168 | #[dbus_interface(property)] 169 | fn has_tracklist(&self) -> bool { 170 | false 171 | } 172 | 173 | #[dbus_interface(property)] 174 | fn identity(&self) -> &str { 175 | &self.friendly_name 176 | } 177 | 178 | #[dbus_interface(property)] 179 | fn supported_uri_schemes(&self) -> &[&str] { 180 | &[] 181 | } 182 | 183 | #[dbus_interface(property)] 184 | fn supported_mime_types(&self) -> &[&str] { 185 | &[] 186 | } 187 | } 188 | 189 | impl AppInterface { 190 | fn send_event(&self, event: MediaControlEvent) { 191 | (self.event_handler.lock().unwrap())(event); 192 | } 193 | } 194 | 195 | struct PlayerInterface { 196 | state: ServiceState, 197 | event_handler: Arc>, 198 | } 199 | 200 | impl PlayerInterface { 201 | fn send_event(&self, event: MediaControlEvent) { 202 | (self.event_handler.lock().unwrap())(event); 203 | } 204 | } 205 | 206 | #[dbus_interface(name = "org.mpris.MediaPlayer2.Player")] 207 | impl PlayerInterface { 208 | fn next(&self) { 209 | self.send_event(MediaControlEvent::Next); 210 | } 211 | fn previous(&self) { 212 | self.send_event(MediaControlEvent::Previous); 213 | } 214 | fn pause(&self) { 215 | self.send_event(MediaControlEvent::Pause); 216 | } 217 | fn play_pause(&self) { 218 | self.send_event(MediaControlEvent::Toggle); 219 | } 220 | fn stop(&self) { 221 | self.send_event(MediaControlEvent::Stop); 222 | } 223 | fn play(&self) { 224 | self.send_event(MediaControlEvent::Play); 225 | } 226 | 227 | fn seek(&self, offset: i64) { 228 | let abs_offset = offset.unsigned_abs(); 229 | let direction = if offset > 0 { 230 | SeekDirection::Forward 231 | } else { 232 | SeekDirection::Backward 233 | }; 234 | 235 | self.send_event(MediaControlEvent::SeekBy( 236 | direction, 237 | Duration::from_micros(abs_offset), 238 | )); 239 | 240 | // NOTE: Should the `Seeked` signal be called when calling this method? 241 | } 242 | 243 | fn set_position(&self, _track_id: zvariant::ObjectPath, position: i64) { 244 | if let Ok(micros) = position.try_into() { 245 | if let Some(duration) = self.state.metadata.duration { 246 | // If the Position argument is greater than the track length, do nothing. 247 | if position > duration { 248 | return; 249 | } 250 | } 251 | 252 | let position = Duration::from_micros(micros); 253 | self.send_event(MediaControlEvent::SetPosition(MediaPosition(position))); 254 | } 255 | } 256 | 257 | fn open_uri(&self, uri: String) { 258 | // NOTE: we should check if the URI is in the `SupportedUriSchemes` list. 259 | self.send_event(MediaControlEvent::OpenUri(uri)); 260 | } 261 | 262 | #[dbus_interface(property)] 263 | fn playback_status(&self) -> &'static str { 264 | match self.state.playback_status { 265 | MediaPlayback::Playing { .. } => "Playing", 266 | MediaPlayback::Paused { .. } => "Paused", 267 | MediaPlayback::Stopped => "Stopped", 268 | } 269 | } 270 | 271 | #[dbus_interface(property)] 272 | fn rate(&self) -> f64 { 273 | 1.0 274 | } 275 | 276 | #[dbus_interface(property)] 277 | fn metadata(&self) -> HashMap<&str, Value> { 278 | // TODO: this should be stored in a cache inside the state. 279 | let mut dict = HashMap::<&str, Value>::new(); 280 | 281 | let OwnedMetadata { 282 | ref title, 283 | ref album, 284 | ref artist, 285 | ref cover_url, 286 | ref duration, 287 | } = self.state.metadata; 288 | 289 | // MPRIS 290 | dict.insert( 291 | "mpris:trackid", 292 | // TODO: this is just a workaround to enable SetPosition. 293 | Value::new(ObjectPath::try_from("/").unwrap()), 294 | ); 295 | 296 | if let Some(length) = duration { 297 | dict.insert("mpris:length", Value::new(*length)); 298 | } 299 | 300 | if let Some(cover_url) = cover_url { 301 | dict.insert("mpris:artUrl", Value::new(cover_url.clone())); 302 | } 303 | 304 | // Xesam 305 | if let Some(title) = title { 306 | dict.insert("xesam:title", Value::new(title.clone())); 307 | } 308 | if let Some(artist) = artist { 309 | dict.insert("xesam:artist", Value::new(vec![artist.clone()])); 310 | } 311 | if let Some(album) = album { 312 | dict.insert("xesam:album", Value::new(album.clone())); 313 | } 314 | dict 315 | } 316 | 317 | #[dbus_interface(property)] 318 | fn volume(&self) -> f64 { 319 | self.state.volume 320 | } 321 | 322 | #[dbus_interface(property)] 323 | fn set_volume(&self, volume: f64) { 324 | self.send_event(MediaControlEvent::SetVolume(volume)); 325 | } 326 | 327 | #[dbus_interface(property)] 328 | fn position(&self) -> i64 { 329 | let position = match self.state.playback_status { 330 | MediaPlayback::Playing { 331 | progress: Some(pos), 332 | } 333 | | MediaPlayback::Paused { 334 | progress: Some(pos), 335 | } => pos.0.as_micros(), 336 | _ => 0, 337 | }; 338 | 339 | position.try_into().unwrap_or(0) 340 | } 341 | 342 | #[dbus_interface(property)] 343 | fn minimum_rate(&self) -> f64 { 344 | 1.0 345 | } 346 | 347 | #[dbus_interface(property)] 348 | fn maximum_rate(&self) -> f64 { 349 | 1.0 350 | } 351 | 352 | #[dbus_interface(property)] 353 | fn can_go_next(&self) -> bool { 354 | true 355 | } 356 | 357 | #[dbus_interface(property)] 358 | fn can_go_previous(&self) -> bool { 359 | true 360 | } 361 | 362 | #[dbus_interface(property)] 363 | fn can_play(&self) -> bool { 364 | true 365 | } 366 | 367 | #[dbus_interface(property)] 368 | fn can_pause(&self) -> bool { 369 | true 370 | } 371 | 372 | #[dbus_interface(property)] 373 | fn can_seek(&self) -> bool { 374 | true 375 | } 376 | 377 | #[dbus_interface(property)] 378 | fn can_control(&self) -> bool { 379 | true 380 | } 381 | } 382 | 383 | async fn run_service( 384 | dbus_name: String, 385 | friendly_name: String, 386 | event_handler: Arc>, 387 | event_channel: mpsc::Receiver, 388 | ) -> zbus::Result<()> { 389 | let app = AppInterface { 390 | friendly_name, 391 | event_handler: event_handler.clone(), 392 | }; 393 | 394 | let player = PlayerInterface { 395 | state: ServiceState { 396 | metadata: OwnedMetadata::default(), 397 | playback_status: MediaPlayback::Stopped, 398 | volume: 1.0, 399 | }, 400 | event_handler, 401 | }; 402 | 403 | let name = format!("org.mpris.MediaPlayer2.{dbus_name}"); 404 | let path = ObjectPath::try_from("/org/mpris/MediaPlayer2")?; 405 | let connection = ConnectionBuilder::session()? 406 | .serve_at(&path, app)? 407 | .serve_at(&path, player)? 408 | .name(name.as_str())? 409 | .build() 410 | .await?; 411 | 412 | loop { 413 | if let Ok(event) = event_channel.recv_timeout(Duration::from_millis(10)) { 414 | if event == InternalEvent::Kill { 415 | break; 416 | } 417 | 418 | let interface_ref = connection 419 | .object_server() 420 | .interface::<_, PlayerInterface>(&path) 421 | .await?; 422 | let mut interface = interface_ref.get_mut().await; 423 | let ctxt = SignalContext::new(&connection, &path)?; 424 | 425 | match event { 426 | InternalEvent::ChangeMetadata(metadata) => { 427 | interface.state.metadata = metadata; 428 | interface.metadata_changed(&ctxt).await?; 429 | } 430 | InternalEvent::ChangePlayback(playback) => { 431 | interface.state.playback_status = playback; 432 | interface.playback_status_changed(&ctxt).await?; 433 | } 434 | InternalEvent::ChangeVolume(volume) => { 435 | interface.state.volume = volume; 436 | interface.volume_changed(&ctxt).await?; 437 | } 438 | InternalEvent::Kill => (), 439 | } 440 | } 441 | } 442 | 443 | Ok(()) 444 | } 445 | --------------------------------------------------------------------------------