diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cb75114..7c19a23 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5da4c3f..8dc5341 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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)] diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs index 2d824bb..99467d7 100644 --- a/src-tauri/src/playback.rs +++ b/src-tauri/src/playback.rs @@ -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::() { 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); } }) diff --git a/src-tauri/src/smtc.rs b/src-tauri/src/smtc.rs new file mode 100644 index 0000000..a5b9d92 --- /dev/null +++ b/src-tauri/src/smtc.rs @@ -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, +) { + 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) + } +} diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index dee0069..7920cf1 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -13,7 +13,7 @@ getArt, getCatalogArt, playerArtSource, - resumePlayback, + togglePlayPause, togglePlayerExpanded, showNowPlaying, saveStream, @@ -296,6 +296,31 @@ return () => (active = false); }); + /* The OS media card (Windows SMTC — src-tauri/src/smtc.rs). Published from + HERE, not from the store: what Windows shows should be what this bar + shows, and this is where that is derived — `metaParts` reconciles the + item's own artist/album fields against the `sub` split, and `artUrl` is + the cover after the local/catalog fork. Deriving either a second time in + Rust would be a second set of rules to keep in step. + Status and position are NOT sent: the backend's player event pump + already has both and drives the card itself, for no IPC. */ + let lastCard = ""; + $effect(() => { + const title = player.track?.title ?? ""; + const [artist, album] = metaParts; + const art = artUrl; + if (!title) return; + // Fires twice per track — once when the title lands, again when the + // cover finishes resolving — and that is deliberate: the card should + // not wait on artwork to say what is playing. Everything past those is + // a re-render carrying the same content, dropped here rather than + // crossing IPC with a few hundred KB of base64 cover for nothing. + const key = `${title}${artist}${album}${art ?? ""}`; + if (key === lastCard) return; + lastCard = key; + void api.smtcMetadata(title, artist, album, art); + }); + // Waveform canvas + hover state. let waveWrap = $state(null); let waveCanvas = $state(null); @@ -598,12 +623,9 @@ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } - function toggle() { - if (player.status === "playing") api.playerControl("pause"); - // Not a bare `resume`: a track restored from the last session has no - // audio loaded behind it and has to be re-sourced from the queue. - else if (player.status === "paused") resumePlayback(); - } + // Shared with the OS media card and the hardware media keys, so a media + // key cannot end up doing something subtly different from this button. + const toggle = togglePlayPause; function seekTarget(e, el) { if (!player.duration || idle) return; diff --git a/src/lib/api.js b/src/lib/api.js index a21cac3..3c1c2a1 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -111,6 +111,16 @@ export const followedArtists = () => invoke("followed_artists"); export const followArtist = (artistId, name, portrait) => invoke("follow_artist", { artistId, name, portrait: portrait ?? null }); export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId }); +// The OS media card (Windows SMTC). Only the metadata crosses IPC and only on +// a track change — status and position are pushed by the backend's own player +// event pump, which already has both. See src-tauri/src/smtc.rs. +export const smtcMetadata = (title, artist, album, art) => + invoke("smtc_metadata", { + title: title ?? "", + artist: artist ?? "", + album: album ?? "", + art: art ?? null, + }); // The WebView blocks target="_blank"; external links open via the backend. export const openExternal = (url) => invoke("open_external", { url }); export const albumArt = (path) => invoke("album_art", { path }); diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 71d1fed..48e6d9f 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -5391,6 +5391,34 @@ export function initEvents() { api.onEvent("soulseek:playlists-changed", refreshPlaylists); + // The OS media card and the hardware media keys, arriving through Windows' + // SMTC (src-tauri/src/smtc.rs). They run the SAME functions the on-screen + // transport runs — the queue, shuffle, repeat and the re-source of a + // restored track all live here, and a second implementation over there + // would be a second set of rules to keep in step. `toggle` is what a + // single play/pause key sends; `play`/`pause` come from the card's + // separate buttons, and both are safe to send at any status because + // togglePlayPause reads the real one. + api.onEvent("soulseek:media-button", (ev) => { + switch (ev?.button) { + case "play": + if (player.status !== "playing") togglePlayPause(); + break; + case "pause": + if (player.status === "playing") togglePlayPause(); + break; + case "toggle": + togglePlayPause(); + break; + case "next": + nextTrack(false); + break; + case "previous": + prevTrack(); + break; + } + }); + // The backend batches search responses (~150ms ticks) so a broad search // can't flood the IPC bridge or trigger a reactive flush per response. api.onEvent("soulseek:search-results", (batch) => { @@ -6254,6 +6282,16 @@ export function resumePlayback() { playQueueIndex(index); } +/** The play/pause button's whole behaviour, in one place — the player bar's + * control and the OS media card's both call this, so a media key can never do + * something subtly different from clicking the button. Not a bare `resume`: + * a track restored from the last session has no audio loaded behind it and + * has to be re-sourced from the queue, which resumePlayback handles. */ +export function togglePlayPause() { + if (player.status === "playing") api.playerControl("pause"); + else if (player.status === "paused") resumePlayback(); +} + /** Consumed by the player-event handler the first time a restored track * reaches `playing`. */ export function takeResumeSeek() { @@ -6357,6 +6395,11 @@ if (import.meta.env.DEV) { app, ui, player, catalog, resolver, nav, libraryStore, recentsStore, streamCatalogAlbum, streamSong, openCatalogArtist, + // The local counterparts of the two above, plus the transport. Lets a + // probe drive playback end to end without Soulseek — which matters + // beyond convenience: the server ignores the handshake after a burst + // of reconnects, so a verification run that streams costs a login. + playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack, catalogGo, refreshLibrary, refreshRecents, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, };