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/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": { 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..9ba2443 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -1 +1,1482 @@ +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, + 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)] +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, + 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 { + 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.flush_state(FlushMode::Full) + } + + /// 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.flush_state(FlushMode::Full) + } + + /// Sets the thumbnail + #[napi] + #[allow(dead_code)] + pub fn set_thumbnail(&mut self, thumbnail: &MediaPlayerThumbnail) -> napi::Result<()> { + self.update_title_data( + TitleDataPatch { + thumbnail: Some(thumbnail.thumbnail.to_owned()), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) + } + + /// 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", + )); + } + + let patch_result: PlaybackPatchResult = self.update_playback_state(PlaybackStatePatch { + duration: Some(duration), + position: Some(position), + ..PlaybackStatePatch::default() + }); + + if !patch_result.changed { + return Ok(()); + } + + 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 + #[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 + ))); + } + + let patch_result: PlaybackPatchResult = self.update_playback_state(PlaybackStatePatch { + playback_status: Some(playback_status), + ..PlaybackStatePatch::default() + }); + if !patch_result.changed { + return Ok(()); + } + self.flush_state(FlushMode::PlaybackOnly) + } + + /// 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<()> { + self.update_title_data( + TitleDataPatch { + title: Some(title), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) + } + + /// 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<()> { + self.update_title_data( + TitleDataPatch { + artist: Some(artist), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) + } + + /// 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<()> { + self.update_title_data( + TitleDataPatch { + album_title: Some(album_title), + ..TitleDataPatch::default() + }, + FlushMode::MetadataOnly, + ) + } + + /// 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<()> { + 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.flush_state(FlushMode::Full) + } + + #[allow(dead_code)] + fn update_metadata(&mut self) -> napi::Result<()> { + 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(()); + } + + 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, + }; + } + + 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 create_flush_payload(&self, flush_mode: FlushMode) -> Option { + if let Ok(state) = self.state.read() { + if !state.active { + return None; + } + + 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, + }; + + 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 + }, + }); + } + + 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, + } + } +} + +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(); + 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); + } + } + _ => {} + } +} + +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) + } +} + +#[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)) +}