Skip to content

Commit 016926b

Browse files
authored
feat(media-windows): SystemMediaTransportControls integration (#367) (#377)
Wires `setNowPlaying` through `Windows.Media.SystemMediaTransportControls` so the volume HUD media tile, Edge / Chromium Now Playing widget, and Bluetooth headphone media keys reflect Perry app metadata. - Cargo.toml: enable `Media` + `Storage_Streams` features on the windows crate (existing `Media_Playback` / `Media_Core` / `Foundation` were added in v0.5.429 for #351). - `set_now_playing`: enables Play/Pause/Stop/Next/Previous buttons, populates `DisplayUpdater.MusicProperties.{Title, Artist, AlbumArtist}` (skipping empty strings), sets type=Music, fetches https:// artwork via `RandomAccessStreamReference::CreateFromUri` → `SetThumbnail`, and calls `Update()`. file:// is logged + skipped (StorageFile path is genuinely async; tracked as a v2 follow-up). - `ButtonPressed` subscribed via `TypedEventHandler`. Because the event fires on a WinRT thread-pool worker (PLAYERS thread_local is empty there), the handler enqueues into a `Mutex<Vec>`-backed cross-thread queue; the main-thread `pump_tick` drains it before `poll_tick` so a press observes fresh state. Play / Pause / Stop dispatch directly; FastForward / Rewind seek by ±5s. Next / Previous are queue-level concerns (no-ops for v1 — apps wire their own queue on `onStateChange`). - State-change ticks push `SetPlaybackStatus` mapped from `MediaState` (Playing → Playing, Paused/Ready → Paused, Ended → Stopped, others → Closed). `last_smtc_status` cache avoids redundant vtable calls. - Module gate stays at the crate-deps level (the `windows` crate is cfg(target_os = "windows") gated in Cargo.toml). Closes #367.
1 parent 9365080 commit 016926b

3 files changed

Lines changed: 175 additions & 12 deletions

File tree

crates/perry-ui-windows/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,8 @@ windows = { version = "0.58", features = [
4646
"Media_Playback",
4747
"Media_Core",
4848
"Foundation",
49+
# SystemMediaTransportControls / DisplayUpdater / MusicDisplayProperties
50+
# (#367 — volume HUD media tile, headphone media keys, Edge Now Playing).
51+
"Media",
52+
"Storage_Streams",
4953
] }

crates/perry-ui-windows/src/media_playback.rs

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
1717
use std::cell::RefCell;
1818
use std::sync::atomic::{AtomicBool, Ordering};
19-
use std::sync::Arc;
19+
use std::sync::{Arc, Mutex, OnceLock};
2020

2121
use windows::core::HSTRING;
2222
use windows::Foundation::{TimeSpan, Uri};
2323
use windows::Media::Core::MediaSource;
2424
use windows::Media::Playback::{MediaPlaybackState, MediaPlayer};
25+
use windows::Media::{
26+
MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControlsButton,
27+
};
28+
use windows::Storage::Streams::RandomAccessStreamReference;
2529

2630
extern "C" {
2731
fn js_nanbox_get_pointer(value: f64) -> i64;
@@ -70,6 +74,10 @@ struct PlayerEntry {
7074
duration_seconds: f64,
7175
on_state_change: Option<f64>,
7276
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>,
7381
}
7482

7583
thread_local! {
@@ -136,6 +144,8 @@ pub fn create_player(url_ptr: *const u8) -> i64 {
136144
duration_seconds: 0.0,
137145
on_state_change: None,
138146
on_time_update: None,
147+
smtc_installed: false,
148+
last_smtc_status: None,
139149
};
140150

141151
let handle = PLAYERS.with(|p| {
@@ -240,18 +250,150 @@ pub fn on_time_update(handle: f64, closure: f64) {
240250
with_entry_mut(handle, |entry| entry.on_time_update = Some(closure));
241251
}
242252

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.
243263
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,
249269
) {
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+
}
255397
}
256398

257399
pub fn destroy(handle: f64) {
@@ -349,6 +491,10 @@ pub fn pump_tick() {
349491
}
350492
});
351493
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();
352498
poll_tick();
353499
}
354500
}
@@ -378,6 +524,19 @@ fn poll_tick() {
378524
let state_changed = new_state != entry.state;
379525
entry.state = new_state;
380526

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+
381540
let on_state = if state_changed {
382541
entry.on_state_change
383542
} else {

docs/src/system/media.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ tick if the signal hasn't arrived.
8989
| visionOS | AVPlayer + UIImage artwork | **Implemented** + lock-screen |
9090
| Android | `android.media.MediaPlayer` via JNI | **Implemented** (lock-screen via `MediaSessionCompat` is a follow-up) |
9191
| GTK4 / Linux | GStreamer `playbin` element + MPRIS D-Bus | **Implemented** + lock-screen |
92-
| Windows | `Windows.Media.Playback.MediaPlayer` (WinRT) | **Implemented** (`SystemMediaTransportControls` lock-screen is a follow-up) |
92+
| Windows | `Windows.Media.Playback.MediaPlayer` (WinRT) + `SystemMediaTransportControls` | **Implemented** + Now Playing |
9393
| watchOS | AVPlayer + AVAudioSession Playback + UIImage artwork | **Implemented** + Now Playing complication |
9494
| HarmonyOS | `@ohos.multimedia.media.AVPlayer` via napi | Stub |
9595
| Web | `<audio>` element + Media Session API | **Implemented** (`--target web`; `setNowPlaying` populates `navigator.mediaSession.metadata` + wires play / pause / seekto / seekforward / seekbackward action handlers) |

0 commit comments

Comments
 (0)