From b38c0a93f8034a03170e481a11bc7913a9d3e48e Mon Sep 17 00:00:00 2001 From: Venipa Date: Sat, 14 Mar 2026 03:07:46 +0100 Subject: [PATCH 1/3] feat: add macOS support with MediaPlayer implementation and dependencies for media control Signed-off-by: Venipa --- Cargo.lock | 228 ++++++++++++- Cargo.toml | 4 + src/lib.rs | 10 +- src/macos/mod.rs | 836 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1076 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 861dbac..602ce47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -47,6 +47,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -59,6 +65,12 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "cc" version = "1.0.86" @@ -71,6 +83,36 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -80,6 +122,46 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "ctor" version = "0.2.7" @@ -123,12 +205,33 @@ dependencies = [ "dbus", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "float_duration" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aed8e3182af8f7f1227d88fe6203a83e7a0cff36a8f0af7195f11449217480e" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "generator" version = "0.7.5" @@ -222,6 +325,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.1.0" @@ -324,6 +436,15 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "object" version = "0.32.2" @@ -501,6 +622,24 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "thiserror", + "windows 0.44.0", +] + [[package]] name = "syn" version = "2.0.52" @@ -512,6 +651,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -634,6 +793,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows" version = "0.48.0" @@ -681,6 +849,21 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -711,6 +894,12 @@ dependencies = [ "windows_x86_64_msvc 0.52.4", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -723,6 +912,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -735,6 +930,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -747,6 +948,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -759,6 +966,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -771,6 +984,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -783,6 +1002,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -807,5 +1032,6 @@ dependencies = [ "napi-build", "napi-derive", "oneshot", + "souvlaki", "windows 0.54.0", ] diff --git a/Cargo.toml b/Cargo.toml index a08d1ab..5b0a58e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ dashmap = "5.5.3" float_duration = { version = "0.3.3", default-features = false } oneshot = "0.1.6" +[target.'cfg(target_os = "macos")'.dependencies] +dashmap = "5.5.3" +souvlaki = "0.8.3" + [build-dependencies] napi-build = "2.0.1" diff --git a/src/lib.rs b/src/lib.rs index eadde67..273388f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,11 +17,19 @@ mod windows; ) )] mod linux; +#[cfg( + any( + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64") + ) +)] +mod macos; #[cfg( not( any( any(all(target_os = "windows", target_arch = "x86_64"), all(target_os = "windows", target_arch = "aarch64")), - any(all(target_os = "linux", target_arch = "x86_64"), all(target_os = "linux", target_arch = "aarch64")) + any(all(target_os = "linux", target_arch = "x86_64"), all(target_os = "linux", target_arch = "aarch64")), + any(all(target_os = "macos", target_arch = "x86_64"), all(target_os = "macos", target_arch = "aarch64")) ) ) )] diff --git a/src/macos/mod.rs b/src/macos/mod.rs index 8b13789..5033a16 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -1 +1,837 @@ +use std::{ + ffi::c_void, + sync::{Arc, RwLock}, + time::Duration, +}; + +use dashmap::DashMap; +use napi::{ + bindgen_prelude::ObjectFinalize, + threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + Env, JsFunction, NapiRaw, +}; +use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, + SeekDirection, +}; + +#[napi] +#[derive(Debug, PartialEq, Eq)] +pub enum MediaPlayerThumbnailType { + Unknown = -1, + File = 1, + Uri = 2, +} + +#[napi] +#[derive(Debug, PartialEq, Eq)] +pub enum MediaPlayerMediaType { + Unknown = -1, + Music = 1, +} + +#[napi] +#[derive(Debug, PartialEq, Eq)] +pub enum MediaPlayerPlaybackStatus { + Unknown = -1, + Playing = 1, + Paused = 2, + Stopped = 3, +} + +#[napi] +struct MediaPlayerThumbnail { + thumbnail_type: MediaPlayerThumbnailType, + thumbnail: String, +} + +#[napi] +impl MediaPlayerThumbnail { + #[napi(factory)] + #[allow(dead_code)] + pub async fn create( + thumbnail_type: MediaPlayerThumbnailType, + thumbnail: String, + ) -> napi::Result { + match thumbnail_type { + MediaPlayerThumbnailType::File => Ok(Self { + thumbnail_type, + thumbnail: format!("file://{}", thumbnail), + }), + MediaPlayerThumbnailType::Uri => Ok(Self { + thumbnail_type, + thumbnail, + }), + _ => Err(napi::Error::from_reason(format!( + "{:?} is not a valid MediaPlayerThumbnailType to create", + thumbnail_type + ))), + } + } + + #[napi(getter, js_name = "type")] + #[allow(dead_code)] + pub fn thumbnail_type(&self) -> MediaPlayerThumbnailType { + self.thumbnail_type + } +} + +#[derive(Debug)] +struct MediaPlayerState { + active: bool, + can_go_next: bool, + can_go_previous: bool, + can_play: bool, + can_pause: bool, + can_seek: bool, + can_control: bool, + media_type: MediaPlayerMediaType, + playback_status: MediaPlayerPlaybackStatus, + thumbnail: String, + artist: String, + album_title: String, + title: String, + track_id: String, + duration: f64, + position: f64, + playback_rate: f64, +} + +#[napi(custom_finalize)] +struct MediaPlayer { + media_controls: MediaControls, + button_pressed_listeners: + Arc>>, + playback_position_changed_listeners: + Arc>>, + playback_position_seeked_listeners: + Arc>>, + state: Arc>, +} + +#[napi] +impl MediaPlayer { + #[napi(constructor)] + #[allow(dead_code)] + pub fn new(service_name: String, identity: String) -> napi::Result { + let button_pressed_listeners: Arc< + DashMap>, + > = Arc::new(DashMap::new()); + let playback_position_changed_listeners: Arc< + DashMap>, + > = Arc::new(DashMap::new()); + let playback_position_seeked_listeners: Arc< + DashMap>, + > = Arc::new(DashMap::new()); + + let state: Arc> = Arc::new(RwLock::new(MediaPlayerState { + active: false, + can_go_next: false, + can_go_previous: false, + can_play: false, + can_pause: false, + can_seek: false, + can_control: true, + media_type: MediaPlayerMediaType::Unknown, + playback_status: MediaPlayerPlaybackStatus::Unknown, + thumbnail: String::new(), + artist: String::new(), + album_title: String::new(), + title: String::new(), + track_id: String::new(), + duration: 0.0, + position: 0.0, + playback_rate: 1.0, + })); + + let mut media_controls: MediaControls = MediaControls::new(PlatformConfig { + display_name: &identity, + dbus_name: &service_name, + hwnd: Option::<*mut c_void>::None, + }) + .map_err(map_souvlaki_error)?; + + let closure_state: Arc> = state.clone(); + let closure_button_pressed_listeners = button_pressed_listeners.clone(); + let closure_position_changed_listeners = playback_position_changed_listeners.clone(); + let closure_position_seeked_listeners = playback_position_seeked_listeners.clone(); + + media_controls + .attach(move |event: MediaControlEvent| { + handle_media_control_event( + event, + &closure_state, + &closure_button_pressed_listeners, + &closure_position_changed_listeners, + &closure_position_seeked_listeners, + ); + }) + .map_err(map_souvlaki_error)?; + + Ok(Self { + media_controls, + button_pressed_listeners, + playback_position_changed_listeners, + playback_position_seeked_listeners, + state, + }) + } + + /// Activates the MediaPlayer allowing the operating system to see and use it + #[napi] + #[allow(dead_code)] + pub fn activate(&mut self) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.active = true; + } + self.publish_state() + } + + /// Deactivates the MediaPlayer denying the operating system to see and use it + #[napi] + #[allow(dead_code)] + pub fn deactivate(&mut self) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.active = false; + } + self + .media_controls + .set_playback(MediaPlayback::Stopped) + .map_err(map_souvlaki_error) + } + + /// Adds an event listener to the MediaPlayer + /// + /// 'buttonpressed' - Emitted when a media services button is pressed + /// 'positionchanged' - Emitted when the media service requests a position change + /// 'positionseeked' - Emitted when the media service requests a forward or backward position seek from current position + #[napi] + #[allow(dead_code)] + pub fn add_event_listener( + &mut self, + env: Env, + #[napi(ts_arg_type = "'buttonpressed' | 'positionchanged' | 'positionseeked'")] + event_name: String, + callback: JsFunction, + ) -> napi::Result<()> { + let callback_ptr: usize = unsafe { callback.raw() as usize }; + + match event_name.as_str() { + "buttonpressed" => { + if !self.button_pressed_listeners.contains_key(&callback_ptr) { + let mut threadsafe_callback = callback.create_threadsafe_function(0, |ctx| { + ctx.env.create_string_from_std(ctx.value).map(|v| vec![v]) + })?; + let _ = threadsafe_callback.unref(&env)?; + self + .button_pressed_listeners + .insert(callback_ptr, threadsafe_callback); + } + } + "positionchanged" => { + if !self + .playback_position_changed_listeners + .contains_key(&callback_ptr) + { + let mut threadsafe_callback = callback.create_threadsafe_function(0, |ctx| { + ctx.env.create_double(ctx.value).map(|v| vec![v]) + })?; + let _ = threadsafe_callback.unref(&env)?; + self + .playback_position_changed_listeners + .insert(callback_ptr, threadsafe_callback); + } + } + "positionseeked" => { + if !self + .playback_position_seeked_listeners + .contains_key(&callback_ptr) + { + let mut threadsafe_callback = callback.create_threadsafe_function(0, |ctx| { + ctx.env.create_double(ctx.value).map(|v| vec![v]) + })?; + let _ = threadsafe_callback.unref(&env)?; + self + .playback_position_seeked_listeners + .insert(callback_ptr, threadsafe_callback); + } + } + _ => {} + }; + + Ok(()) + } + + /// Removes an event listener from the MediaPlayer + #[napi] + #[allow(dead_code)] + pub fn remove_event_listener( + &mut self, + #[napi(ts_arg_type = "'buttonpressed' | 'positionchanged' | 'positionseeked'")] + event_name: String, + callback: JsFunction, + ) -> napi::Result<()> { + let callback_ptr: usize = unsafe { callback.raw() as usize }; + + match event_name.as_str() { + "buttonpressed" => { + if self.button_pressed_listeners.contains_key(&callback_ptr) { + self.button_pressed_listeners.remove(&callback_ptr); + } + } + "positionchanged" => { + if self + .playback_position_changed_listeners + .contains_key(&callback_ptr) + { + self + .playback_position_changed_listeners + .remove(&callback_ptr); + } + } + "positionseeked" => { + if self + .playback_position_seeked_listeners + .contains_key(&callback_ptr) + { + self + .playback_position_seeked_listeners + .remove(&callback_ptr); + } + } + _ => {} + }; + + Ok(()) + } + + /// Adds an event listener to the MediaPlayer + /// + /// Alias for addEventListener + #[napi] + #[allow(dead_code)] + pub fn on( + &mut self, + env: Env, + #[napi(ts_arg_type = "'buttonpressed' | 'positionchanged' | 'positionseeked'")] + event_name: String, + callback: JsFunction, + ) -> napi::Result<()> { + self.add_event_listener(env, event_name, callback) + } + + /// Removes an event listener from the MediaPlayer + /// + /// Alias for removeEventListener + #[napi] + #[allow(dead_code)] + pub fn off( + &mut self, + #[napi(ts_arg_type = "'buttonpressed' | 'positionchanged' | 'positionseeked'")] + event_name: String, + callback: JsFunction, + ) -> napi::Result<()> { + self.remove_event_listener(event_name, callback) + } + + /// Instructs the media service to update its media information being displayed + #[napi] + #[allow(dead_code)] + pub fn update(&mut self) -> napi::Result<()> { + self.publish_state() + } + + /// Sets the thumbnail + #[napi] + #[allow(dead_code)] + pub fn set_thumbnail(&mut self, thumbnail: &MediaPlayerThumbnail) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.thumbnail = thumbnail.thumbnail.to_owned(); + } + self.update_metadata() + } + + /// Sets the timeline data + /// + /// You MUST call this function everytime the position changes in the song. The media service will become out of sync if this is not called enough or cause seeked signals to be emitted to the media service unnecessarily. + #[napi] + #[allow(dead_code)] + pub fn set_timeline(&mut self, duration: f64, position: f64) -> napi::Result<()> { + if duration < 0.0 { + return Err(napi::Error::from_reason("Duration cannot be less than 0")); + } + if position < 0.0 { + return Err(napi::Error::from_reason("Position cannot be less than 0")); + } + if position > duration { + return Err(napi::Error::from_reason( + "Position cannot be greather than provided duration", + )); + } + + if let Ok(mut state) = self.state.write() { + state.duration = duration; + state.position = position; + } + + self.publish_state() + } + + /// Gets the play button enbled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_play_button_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_play); + } + + Ok(false) + } + + /// Sets the play button enbled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_play_button_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_play = enabled; + } + Ok(()) + } + + /// Gets the paused button enbled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_pause_button_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_pause); + } + + Ok(false) + } + + /// Sets the paused button enbled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_pause_button_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_pause = enabled; + } + Ok(()) + } + + /// Gets the paused button enbled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_stop_button_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_control); + } + + Ok(false) + } + + /// Sets the paused button enbled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_stop_button_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_control = enabled; + } + Ok(()) + } + + /// Gets the previous button enbled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_previous_button_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_go_previous); + } + + Ok(false) + } + + /// Sets the previous button enbled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_previous_button_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_go_previous = enabled; + } + Ok(()) + } + + /// Gets the next button enbled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_next_button_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_go_next); + } + + Ok(false) + } + + /// Sets the next button enbled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_next_button_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_go_next = enabled; + } + Ok(()) + } + + /// Gets the seek enabled state + #[napi(getter)] + #[allow(dead_code)] + pub fn get_seek_enabled(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.can_seek); + } + + Ok(false) + } + + /// Sets the seek enabled state + #[napi(setter)] + #[allow(dead_code)] + pub fn set_seek_enabled(&mut self, enabled: bool) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.can_seek = enabled; + } + Ok(()) + } + + /// Gets the playback rate + #[napi(getter)] + #[allow(dead_code)] + pub fn get_playback_rate(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.playback_rate); + } + + Ok(1.0) + } + + /// Sets the playback rate + #[napi(setter)] + #[allow(dead_code)] + pub fn set_playback_rate(&mut self, playback_rate: f64) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.playback_rate = playback_rate; + } + Ok(()) + } + + /// Gets the playback status + #[napi(getter)] + #[allow(dead_code)] + pub fn get_playback_status(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.playback_status); + } + + Ok(MediaPlayerPlaybackStatus::Unknown) + } + + /// Sets the playback status + #[napi(setter)] + #[allow(dead_code)] + pub fn set_playback_status( + &mut self, + playback_status: MediaPlayerPlaybackStatus, + ) -> napi::Result<()> { + if playback_status == MediaPlayerPlaybackStatus::Unknown { + return Err(napi::Error::from_reason(format!( + "{:?} is not a valid MediaPlayerPlaybackStatus to set", + playback_status + ))); + } + + if let Ok(mut state) = self.state.write() { + state.playback_status = playback_status; + } + + self.update_playback() + } + + /// Gets the media type + #[napi(getter)] + #[allow(dead_code)] + pub fn get_media_type(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.media_type); + } + + Ok(MediaPlayerMediaType::Unknown) + } + + /// Sets the media type + #[napi(setter)] + #[allow(dead_code)] + pub fn set_media_type(&mut self, media_type: MediaPlayerMediaType) -> napi::Result<()> { + if media_type == MediaPlayerMediaType::Unknown { + return Err(napi::Error::from_reason(format!( + "{:?} is not a valid MediaPlayerMediaType to set", + media_type + ))); + } + + if let Ok(mut state) = self.state.write() { + state.media_type = media_type; + } + + Ok(()) + } + + /// Gets the media title + #[napi(getter)] + #[allow(dead_code)] + pub fn get_title(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.title.to_owned()); + } + + Ok(String::new()) + } + + /// Sets the media title + #[napi(setter)] + #[allow(dead_code)] + pub fn set_title(&mut self, title: String) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.title = title; + } + + self.update_metadata() + } + + /// Gets the media artist + #[napi(getter)] + #[allow(dead_code)] + pub fn get_artist(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.artist.to_owned()); + } + + Ok(String::new()) + } + + /// Sets the media artist + #[napi(setter)] + #[allow(dead_code)] + pub fn set_artist(&mut self, artist: String) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.artist = artist; + } + + self.update_metadata() + } + + /// Gets the media album title + #[napi(getter)] + #[allow(dead_code)] + pub fn get_album_title(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.album_title.to_owned()); + } + + Ok(String::new()) + } + + /// Sets the media artist + #[napi(setter)] + #[allow(dead_code)] + pub fn set_album_title(&mut self, album_title: String) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.album_title = album_title; + } + + self.update_metadata() + } + + /// Gets the track id + #[napi(getter)] + #[allow(dead_code)] + pub fn get_track_id(&self) -> napi::Result { + if let Ok(state) = self.state.read() { + return Ok(state.track_id.to_owned()); + } + + Ok(String::new()) + } + + /// Sets the track id + #[napi(setter)] + #[allow(dead_code)] + pub fn set_track_id(&mut self, track_id: String) -> napi::Result<()> { + if let Ok(mut state) = self.state.write() { + state.track_id = track_id; + } + + Ok(()) + } + + fn publish_state(&mut self) -> napi::Result<()> { + self.update_metadata()?; + self.update_playback() + } + + fn update_metadata(&mut self) -> napi::Result<()> { + if let Ok(state) = self.state.read() { + if !state.active { + return Ok(()); + } + + let metadata: MediaMetadata<'_> = MediaMetadata { + title: to_optional_ref(state.title.as_str()), + album: to_optional_ref(state.album_title.as_str()), + artist: to_optional_ref(state.artist.as_str()), + cover_url: to_optional_ref(state.thumbnail.as_str()), + duration: Some(Duration::from_secs_f64(state.duration.max(0.0))), + }; + + return self + .media_controls + .set_metadata(metadata) + .map_err(map_souvlaki_error); + } + + Ok(()) + } + + fn update_playback(&mut self) -> napi::Result<()> { + if let Ok(state) = self.state.read() { + if !state.active { + return Ok(()); + } + + let progress: Option = Some(MediaPosition(Duration::from_secs_f64( + state.position.max(0.0), + ))); + let playback: MediaPlayback = match state.playback_status { + MediaPlayerPlaybackStatus::Playing => MediaPlayback::Playing { progress }, + MediaPlayerPlaybackStatus::Paused => MediaPlayback::Paused { progress }, + _ => MediaPlayback::Stopped, + }; + + return self + .media_controls + .set_playback(playback) + .map_err(map_souvlaki_error); + } + + Ok(()) + } +} + +impl ObjectFinalize for MediaPlayer { + fn finalize(mut self, _env: napi::Env) -> napi::Result<()> { + let _ = self.media_controls.detach(); + self.button_pressed_listeners.clear(); + self.playback_position_changed_listeners.clear(); + self.playback_position_seeked_listeners.clear(); + Ok(()) + } +} + +fn handle_media_control_event( + event: MediaControlEvent, + state: &Arc>, + button_pressed_listeners: &Arc< + DashMap>, + >, + playback_position_changed_listeners: &Arc< + DashMap>, + >, + playback_position_seeked_listeners: &Arc< + DashMap>, + >, +) { + let Ok(current_state) = state.read() else { + return; + }; + + if !current_state.active { + return; + } + + match event { + MediaControlEvent::Play if current_state.can_play => { + emit_button_pressed(button_pressed_listeners, "play"); + } + MediaControlEvent::Pause if current_state.can_pause => { + emit_button_pressed(button_pressed_listeners, "pause"); + } + MediaControlEvent::Toggle if current_state.can_play || current_state.can_pause => { + emit_button_pressed(button_pressed_listeners, "playpause"); + } + MediaControlEvent::Next if current_state.can_go_next => { + emit_button_pressed(button_pressed_listeners, "next"); + } + MediaControlEvent::Previous if current_state.can_go_previous => { + emit_button_pressed(button_pressed_listeners, "previous"); + } + MediaControlEvent::Stop if current_state.can_control => { + emit_button_pressed(button_pressed_listeners, "stop"); + } + MediaControlEvent::SeekBy(direction, amount) if current_state.can_seek => { + let signed_seconds: f64 = match direction { + SeekDirection::Forward => amount.as_secs_f64(), + SeekDirection::Backward => -amount.as_secs_f64(), + }; + emit_seek(playback_position_seeked_listeners, signed_seconds); + } + MediaControlEvent::SetPosition(position) if current_state.can_seek => { + let requested_seconds: f64 = position.0.as_secs_f64(); + if requested_seconds <= current_state.duration { + emit_position(playback_position_changed_listeners, requested_seconds); + } + } + _ => {} + } +} + +fn emit_button_pressed( + listeners: &Arc>>, + button: &str, +) { + for listener in listeners.iter() { + listener.call( + Ok(button.to_string()), + ThreadsafeFunctionCallMode::NonBlocking, + ); + } +} + +fn emit_position( + listeners: &Arc>>, + position: f64, +) { + for listener in listeners.iter() { + listener.call(Ok(position), ThreadsafeFunctionCallMode::NonBlocking); + } +} + +fn emit_seek( + listeners: &Arc>>, + seek_delta: f64, +) { + for listener in listeners.iter() { + listener.call(Ok(seek_delta), ThreadsafeFunctionCallMode::NonBlocking); + } +} + +fn to_optional_ref(value: &str) -> Option<&str> { + if value.is_empty() { + None + } else { + Some(value) + } +} + +fn map_souvlaki_error(error: souvlaki::Error) -> napi::Error { + napi::Error::from_reason(format!("{:?}", error)) +} From 8b176e8f3814d30febecc03eee89577b1b1652b5 Mon Sep 17 00:00:00 2001 From: Venipa Date: Sat, 14 Mar 2026 04:14:21 +0100 Subject: [PATCH 2/3] feat: enhance MediaPlayer state management with new snapshot structures and flush modes - Added state revisioning and metadata tracking to MediaPlayerState. - Introduced MetadataSnapshot and PlaybackSnapshot for better state handling. - Implemented FlushPayload and FlushMode enums to manage state flushing. - Updated methods to utilize new structures for setting title, artist, album, and thumbnail. - Refactored state publishing to use flush modes for improved performance and clarity. --- src/macos/mod.rs | 773 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 709 insertions(+), 64 deletions(-) diff --git a/src/macos/mod.rs b/src/macos/mod.rs index 5033a16..9ba2443 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -95,6 +95,67 @@ struct MediaPlayerState { duration: f64, position: f64, playback_rate: f64, + state_revision: u64, + track_revision: u64, + position_event_track_revision: u64, + track_transition_pending: bool, + prefer_last_playback_position_for_status_flush: bool, + metadata_dirty: bool, + playback_dirty: bool, + last_metadata_snapshot: Option, + last_playback_snapshot: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct MetadataSnapshot { + title: String, + album_title: String, + artist: String, + thumbnail: String, + duration: f64, +} + +#[derive(Clone, Debug, PartialEq)] +struct PlaybackSnapshot { + playback_status: MediaPlayerPlaybackStatus, + position: f64, +} + +#[derive(Clone, Debug)] +struct FlushPayload { + state_revision: u64, + metadata: Option, + playback: Option, +} + +#[derive(Clone, Copy)] +enum FlushMode { + None, + Full, + TrackChange, + MetadataOnly, + PlaybackOnly, +} + +#[derive(Default)] +struct TitleDataPatch { + title: Option, + artist: Option, + album_title: Option, + thumbnail: Option, + track_id: Option, +} + +#[derive(Default)] +struct PlaybackStatePatch { + duration: Option, + position: Option, + playback_status: Option, +} + +struct PlaybackPatchResult { + changed: bool, + completed_track_transition: bool, } #[napi(custom_finalize)] @@ -142,6 +203,15 @@ impl MediaPlayer { duration: 0.0, position: 0.0, playback_rate: 1.0, + state_revision: 0, + track_revision: 0, + position_event_track_revision: 0, + track_transition_pending: false, + prefer_last_playback_position_for_status_flush: false, + metadata_dirty: false, + playback_dirty: false, + last_metadata_snapshot: None, + last_playback_snapshot: None, })); let mut media_controls: MediaControls = MediaControls::new(PlatformConfig { @@ -184,7 +254,7 @@ impl MediaPlayer { if let Ok(mut state) = self.state.write() { state.active = true; } - self.publish_state() + self.flush_state(FlushMode::Full) } /// Deactivates the MediaPlayer denying the operating system to see and use it @@ -338,17 +408,20 @@ impl MediaPlayer { #[napi] #[allow(dead_code)] pub fn update(&mut self) -> napi::Result<()> { - self.publish_state() + self.flush_state(FlushMode::Full) } /// Sets the thumbnail #[napi] #[allow(dead_code)] pub fn set_thumbnail(&mut self, thumbnail: &MediaPlayerThumbnail) -> napi::Result<()> { - if let Ok(mut state) = self.state.write() { - state.thumbnail = thumbnail.thumbnail.to_owned(); - } - self.update_metadata() + self.update_title_data( + TitleDataPatch { + thumbnail: Some(thumbnail.thumbnail.to_owned()), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) } /// Sets the timeline data @@ -369,12 +442,23 @@ impl MediaPlayer { )); } - if let Ok(mut state) = self.state.write() { - state.duration = duration; - state.position = position; + let patch_result: PlaybackPatchResult = self.update_playback_state(PlaybackStatePatch { + duration: Some(duration), + position: Some(position), + ..PlaybackStatePatch::default() + }); + + if !patch_result.changed { + return Ok(()); } - self.publish_state() + let flush_mode: FlushMode = if patch_result.completed_track_transition { + FlushMode::TrackChange + } else { + FlushMode::PlaybackOnly + }; + + self.flush_state(flush_mode) } /// Gets the play button enbled state @@ -549,11 +633,14 @@ impl MediaPlayer { ))); } - if let Ok(mut state) = self.state.write() { - state.playback_status = playback_status; + let patch_result: PlaybackPatchResult = self.update_playback_state(PlaybackStatePatch { + playback_status: Some(playback_status), + ..PlaybackStatePatch::default() + }); + if !patch_result.changed { + return Ok(()); } - - self.update_playback() + self.flush_state(FlushMode::PlaybackOnly) } /// Gets the media type @@ -600,11 +687,13 @@ impl MediaPlayer { #[napi(setter)] #[allow(dead_code)] pub fn set_title(&mut self, title: String) -> napi::Result<()> { - if let Ok(mut state) = self.state.write() { - state.title = title; - } - - self.update_metadata() + self.update_title_data( + TitleDataPatch { + title: Some(title), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) } /// Gets the media artist @@ -622,11 +711,13 @@ impl MediaPlayer { #[napi(setter)] #[allow(dead_code)] pub fn set_artist(&mut self, artist: String) -> napi::Result<()> { - if let Ok(mut state) = self.state.write() { - state.artist = artist; - } - - self.update_metadata() + self.update_title_data( + TitleDataPatch { + artist: Some(artist), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) } /// Gets the media album title @@ -644,11 +735,13 @@ impl MediaPlayer { #[napi(setter)] #[allow(dead_code)] pub fn set_album_title(&mut self, album_title: String) -> napi::Result<()> { - if let Ok(mut state) = self.state.write() { - state.album_title = album_title; - } - - self.update_metadata() + self.update_title_data( + TitleDataPatch { + album_title: Some(album_title), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) } /// Gets the track id @@ -666,63 +759,445 @@ impl MediaPlayer { #[napi(setter)] #[allow(dead_code)] pub fn set_track_id(&mut self, track_id: String) -> napi::Result<()> { - if let Ok(mut state) = self.state.write() { - state.track_id = track_id; - } - - Ok(()) + self.update_title_data( + TitleDataPatch { + track_id: Some(track_id), + ..TitleDataPatch::default() + }, + FlushMode::None, + ) } + #[allow(dead_code)] fn publish_state(&mut self) -> napi::Result<()> { - self.update_metadata()?; - self.update_playback() + self.flush_state(FlushMode::Full) } + #[allow(dead_code)] fn update_metadata(&mut self) -> napi::Result<()> { - if let Ok(state) = self.state.read() { - if !state.active { - return Ok(()); + self.flush_state(FlushMode::MetadataOnly) + } + + #[allow(dead_code)] + fn update_playback(&mut self) -> napi::Result<()> { + self.flush_state(FlushMode::PlaybackOnly) + } + + fn update_title_data(&mut self, patch: TitleDataPatch, flush_mode: FlushMode) -> napi::Result<()> { + let mut did_change: bool = false; + if let Ok(mut state) = self.state.write() { + if let Some(title) = patch.title { + if state.title != title { + state.title = title; + did_change = true; + } + } + if let Some(artist) = patch.artist { + if state.artist != artist { + state.artist = artist; + did_change = true; + } + } + if let Some(album_title) = patch.album_title { + if state.album_title != album_title { + state.album_title = album_title; + did_change = true; + } + } + if let Some(thumbnail) = patch.thumbnail { + if state.thumbnail != thumbnail { + state.thumbnail = thumbnail; + did_change = true; + } + } + if let Some(track_id) = patch.track_id { + if state.track_id != track_id { + state.track_id = track_id; + state.track_revision = state.track_revision.saturating_add(1); + state.track_transition_pending = true; + state.duration = 0.0; + state.position = 0.0; + state.playback_dirty = true; + state.prefer_last_playback_position_for_status_flush = false; + did_change = true; + } + } + + if did_change { + state.state_revision = state.state_revision.saturating_add(1); + state.metadata_dirty = true; } + } + + if !did_change { + return Ok(()); + } - let metadata: MediaMetadata<'_> = MediaMetadata { - title: to_optional_ref(state.title.as_str()), - album: to_optional_ref(state.album_title.as_str()), - artist: to_optional_ref(state.artist.as_str()), - cover_url: to_optional_ref(state.thumbnail.as_str()), - duration: Some(Duration::from_secs_f64(state.duration.max(0.0))), + self.flush_state(flush_mode) + } + + fn update_playback_state(&mut self, patch: PlaybackStatePatch) -> PlaybackPatchResult { + if let Ok(mut state) = self.state.write() { + let mut playback_changed: bool = false; + let mut duration_changed: bool = false; + let mut playback_status_changed: bool = false; + let mut completed_track_transition: bool = false; + + if let Some(duration) = patch.duration { + if (state.duration - duration).abs() > f64::EPSILON { + state.duration = duration; + duration_changed = true; + } + } + + if let Some(position) = patch.position { + if (state.position - position).abs() > f64::EPSILON { + state.position = position; + playback_changed = true; + } + } + + if let Some(playback_status) = patch.playback_status { + if state.playback_status != playback_status { + state.playback_status = playback_status; + playback_status_changed = true; + playback_changed = true; + } + } + + if duration_changed { + state.metadata_dirty = true; + playback_changed = true; + } + + if state.track_transition_pending && (duration_changed || patch.position.is_some()) { + state.position_event_track_revision = state.track_revision; + state.track_transition_pending = false; + completed_track_transition = true; + } + + if patch.position.is_some() || duration_changed { + state.prefer_last_playback_position_for_status_flush = false; + } else if playback_status_changed { + state.prefer_last_playback_position_for_status_flush = true; + } + + if playback_changed { + state.state_revision = state.state_revision.saturating_add(1); + state.playback_dirty = true; + } + + return PlaybackPatchResult { + changed: playback_changed, + completed_track_transition, }; + } - return self - .media_controls - .set_metadata(metadata) - .map_err(map_souvlaki_error); + PlaybackPatchResult { + changed: false, + completed_track_transition: false, + } + } + + fn flush_state(&mut self, flush_mode: FlushMode) -> napi::Result<()> { + let payload: Option = self.create_flush_payload(flush_mode); + let Some(payload) = payload else { + return Ok(()); + }; + + if let Some(metadata_snapshot) = payload.metadata.clone() { + self.send_metadata(&metadata_snapshot)?; + self.mark_metadata_flushed(payload.state_revision, metadata_snapshot); + } + + if let Some(playback_snapshot) = payload.playback.clone() { + self.send_playback(&playback_snapshot)?; + self.mark_playback_flushed(payload.state_revision, playback_snapshot); } Ok(()) } - fn update_playback(&mut self) -> napi::Result<()> { + fn create_flush_payload(&self, flush_mode: FlushMode) -> Option { if let Ok(state) = self.state.read() { if !state.active { - return Ok(()); + return None; } - let progress: Option = Some(MediaPosition(Duration::from_secs_f64( - state.position.max(0.0), - ))); - let playback: MediaPlayback = match state.playback_status { - MediaPlayerPlaybackStatus::Playing => MediaPlayback::Playing { progress }, - MediaPlayerPlaybackStatus::Paused => MediaPlayback::Paused { progress }, - _ => MediaPlayback::Stopped, + let metadata_requested: bool = matches!( + flush_mode, + FlushMode::Full | FlushMode::TrackChange | FlushMode::MetadataOnly + ); + let playback_requested: bool = matches!( + flush_mode, + FlushMode::Full | FlushMode::TrackChange | FlushMode::PlaybackOnly + ); + + let metadata_snapshot: MetadataSnapshot = MetadataSnapshot { + title: state.title.clone(), + album_title: state.album_title.clone(), + artist: state.artist.clone(), + thumbnail: state.thumbnail.clone(), + duration: state.duration.max(0.0), + }; + let playback_position: f64 = if state.prefer_last_playback_position_for_status_flush + && !state.track_transition_pending + { + state + .last_playback_snapshot + .as_ref() + .map_or_else(|| state.position.max(0.0), |snapshot| snapshot.position.max(0.0)) + } else { + state.position.max(0.0) + }; + let playback_snapshot: PlaybackSnapshot = PlaybackSnapshot { + playback_status: state.playback_status, + position: playback_position, }; - return self - .media_controls - .set_playback(playback) - .map_err(map_souvlaki_error); + let should_emit_metadata: bool = metadata_requested + && (state.metadata_dirty + || state.last_metadata_snapshot.as_ref() != Some(&metadata_snapshot)); + let should_emit_playback: bool = playback_requested + && (state.playback_dirty + || state.last_playback_snapshot.as_ref() != Some(&playback_snapshot)); + + if !should_emit_metadata && !should_emit_playback { + return None; + } + + return Some(FlushPayload { + state_revision: state.state_revision, + metadata: if should_emit_metadata { + Some(metadata_snapshot) + } else { + None + }, + playback: if should_emit_playback { + Some(playback_snapshot) + } else { + None + }, + }); } - Ok(()) + None + } + + fn send_metadata(&mut self, metadata_snapshot: &MetadataSnapshot) -> napi::Result<()> { + let metadata: MediaMetadata<'_> = MediaMetadata { + title: to_optional_ref(metadata_snapshot.title.as_str()), + album: to_optional_ref(metadata_snapshot.album_title.as_str()), + artist: to_optional_ref(metadata_snapshot.artist.as_str()), + cover_url: to_optional_ref(metadata_snapshot.thumbnail.as_str()), + duration: Some(Duration::from_secs_f64(metadata_snapshot.duration)), + }; + + self + .media_controls + .set_metadata(metadata) + .map_err(map_souvlaki_error) + } + + fn send_playback(&mut self, playback_snapshot: &PlaybackSnapshot) -> napi::Result<()> { + let progress: Option = + Some(MediaPosition(Duration::from_secs_f64(playback_snapshot.position))); + let playback: MediaPlayback = match playback_snapshot.playback_status { + MediaPlayerPlaybackStatus::Playing => MediaPlayback::Playing { progress }, + MediaPlayerPlaybackStatus::Paused => MediaPlayback::Paused { progress }, + _ => MediaPlayback::Stopped, + }; + + self + .media_controls + .set_playback(playback) + .map_err(map_souvlaki_error) + } + + fn mark_metadata_flushed(&self, state_revision: u64, metadata_snapshot: MetadataSnapshot) { + if let Ok(mut state) = self.state.write() { + state.last_metadata_snapshot = Some(metadata_snapshot); + if state.state_revision == state_revision { + state.metadata_dirty = false; + } + } + } + + fn mark_playback_flushed(&self, state_revision: u64, playback_snapshot: PlaybackSnapshot) { + if let Ok(mut state) = self.state.write() { + state.last_playback_snapshot = Some(playback_snapshot); + if state.state_revision == state_revision { + state.playback_dirty = false; + state.prefer_last_playback_position_for_status_flush = false; + } + } + } + + #[cfg(test)] + fn test_apply_title_data_patch( + state: &mut MediaPlayerState, + patch: TitleDataPatch, + ) -> bool { + let mut did_change: bool = false; + if let Some(title) = patch.title { + if state.title != title { + state.title = title; + did_change = true; + } + } + if let Some(artist) = patch.artist { + if state.artist != artist { + state.artist = artist; + did_change = true; + } + } + if let Some(album_title) = patch.album_title { + if state.album_title != album_title { + state.album_title = album_title; + did_change = true; + } + } + if let Some(thumbnail) = patch.thumbnail { + if state.thumbnail != thumbnail { + state.thumbnail = thumbnail; + did_change = true; + } + } + if let Some(track_id) = patch.track_id { + if state.track_id != track_id { + state.track_id = track_id; + state.track_revision = state.track_revision.saturating_add(1); + state.track_transition_pending = true; + state.duration = 0.0; + state.position = 0.0; + state.playback_dirty = true; + state.prefer_last_playback_position_for_status_flush = false; + did_change = true; + } + } + if did_change { + state.metadata_dirty = true; + state.state_revision = state.state_revision.saturating_add(1); + } + did_change + } + + #[cfg(test)] + fn test_apply_playback_state_patch( + state: &mut MediaPlayerState, + patch: PlaybackStatePatch, + ) -> PlaybackPatchResult { + let mut playback_changed: bool = false; + let mut duration_changed: bool = false; + let mut playback_status_changed: bool = false; + let mut completed_track_transition: bool = false; + + if let Some(duration) = patch.duration { + if (state.duration - duration).abs() > f64::EPSILON { + state.duration = duration; + duration_changed = true; + } + } + if let Some(position) = patch.position { + if (state.position - position).abs() > f64::EPSILON { + state.position = position; + playback_changed = true; + } + } + if let Some(playback_status) = patch.playback_status { + if state.playback_status != playback_status { + state.playback_status = playback_status; + playback_status_changed = true; + playback_changed = true; + } + } + if duration_changed { + state.metadata_dirty = true; + playback_changed = true; + } + if state.track_transition_pending && (duration_changed || patch.position.is_some()) { + state.position_event_track_revision = state.track_revision; + state.track_transition_pending = false; + completed_track_transition = true; + } + if patch.position.is_some() || duration_changed { + state.prefer_last_playback_position_for_status_flush = false; + } else if playback_status_changed { + state.prefer_last_playback_position_for_status_flush = true; + } + if playback_changed { + state.playback_dirty = true; + state.state_revision = state.state_revision.saturating_add(1); + } + PlaybackPatchResult { + changed: playback_changed, + completed_track_transition, + } + } + + #[cfg(test)] + fn test_should_accept_set_position(state: &MediaPlayerState, requested_seconds: f64) -> bool { + requested_seconds <= state.duration + && state.can_seek + && state.position_event_track_revision == state.track_revision + } + + #[cfg(test)] + fn test_should_emit_metadata( + state: &MediaPlayerState, + metadata_snapshot: &MetadataSnapshot, + flush_mode: FlushMode, + ) -> bool { + let metadata_requested: bool = matches!( + flush_mode, + FlushMode::Full | FlushMode::TrackChange | FlushMode::MetadataOnly + ); + metadata_requested + && (state.metadata_dirty || state.last_metadata_snapshot.as_ref() != Some(metadata_snapshot)) + } + + #[cfg(test)] + fn test_should_emit_playback( + state: &MediaPlayerState, + playback_snapshot: &PlaybackSnapshot, + flush_mode: FlushMode, + ) -> bool { + let playback_requested: bool = matches!( + flush_mode, + FlushMode::Full | FlushMode::TrackChange | FlushMode::PlaybackOnly + ); + playback_requested + && (state.playback_dirty || state.last_playback_snapshot.as_ref() != Some(playback_snapshot)) + } + + #[cfg(test)] + fn test_metadata_snapshot_from_state(state: &MediaPlayerState) -> MetadataSnapshot { + MetadataSnapshot { + title: state.title.clone(), + album_title: state.album_title.clone(), + artist: state.artist.clone(), + thumbnail: state.thumbnail.clone(), + duration: state.duration.max(0.0), + } + } + + #[cfg(test)] + fn test_playback_snapshot_from_state(state: &MediaPlayerState) -> PlaybackSnapshot { + let playback_position: f64 = if state.prefer_last_playback_position_for_status_flush + && !state.track_transition_pending + { + state + .last_playback_snapshot + .as_ref() + .map_or_else(|| state.position.max(0.0), |snapshot| snapshot.position.max(0.0)) + } else { + state.position.max(0.0) + }; + + PlaybackSnapshot { + playback_status: state.playback_status, + position: playback_position, + } } } @@ -785,7 +1260,9 @@ fn handle_media_control_event( } MediaControlEvent::SetPosition(position) if current_state.can_seek => { let requested_seconds: f64 = position.0.as_secs_f64(); - if requested_seconds <= current_state.duration { + let is_track_context_current: bool = + current_state.position_event_track_revision == current_state.track_revision; + if requested_seconds <= current_state.duration && is_track_context_current { emit_position(playback_position_changed_listeners, requested_seconds); } } @@ -831,6 +1308,174 @@ fn to_optional_ref(value: &str) -> Option<&str> { } } +#[cfg(test)] +mod tests { + use super::{ + FlushMode, MediaPlayer, MediaPlayerPlaybackStatus, MediaPlayerState, MetadataSnapshot, + PlaybackSnapshot, PlaybackStatePatch, TitleDataPatch, + }; + + fn build_test_state() -> MediaPlayerState { + MediaPlayerState { + active: true, + can_go_next: true, + can_go_previous: true, + can_play: true, + can_pause: true, + can_seek: true, + can_control: true, + media_type: super::MediaPlayerMediaType::Music, + playback_status: MediaPlayerPlaybackStatus::Paused, + thumbnail: String::new(), + artist: String::new(), + album_title: String::new(), + title: String::new(), + track_id: String::new(), + duration: 0.0, + position: 0.0, + playback_rate: 1.0, + state_revision: 0, + track_revision: 0, + position_event_track_revision: 0, + track_transition_pending: false, + prefer_last_playback_position_for_status_flush: false, + metadata_dirty: false, + playback_dirty: false, + last_metadata_snapshot: None, + last_playback_snapshot: None, + } + } + + #[test] + fn track_transition_is_completed_by_timeline_patch() { + let mut state: MediaPlayerState = build_test_state(); + + let title_changed: bool = MediaPlayer::test_apply_title_data_patch( + &mut state, + TitleDataPatch { + track_id: Some(String::from("track-2")), + ..TitleDataPatch::default() + }, + ); + assert!(title_changed); + assert!(state.track_transition_pending); + assert_eq!(state.track_revision, 1); + assert_eq!(state.position_event_track_revision, 0); + + let playback_result = MediaPlayer::test_apply_playback_state_patch( + &mut state, + PlaybackStatePatch { + duration: Some(200.0), + position: Some(0.0), + ..PlaybackStatePatch::default() + }, + ); + + assert!(playback_result.completed_track_transition); + assert_eq!(state.position_event_track_revision, state.track_revision); + assert!(!state.track_transition_pending); + } + + #[test] + fn set_position_is_rejected_for_stale_track_context() { + let mut state: MediaPlayerState = build_test_state(); + state.track_revision = 2; + state.position_event_track_revision = 1; + state.duration = 120.0; + state.can_seek = true; + + assert!(!MediaPlayer::test_should_accept_set_position(&state, 12.0)); + + state.position_event_track_revision = 2; + assert!(MediaPlayer::test_should_accept_set_position(&state, 12.0)); + } + + #[test] + fn metadata_flush_requires_dirty_or_changed_snapshot() { + let mut state: MediaPlayerState = build_test_state(); + state.title = String::from("Song"); + state.duration = 180.0; + + let metadata_snapshot: MetadataSnapshot = MediaPlayer::test_metadata_snapshot_from_state(&state); + state.last_metadata_snapshot = Some(metadata_snapshot.clone()); + state.metadata_dirty = false; + + assert!(!MediaPlayer::test_should_emit_metadata( + &state, + &metadata_snapshot, + FlushMode::PlaybackOnly + )); + assert!(!MediaPlayer::test_should_emit_metadata( + &state, + &metadata_snapshot, + FlushMode::TrackChange + )); + + state.metadata_dirty = true; + assert!(MediaPlayer::test_should_emit_metadata( + &state, + &metadata_snapshot, + FlushMode::TrackChange + )); + } + + #[test] + fn paused_playback_flush_uses_latest_position_snapshot() { + let mut state: MediaPlayerState = build_test_state(); + state.position = 55.0; + state.playback_status = MediaPlayerPlaybackStatus::Playing; + state.last_playback_snapshot = Some(PlaybackSnapshot { + playback_status: MediaPlayerPlaybackStatus::Playing, + position: 58.0, + }); + state.playback_dirty = false; + + let changed: bool = MediaPlayer::test_apply_playback_state_patch( + &mut state, + PlaybackStatePatch { + playback_status: Some(MediaPlayerPlaybackStatus::Paused), + ..PlaybackStatePatch::default() + }, + ) + .changed; + assert!(changed); + assert!(state.prefer_last_playback_position_for_status_flush); + + let playback_snapshot: PlaybackSnapshot = MediaPlayer::test_playback_snapshot_from_state(&state); + assert_eq!(playback_snapshot.position, 58.0); + assert_eq!(playback_snapshot.playback_status, MediaPlayerPlaybackStatus::Paused); + assert!(MediaPlayer::test_should_emit_playback( + &state, + &playback_snapshot, + FlushMode::PlaybackOnly + )); + } + + #[test] + fn track_change_resets_timeline_state_before_next_playback_flush() { + let mut state: MediaPlayerState = build_test_state(); + state.duration = 245.0; + state.position = 182.0; + state.last_playback_snapshot = Some(PlaybackSnapshot { + playback_status: MediaPlayerPlaybackStatus::Playing, + position: 182.0, + }); + + let title_changed: bool = MediaPlayer::test_apply_title_data_patch( + &mut state, + TitleDataPatch { + track_id: Some(String::from("new-track")), + ..TitleDataPatch::default() + }, + ); + + assert!(title_changed); + assert_eq!(state.duration, 0.0); + assert_eq!(state.position, 0.0); + assert!(state.track_transition_pending); + } +} + fn map_souvlaki_error(error: souvlaki::Error) -> napi::Error { napi::Error::from_reason(format!("{:?}", error)) } From 330cb994063a1a8857fee147ba27079752e076e3 Mon Sep 17 00:00:00 2001 From: Venipa Date: Sat, 14 Mar 2026 04:14:52 +0100 Subject: [PATCH 3/3] chore: update version to 0.7.0 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d8183c0..75fc4d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xosms", - "version": "0.6.2", + "version": "0.7.0", "main": "index.js", "types": "index.d.ts", "napi": {