//! System Media Transport Controls — SoulStream on the OS's own media card. //! //! Windows draws a now-playing card for any app that registers with SMTC: the //! one the media-key overlay shows, the one in the taskbar's media flyout, and //! the one on the lock screen. Registering is what makes an app a media app to //! the OS rather than just a window that happens to make noise — the card //! carries our artwork and title, the hardware media keys reach us even while //! the window is unfocused, and the transport buttons on that card drive this //! app. //! //! The split of responsibility is deliberate, and it follows where the truth //! already lives. //! //! - **Status and position are pushed from Rust**, out of the player-event //! pump in `playback.rs`. That thread already sees every transport change //! and every position tick, so the card is driven by the same events the UI //! is and cannot drift from it. It also costs no IPC at all. //! - **Metadata is pushed from the frontend**, once per track change. The //! queue lives there, and so does the derivation the player bar shows //! (`metaParts`, the artwork resolution). Publishing what the bar is already //! displaying means the OS card and the bar can never disagree; deriving it //! a second time here would guarantee that one day they do. //! - **Button presses are not handled here.** They are forwarded as //! `soulseek:media-button` and the frontend runs exactly the code the //! on-screen transport runs — `nextTrack` knows about shuffle, repeat, the //! album fill and re-sourcing a track restored from the last session, and //! none of that is knowable from this side. use serde::Serialize; /// What the OS card asked for. The names match the frontend's dispatch table; /// see `initEvents` in state.svelte.js. #[derive(Clone, Serialize)] pub struct MediaButton { pub button: &'static str, } #[cfg(windows)] pub use imp::{init, set_metadata, set_status, set_timeline}; #[cfg(not(windows))] pub use stub::{init, set_metadata, set_status, set_timeline}; /// Publish the current track to the OS card. Called once per track change — /// `art` is a data URL, the same one the player bar is showing. #[tauri::command] pub fn smtc_metadata( app: tauri::AppHandle, title: String, artist: String, album: String, art: Option, ) { set_metadata(&app, &title, &artist, &album, art.as_deref()); } #[cfg(not(windows))] mod stub { pub fn init(_app: &tauri::AppHandle) {} pub fn set_metadata( _app: &tauri::AppHandle, _title: &str, _artist: &str, _album: &str, _art: Option<&str>, ) { } pub fn set_status(_app: &tauri::AppHandle, _status: &str) {} pub fn set_timeline(_app: &tauri::AppHandle, _position: f64, _duration: Option) {} } #[cfg(windows)] mod imp { use super::MediaButton; use base64::Engine; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::{Emitter, Manager}; use windows::core::HSTRING; use windows::Foundation::TimeSpan; use windows::Foundation::TypedEventHandler; use windows::Media::{ MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls, SystemMediaTransportControlsButton, SystemMediaTransportControlsButtonPressedEventArgs, SystemMediaTransportControlsDisplayUpdater, SystemMediaTransportControlsTimelineProperties, }; use windows::Storage::Streams::{ DataWriter, InMemoryRandomAccessStream, RandomAccessStreamReference, }; use windows::Win32::Foundation::HWND; use windows::Win32::System::WinRT::ISystemMediaTransportControlsInterop; /// A WinRT TimeSpan counts 100-nanosecond ticks. const TICKS_PER_SEC: f64 = 10_000_000.0; /// The position ticks arrive every 200ms (UI_POSITION_INTERVAL). The OS /// card does not need that: it redraws a scrubber a user is usually not /// even looking at. One update a second keeps it honest for a fraction of /// the cross-process traffic. const TIMELINE_INTERVAL: Duration = Duration::from_millis(1000); struct Controls { controls: SystemMediaTransportControls, updater: SystemMediaTransportControlsDisplayUpdater, /// Rebuilt per update rather than kept: the properties object is a /// value the OS copies at Update time, not a live handle. last_timeline: Option, /// Suppresses repeat writes of a status the card already shows — /// `Paused` arrives once but `Position` keeps coming, and every /// SetPlaybackStatus is a cross-process call. last_status: Option, } #[derive(Default)] pub struct SmtcState(Mutex>); /// Runs on the main thread from the Tauri setup hook, after the window /// exists — SMTC is acquired FOR a window (that is what tells the OS which /// app the card belongs to), so there is nothing to register before then. pub fn init(app: &tauri::AppHandle) { app.manage(SmtcState::default()); let Some(window) = app.get_webview_window("main") else { log::warn!("smtc: no main window; the OS media card will be absent"); return; }; match create(app, &window) { Ok(controls) => { if let Some(state) = app.try_state::() { *state.0.lock().expect("smtc lock") = Some(controls); } log::info!("smtc: registered with the OS media card"); } // Never fatal. A machine where this fails is a machine without the // media card, not a machine without a music player. Err(e) => log::warn!("smtc: registration failed ({e}); no OS media card"), } } fn create( app: &tauri::AppHandle, window: &tauri::WebviewWindow, ) -> windows::core::Result { // Tauri and this crate can resolve to different `windows` versions, so // the handle travels as a raw integer rather than as an HWND — `as // isize` is valid whether tauri's HWND wraps a pointer or an isize. let raw = window .hwnd() .map_err(|e| windows::core::Error::new(windows::Win32::Foundation::E_FAIL, e.to_string()))? .0 as isize; let hwnd = HWND(raw as *mut core::ffi::c_void); // SMTC has no WinRT constructor for desktop apps: a packaged app gets // one per view, a Win32 app asks the interop factory for the one that // belongs to its window. let interop: ISystemMediaTransportControlsInterop = windows::core::factory::()?; let controls: SystemMediaTransportControls = unsafe { interop.GetForWindow(hwnd)? }; controls.SetIsEnabled(true)?; controls.SetIsPlayEnabled(true)?; controls.SetIsPauseEnabled(true)?; controls.SetIsNextEnabled(true)?; controls.SetIsPreviousEnabled(true)?; // Stop is deliberately off. The card would show a button this app has // no equivalent for — there is no "stop" in the transport, only pause // — and a dead button is worse than a missing one. controls.SetIsStopEnabled(false)?; // Closed, not Stopped: nothing has played yet, so the card should not // appear at all until the first track does. controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?; let updater = controls.DisplayUpdater()?; updater.SetType(MediaPlaybackType::Music)?; updater.Update()?; let handle = app.clone(); controls.ButtonPressed(&TypedEventHandler::< SystemMediaTransportControls, SystemMediaTransportControlsButtonPressedEventArgs, >::new(move |_, args| { // Arrives on a WinRT thread-pool thread, not the UI thread. Emit // and return: everything this button means happens in the frontend. let Ok(args) = args.ok() else { return Ok(()) }; let button = match args.Button()? { SystemMediaTransportControlsButton::Play => "play", SystemMediaTransportControlsButton::Pause => "pause", SystemMediaTransportControlsButton::Next => "next", SystemMediaTransportControlsButton::Previous => "previous", // Play/pause on one key arrives as its own button on some // keyboards rather than as Play or Pause. _ => "toggle", }; let _ = handle.emit("soulseek:media-button", MediaButton { button }); Ok(()) }))?; Ok(Controls { controls, updater, last_timeline: None, last_status: None, }) } /// Run `f` against the live controls, if registration succeeded. Every /// failure past init is logged at debug and swallowed: a media card that /// stops updating must never take playback down with it. fn with(app: &tauri::AppHandle, what: &str, f: F) where F: FnOnce(&mut Controls) -> windows::core::Result<()>, { let Some(state) = app.try_state::() else { return; }; let mut guard = state.0.lock().expect("smtc lock"); let Some(controls) = guard.as_mut() else { return; }; if let Err(e) = f(controls) { log::debug!("smtc: {what} failed ({e})"); } } pub fn set_metadata( app: &tauri::AppHandle, title: &str, artist: &str, album: &str, art: Option<&str>, ) { with(app, "metadata", |c| { // Re-stating the type each time is not redundant: ClearAll resets // it, and the properties accessor throws unless a type is set. c.updater.ClearAll()?; c.updater.SetType(MediaPlaybackType::Music)?; let music = c.updater.MusicProperties()?; music.SetTitle(&HSTRING::from(title))?; music.SetArtist(&HSTRING::from(artist))?; music.SetAlbumTitle(&HSTRING::from(album))?; // AlbumArtist as well as Artist: the Windows 11 card prefers it // for the second line and falls back to Artist, and leaving it // empty makes a compilation read as if it had no artist at all. music.SetAlbumArtist(&HSTRING::from(artist))?; match art.and_then(decode_data_url).and_then(|bytes| thumbnail(&bytes).ok()) { Some(stream) => c.updater.SetThumbnail(&stream)?, // Explicitly cleared, not left: the previous track's cover // outliving it is worse than no cover. None => c.updater.SetThumbnail(None)?, } c.updater.Update() }); } pub fn set_status(app: &tauri::AppHandle, status: &str) { let wanted = match status { "playing" => MediaPlaybackStatus::Playing, "paused" => MediaPlaybackStatus::Paused, // Buffering is a real SMTC state and the card shows it as such — // "finding best source…" is exactly what Changing means here. "buffering" => MediaPlaybackStatus::Changing, _ => MediaPlaybackStatus::Stopped, }; with(app, "status", |c| { if c.last_status == Some(wanted) { return Ok(()); } c.controls.SetPlaybackStatus(wanted)?; c.last_status = Some(wanted); // A stop clears the timeline too, or the card keeps a scrubber // parked wherever the last track happened to end. if wanted == MediaPlaybackStatus::Stopped { c.controls .UpdateTimelineProperties(&SystemMediaTransportControlsTimelineProperties::new()?)?; c.last_timeline = None; } Ok(()) }); } pub fn set_timeline(app: &tauri::AppHandle, position: f64, duration: Option) { let Some(duration) = duration.filter(|d| *d > 0.0) else { return; }; with(app, "timeline", |c| { let now = Instant::now(); if c.last_timeline .is_some_and(|last| now.duration_since(last) < TIMELINE_INTERVAL) { return Ok(()); } c.last_timeline = Some(now); let props = SystemMediaTransportControlsTimelineProperties::new()?; props.SetStartTime(span(0.0))?; props.SetMinSeekTime(span(0.0))?; props.SetPosition(span(position.clamp(0.0, duration)))?; props.SetMaxSeekTime(span(duration))?; props.SetEndTime(span(duration))?; c.controls.UpdateTimelineProperties(&props) }); } fn span(secs: f64) -> TimeSpan { TimeSpan { Duration: (secs * TICKS_PER_SEC) as i64, } } /// The player bar's artwork is a `data:` URL — the same bytes the cover /// cache already handed it. Anything else (an http URL, an empty string) /// is not something the OS card can be given synchronously, so it becomes /// no thumbnail rather than a fetch on this thread. fn decode_data_url(url: &str) -> Option> { let payload = url.strip_prefix("data:")?.split_once(",")?; if !payload.0.ends_with(";base64") { return None; } base64::engine::general_purpose::STANDARD .decode(payload.1) .ok() } fn thumbnail(bytes: &[u8]) -> windows::core::Result { let stream = InMemoryRandomAccessStream::new()?; let writer = DataWriter::CreateDataWriter(&stream.GetOutputStreamAt(0)?)?; writer.WriteBytes(bytes)?; // StoreAsync fills the stream; the reference below is taken from the // stream itself, so the bytes must be committed before it is handed // over. Blocking here is a few hundred KB of memcpy on the caller's // thread, not I/O. writer.StoreAsync()?.get()?; writer.FlushAsync()?.get()?; // Detach so dropping the writer does not close the stream underneath // the reference the OS is about to hold. let _ = writer.DetachStream(); RandomAccessStreamReference::CreateFromStream(&stream) } }