|
16 | 16 |
|
17 | 17 | use std::cell::RefCell; |
18 | 18 | use std::sync::atomic::{AtomicBool, Ordering}; |
19 | | -use std::sync::Arc; |
| 19 | +use std::sync::{Arc, Mutex, OnceLock}; |
20 | 20 |
|
21 | 21 | use windows::core::HSTRING; |
22 | 22 | use windows::Foundation::{TimeSpan, Uri}; |
23 | 23 | use windows::Media::Core::MediaSource; |
24 | 24 | use windows::Media::Playback::{MediaPlaybackState, MediaPlayer}; |
| 25 | +use windows::Media::{ |
| 26 | + MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControlsButton, |
| 27 | +}; |
| 28 | +use windows::Storage::Streams::RandomAccessStreamReference; |
25 | 29 |
|
26 | 30 | extern "C" { |
27 | 31 | fn js_nanbox_get_pointer(value: f64) -> i64; |
@@ -70,6 +74,10 @@ struct PlayerEntry { |
70 | 74 | duration_seconds: f64, |
71 | 75 | on_state_change: Option<f64>, |
72 | 76 | on_time_update: Option<f64>, |
| 77 | + /// Last `MediaPlaybackStatus` we pushed to SMTC. Avoids redundant |
| 78 | + /// vtable calls when the derived state hasn't changed buckets. |
| 79 | + smtc_installed: bool, |
| 80 | + last_smtc_status: Option<MediaPlaybackStatus>, |
73 | 81 | } |
74 | 82 |
|
75 | 83 | thread_local! { |
@@ -136,6 +144,8 @@ pub fn create_player(url_ptr: *const u8) -> i64 { |
136 | 144 | duration_seconds: 0.0, |
137 | 145 | on_state_change: None, |
138 | 146 | on_time_update: None, |
| 147 | + smtc_installed: false, |
| 148 | + last_smtc_status: None, |
139 | 149 | }; |
140 | 150 |
|
141 | 151 | let handle = PLAYERS.with(|p| { |
@@ -240,18 +250,150 @@ pub fn on_time_update(handle: f64, closure: f64) { |
240 | 250 | with_entry_mut(handle, |entry| entry.on_time_update = Some(closure)); |
241 | 251 | } |
242 | 252 |
|
| 253 | +/// Wires `MediaPlayer.SystemMediaTransportControls` so the metadata shows |
| 254 | +/// up on the Windows volume HUD media tile, the Edge / Chromium Now |
| 255 | +/// Playing widget, and Bluetooth headphone media keys (#367). |
| 256 | +/// |
| 257 | +/// `artworkUrl` accepts `https://` URLs (the common case — fed straight |
| 258 | +/// to `RandomAccessStreamReference::CreateFromUri`). `file://` paths are |
| 259 | +/// **not** supported in v1 — `StorageFile::GetFileFromPathAsync` is |
| 260 | +/// genuinely asynchronous and the synchronous-blocking-on-`IAsyncOperation` |
| 261 | +/// dance has its own gotchas in non-MTA threads. Pass an `https://` URL |
| 262 | +/// or `""` (empty string skips artwork). Tracked as a follow-up. |
243 | 263 | pub fn set_now_playing( |
244 | | - _handle: f64, |
245 | | - _title_ptr: *const u8, |
246 | | - _artist_ptr: *const u8, |
247 | | - _album_ptr: *const u8, |
248 | | - _artwork_ptr: *const u8, |
| 264 | + handle: f64, |
| 265 | + title_ptr: *const u8, |
| 266 | + artist_ptr: *const u8, |
| 267 | + album_ptr: *const u8, |
| 268 | + artwork_ptr: *const u8, |
249 | 269 | ) { |
250 | | - // Windows.Media.SystemMediaTransportControls is the canonical |
251 | | - // surface — exposes per-app metadata to the volume HUD, headphone |
252 | | - // play/pause buttons, and the Edge Now Playing tile. Tracked in a |
253 | | - // #351 follow-up; the metadata is silently dropped here so callers |
254 | | - // don't have to feature-detect. |
| 270 | + let title = str_from_header(title_ptr); |
| 271 | + let artist = str_from_header(artist_ptr); |
| 272 | + let album = str_from_header(album_ptr); |
| 273 | + let artwork = str_from_header(artwork_ptr); |
| 274 | + |
| 275 | + with_entry_mut(handle, |entry| { |
| 276 | + let smtc = match entry.player.SystemMediaTransportControls() { |
| 277 | + Ok(s) => s, |
| 278 | + Err(_) => return, |
| 279 | + }; |
| 280 | + |
| 281 | + // Enable buttons. Idempotent — Windows ignores repeat sets. |
| 282 | + let _ = smtc.SetIsPlayEnabled(true); |
| 283 | + let _ = smtc.SetIsPauseEnabled(true); |
| 284 | + let _ = smtc.SetIsStopEnabled(true); |
| 285 | + let _ = smtc.SetIsNextEnabled(true); |
| 286 | + let _ = smtc.SetIsPreviousEnabled(true); |
| 287 | + |
| 288 | + if let Ok(updater) = smtc.DisplayUpdater() { |
| 289 | + let _ = updater.SetType(MediaPlaybackType::Music); |
| 290 | + if let Ok(music) = updater.MusicProperties() { |
| 291 | + if !title.is_empty() { |
| 292 | + let _ = music.SetTitle(&HSTRING::from(title)); |
| 293 | + } |
| 294 | + if !artist.is_empty() { |
| 295 | + let _ = music.SetArtist(&HSTRING::from(artist)); |
| 296 | + } |
| 297 | + if !album.is_empty() { |
| 298 | + let _ = music.SetAlbumArtist(&HSTRING::from(album)); |
| 299 | + } |
| 300 | + } |
| 301 | + |
| 302 | + if !artwork.is_empty() { |
| 303 | + if artwork.starts_with("https://") || artwork.starts_with("http://") { |
| 304 | + if let Ok(uri) = Uri::CreateUri(&HSTRING::from(artwork)) { |
| 305 | + if let Ok(stream) = RandomAccessStreamReference::CreateFromUri(&uri) { |
| 306 | + let _ = updater.SetThumbnail(&stream); |
| 307 | + } |
| 308 | + } |
| 309 | + } else if artwork.starts_with("file://") { |
| 310 | + // file:// requires StorageFile::GetFileFromPathAsync — |
| 311 | + // skipped in v1 (see fn doc). Silently ignored. |
| 312 | + eprintln!( |
| 313 | + "perry/media: setNowPlaying file:// artwork not supported on Windows yet (#367 follow-up); use https://" |
| 314 | + ); |
| 315 | + } |
| 316 | + } |
| 317 | + let _ = updater.Update(); |
| 318 | + } |
| 319 | + |
| 320 | + // Install ButtonPressed handler once per player. The handler |
| 321 | + // fires on a WinRT thread-pool worker — `play / pause / ...` |
| 322 | + // dispatch via `thread_local!` PLAYERS, which is empty on the |
| 323 | + // worker thread. So the handler enqueues into BUTTON_QUEUE |
| 324 | + // (cross-thread) and the main-thread `pump_tick` drains it. |
| 325 | + if !entry.smtc_installed { |
| 326 | + use windows::Foundation::TypedEventHandler; |
| 327 | + let player_handle = handle; |
| 328 | + let _ = smtc.ButtonPressed(&TypedEventHandler::new(move |_, args| { |
| 329 | + if let Some(args) = args { |
| 330 | + let args: &windows::Media::SystemMediaTransportControlsButtonPressedEventArgs = args; |
| 331 | + if let Ok(button) = args.Button() { |
| 332 | + enqueue_button(player_handle, button); |
| 333 | + } |
| 334 | + } |
| 335 | + Ok(()) |
| 336 | + })); |
| 337 | + entry.smtc_installed = true; |
| 338 | + } |
| 339 | + |
| 340 | + // Initial status push so the system UI doesn't open with stale |
| 341 | + // "Stopped". Mirrors the state poller's mapping. |
| 342 | + let status = state_to_smtc_status(entry.state); |
| 343 | + if entry.last_smtc_status != Some(status) { |
| 344 | + let _ = smtc.SetPlaybackStatus(status); |
| 345 | + entry.last_smtc_status = Some(status); |
| 346 | + } |
| 347 | + }); |
| 348 | +} |
| 349 | + |
| 350 | +fn state_to_smtc_status(state: MediaState) -> MediaPlaybackStatus { |
| 351 | + match state { |
| 352 | + MediaState::Playing => MediaPlaybackStatus::Playing, |
| 353 | + MediaState::Paused | MediaState::Ready => MediaPlaybackStatus::Paused, |
| 354 | + MediaState::Ended => MediaPlaybackStatus::Stopped, |
| 355 | + MediaState::Idle | MediaState::Loading | MediaState::Error => MediaPlaybackStatus::Closed, |
| 356 | + } |
| 357 | +} |
| 358 | + |
| 359 | +// SMTC ButtonPressed fires on a WinRT thread-pool worker — drain to the |
| 360 | +// main thread via `pump_tick`. `Mutex<Vec>` is fine; queue is tiny and |
| 361 | +// only contended on actual button presses. |
| 362 | +fn button_queue() -> &'static Mutex<Vec<(f64, SystemMediaTransportControlsButton)>> { |
| 363 | + static Q: OnceLock<Mutex<Vec<(f64, SystemMediaTransportControlsButton)>>> = OnceLock::new(); |
| 364 | + Q.get_or_init(|| Mutex::new(Vec::new())) |
| 365 | +} |
| 366 | + |
| 367 | +fn enqueue_button(handle: f64, button: SystemMediaTransportControlsButton) { |
| 368 | + if let Ok(mut q) = button_queue().lock() { |
| 369 | + q.push((handle, button)); |
| 370 | + } |
| 371 | +} |
| 372 | + |
| 373 | +fn drain_buttons() { |
| 374 | + let drained: Vec<(f64, SystemMediaTransportControlsButton)> = match button_queue().lock() { |
| 375 | + Ok(mut q) => std::mem::take(&mut *q), |
| 376 | + Err(_) => return, |
| 377 | + }; |
| 378 | + for (handle, button) in drained { |
| 379 | + match button { |
| 380 | + SystemMediaTransportControlsButton::Play => play(handle), |
| 381 | + SystemMediaTransportControlsButton::Pause => pause(handle), |
| 382 | + SystemMediaTransportControlsButton::Stop => stop(handle), |
| 383 | + SystemMediaTransportControlsButton::FastForward => { |
| 384 | + let cur = get_current_time(handle); |
| 385 | + seek(handle, cur + 5.0); |
| 386 | + } |
| 387 | + SystemMediaTransportControlsButton::Rewind => { |
| 388 | + let cur = get_current_time(handle); |
| 389 | + seek(handle, (cur - 5.0).max(0.0)); |
| 390 | + } |
| 391 | + // Next / Previous are queue-level concerns; v1 leaves them |
| 392 | + // as no-ops. Multi-track apps can wire their own queue logic |
| 393 | + // on top of `onStateChange`. |
| 394 | + _ => {} |
| 395 | + } |
| 396 | + } |
255 | 397 | } |
256 | 398 |
|
257 | 399 | pub fn destroy(handle: f64) { |
@@ -349,6 +491,10 @@ pub fn pump_tick() { |
349 | 491 | } |
350 | 492 | }); |
351 | 493 | if should_run { |
| 494 | + // Drain button presses queued from the WinRT worker thread first, |
| 495 | + // so a press that arrived since the last tick observes the right |
| 496 | + // player state when it dispatches. |
| 497 | + drain_buttons(); |
352 | 498 | poll_tick(); |
353 | 499 | } |
354 | 500 | } |
@@ -378,6 +524,19 @@ fn poll_tick() { |
378 | 524 | let state_changed = new_state != entry.state; |
379 | 525 | entry.state = new_state; |
380 | 526 |
|
| 527 | + // Push status to SMTC so the volume HUD / Edge Now Playing |
| 528 | + // tile reflect transitions (#367). Only after setNowPlaying |
| 529 | + // installed the handler — otherwise the SMTC isn't surfaced. |
| 530 | + if state_changed && entry.smtc_installed { |
| 531 | + let status = state_to_smtc_status(new_state); |
| 532 | + if entry.last_smtc_status != Some(status) { |
| 533 | + if let Ok(smtc) = entry.player.SystemMediaTransportControls() { |
| 534 | + let _ = smtc.SetPlaybackStatus(status); |
| 535 | + } |
| 536 | + entry.last_smtc_status = Some(status); |
| 537 | + } |
| 538 | + } |
| 539 | + |
381 | 540 | let on_state = if state_changed { |
382 | 541 | entry.on_state_change |
383 | 542 | } else { |
|
0 commit comments