media card: Windows knows SoulStream is a music player now

Registering with the System Media Transport Controls is what makes an app
a media app to the OS rather than a window that happens to make noise.
The card that the media-key overlay, the taskbar media flyout and the
lock screen all draw now carries our cover, title, artist and album; the
hardware media keys reach us with the window unfocused; and the transport
buttons on that card drive this app. Verified against the OS's own
session list: title/artist/album/albumArtist, type Music, a live
scrubber, a present thumbnail, and play disabled while playing.

Where each half of it comes from follows where the truth already lives.
Status and position are pushed by the backend's player event pump, which
already sees every transport change and every position tick — the card
is driven by the same events the UI is, so it cannot drift from the
window, and it costs no IPC at all. Metadata goes the other way, once per
track, published by the player bar: the queue lives in the frontend and
so does the derivation the bar displays, and publishing exactly what the
bar shows means the two can never disagree. Deriving it a second time in
Rust would guarantee that one day they do.

Button presses are forwarded as `soulseek:media-button` rather than
handled in Rust, because 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 that side. They run the same functions the on-screen
transport runs — the play/pause branch moved into togglePlayPause() so
there is one implementation, not two that drift. Proven by driving the
app from the OS session: next advanced the queue, pause and play moved
the status, previous restarted the track on the >3s rule the app already
had.

Stop is deliberately disabled: this transport has no stop, only pause,
and a dead button on the card is worse than a missing one. Buffering maps
to Changing, which is what "finding best source…" honestly is. The
thumbnail is the bar's own data URL decoded into an in-memory stream —
no temp files — and it is explicitly cleared when a track has no cover,
because the previous track's art outliving it is worse than none.

The debug handle gains the local playback entry points beside the
streaming ones it already had. That is not only convenience: verifying
this end to end needs a track playing, and every run that reaches for
Soulseek to get one costs a login the server starts ignoring after a
burst.
This commit is contained in:
KeelrLeek committed 2026-08-04 16:08:10 -05:00
1 parent f72b12a892
commit f6eb9ff02f
7 files changed
+439 -7

No files matched your search

+2
View File
@@ -39,6 +39,8 @@ windows = { version = "0.61.3", features = [
"Foundation_Collections",
"Globalization",
"Graphics_Imaging",
# SystemMediaTransportControls — the OS media card (see src/smtc.rs).
"Media",
"Media_FaceAnalysis",
"Media_Ocr",
"Storage_Streams",
+6
View File
@@ -8,6 +8,7 @@ mod loudness;
mod pipeline;
mod playback;
mod preview;
mod smtc;
mod state;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -30,6 +31,10 @@ pub fn run() {
// Prepare the small local vision model while the user is still on
// the app shell, so a first artist visit pays only warm inference.
hero_detector::warm_up(app.handle().clone());
// Before playback::init: the event pump it spawns starts pushing
// transport state at the card immediately, and the card has to be
// registered by then or the first track's status is dropped.
smtc::init(app.handle());
playback::init(app.handle());
playback::prune_cache(app.handle());
preview::init(app.handle());
@@ -128,6 +133,7 @@ pub fn run() {
preview::player_fade_pause_acked,
preview::player_fade_resume,
diagnostics::debug_stats,
smtc::smtc_metadata,
])
.run({
#[allow(unused_mut)]
+12
View File
@@ -238,6 +238,13 @@ pub fn init(app: &tauri::AppHandle) {
// deliberately not recorded: the frontend owns the queue and
// decides what follows, and the next Buffering/Stopped says
// what actually happened.
// The OS media card rides this same mirror (see smtc.rs). The
// snapshot IS the transport the UI is about to be told about,
// so the card cannot drift from the window, and driving it
// from here costs no IPC at all. Copied out rather than used
// under the lock: the card's updates are cross-process calls
// and have no business holding the transport mutex.
let mut card = None;
if let Some(state) = handle.try_state::<PlayerState>() {
if let Ok(mut last) = state.last_transport.lock() {
match &ui {
@@ -261,8 +268,13 @@ pub fn init(app: &tauri::AppHandle) {
}
PlayerUiEvent::TrackEnded { .. } => {}
}
card = Some((last.status.clone(), last.position, last.duration));
}
}
if let Some((status, position, duration)) = card {
crate::smtc::set_status(&handle, &status);
crate::smtc::set_timeline(&handle, position, duration);
}
let _ = handle.emit("soulseek:player", ui);
}
})
+337
View File
@@ -0,0 +1,337 @@
//! 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<String>,
) {
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<f64>) {}
}
#[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<Instant>,
/// 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<MediaPlaybackStatus>,
}
#[derive(Default)]
pub struct SmtcState(Mutex<Option<Controls>>);
/// 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::<SmtcState>() {
*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<Controls> {
// 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::<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>()?;
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<F>(app: &tauri::AppHandle, what: &str, f: F)
where
F: FnOnce(&mut Controls) -> windows::core::Result<()>,
{
let Some(state) = app.try_state::<SmtcState>() 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<f64>) {
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<Vec<u8>> {
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<RandomAccessStreamReference> {
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)
}
}