From 5d4b6d83b99e0505495da277a08f229ef5efd4f0 Mon Sep 17 00:00:00 2001 From: rlpratyoosh Date: Mon, 8 Jun 2026 20:18:29 +0530 Subject: [PATCH 1/2] feat: smtc integration for windows --- Cargo.lock | 12 +++ Cargo.toml | 4 +- src/infra/media_metadata.rs | 3 +- src/infra/player/events.rs | 59 +++++++++++ src/runtime.rs | 189 ++++++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce2e126..509b275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5442,6 +5442,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smtc-tokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efdb8287e392ee9fe68f7dbf87d5673e6ed465f0aaab99b0e6680c346fdc1e6e" +dependencies = [ + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + [[package]] name = "socket2" version = "0.6.3" @@ -5537,6 +5548,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", + "smtc-tokio", "tokio", "tokio-tungstenite 0.29.0", "tui-bar-graph", diff --git a/Cargo.toml b/Cargo.toml index 1bcfabc..c4e3661 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ arboard = "3.4" # Without this, librespot-playback falls back to the `pipe` sink which writes raw audio bytes # to stdout (breaking the TUI and producing no audible output). librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["rodio-backend"] } +smtc-tokio = { version = "0.1.0", optional = true } # MPRIS D-Bus support (Linux only) [target.'cfg(target_os = "linux")'.dependencies] @@ -104,7 +105,7 @@ objc2 = { version = "0.6", optional = true } block2 = { version = "0.6", optional = true } [features] -default = ["telemetry", "streaming", "audio-viz-cpal", "macos-media", "discord-rpc", "mpris", "self-update"] +default = ["telemetry", "streaming", "audio-viz-cpal", "macos-media", "discord-rpc", "mpris", "self-update", "windows-media"] telemetry = [] self-update = ["dep:self_update", "dep:sha2", "dep:hex"] streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata", "librespot-protocol", "protobuf"] @@ -121,6 +122,7 @@ audio-viz = ["realfft", "pipewire"] audio-viz-cpal = ["realfft", "cpal"] # Alternative for Windows/macOS or if pipewire issues mpris = ["mpris-server"] # MPRIS D-Bus integration (Linux only) macos-media = ["objc2-media-player", "objc2-foundation", "objc2-app-kit", "objc2", "block2", "streaming"] # macOS Now Playing integration +windows-media = ["smtc-tokio", "streaming"] # windows SMTC integration discord-rpc = ["discord-rich-presence"] cover-art = ["ratatui-image", "image"] diff --git a/src/infra/media_metadata.rs b/src/infra/media_metadata.rs index 112c985..6eaf5d4 100644 --- a/src/infra/media_metadata.rs +++ b/src/infra/media_metadata.rs @@ -2,7 +2,8 @@ not(any( feature = "discord-rpc", all(feature = "mpris", target_os = "linux"), - all(feature = "macos-media", target_os = "macos") + all(feature = "macos-media", target_os = "macos"), + all(feature = "windows-media", target_os = "windows") )), allow(dead_code) )] diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index c1e7f4d..130c830 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -30,6 +30,8 @@ pub struct PlayerEventContext { pub mpris_manager: Option>, #[cfg(all(feature = "macos-media", target_os = "macos"))] pub macos_media_manager: Option>, + #[cfg(all(feature = "windows-media", target_os = "windows"))] + pub windows_media_manager: Option>, } pub struct StreamingRecoveryContext { @@ -44,6 +46,8 @@ pub struct StreamingRecoveryContext { pub mpris_manager: Option>, #[cfg(all(feature = "macos-media", target_os = "macos"))] pub macos_media_manager: Option>, + #[cfg(all(feature = "windows-media", target_os = "windows"))] + pub windows_media_manager: Option>, } pub fn spawn_streaming_recovery_handler(ctx: StreamingRecoveryContext) { @@ -108,6 +112,8 @@ async fn handle_streaming_recovery(mut ctx: StreamingRecoveryContext) { mpris_manager: ctx.mpris_manager.clone(), #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager: ctx.macos_media_manager.clone(), + #[cfg(all(feature = "windows-media", target_os = "windows"))] + windows_media_manager: ctx.windows_media_manager.clone(), }); } Err(e) => { @@ -138,6 +144,8 @@ pub fn spawn_player_event_handler(ctx: PlayerEventContext) { let mpris_manager = ctx.mpris_manager.clone(); #[cfg(all(feature = "macos-media", target_os = "macos"))] let macos_media_manager = ctx.macos_media_manager.clone(); + #[cfg(all(feature = "windows-media", target_os = "windows"))] + let windows_media_manager = ctx.windows_media_manager.clone(); tokio::spawn(async move { handle_player_events( @@ -151,6 +159,8 @@ pub fn spawn_player_event_handler(ctx: PlayerEventContext) { mpris_manager, #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager, + #[cfg(all(feature = "windows-media", target_os = "windows"))] + windows_media_manager, ) .await; }); @@ -171,6 +181,9 @@ async fn handle_player_events( #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager: Option< Arc, >, + #[cfg(all(feature = "windows-media", target_os = "windows"))] windows_media_manager: Option< + Arc, + >, ) { use chrono::TimeDelta; @@ -206,6 +219,11 @@ async fn handle_player_events( macos_media.set_playback_status(true); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_playback_status(true); + } + { let mut app_lock = app.lock().await; app_lock.native_is_playing = Some(true); @@ -252,6 +270,11 @@ async fn handle_player_events( macos_media.set_playback_status(false); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_playback_status(false); + } + { let mut app_lock = app.lock().await; app_lock.native_is_playing = Some(false); @@ -277,6 +300,11 @@ async fn handle_player_events( macos_media.set_position(position_ms as u64); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_position(position_ms as u64); + } + if let Ok(mut app) = app.try_lock() { app.song_progress_ms = position_ms as u128; app.seek_ms = None; @@ -332,6 +360,17 @@ async fn handle_player_events( ); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_metadata( + &audio_item.name, + &artists, + &album, + audio_item.duration_ms as u64, + None, + ); + } + let mut app = app.lock().await; app.native_track_info = Some(app::NativeTrackInfo { name: audio_item.name.clone(), @@ -357,6 +396,11 @@ async fn handle_player_events( macos_media.set_stopped(); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_stopped(); + } + if let Ok(mut app) = app.try_lock() { if let Some(ref mut ctx) = app.current_playback_context { ctx.is_playing = false; @@ -383,6 +427,11 @@ async fn handle_player_events( macos_media.set_stopped(); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_stopped(); + } + if let Ok(mut app) = app.try_lock() { if let Some(ref mut ctx) = app.current_playback_context { ctx.is_playing = false; @@ -445,6 +494,11 @@ async fn handle_player_events( if let Some(ref macos_media) = macos_media_manager { macos_media.set_position(position_ms as u64); } + + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_position(position_ms as u64); + } } PlayerEvent::SessionDisconnected { .. } => { #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -457,6 +511,11 @@ async fn handle_player_events( macos_media.set_stopped(); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + windows_media.set_stopped(); + } + if let Some(request) = disconnect_streaming_player( &app, &player, diff --git a/src/runtime.rs b/src/runtime.rs index 6d0999b..5ed7367 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -84,6 +84,17 @@ struct MacosMetadata { duration_ms: u32, art_url: Option, } + +#[cfg(all(feature = "windows-media", target_os = "windows"))] +#[derive(Default, PartialEq)] +struct WindowsMetadata { + title: String, + artists: Vec, + album: String, + duration: u64, + art_url: Option, +} + #[cfg(feature = "discord-rpc")] fn resolve_discord_app_id(user_config: &UserConfig) -> Option { std::env::var("SPOTATUI_DISCORD_APP_ID") @@ -124,6 +135,36 @@ fn update_macos_metadata( } } +#[cfg(all(feature = "windows-media", target_os = "windows"))] +fn update_windows_metadata( + manager: &smtc_tokio::WindowsMediaManager, + last_metadata: &mut Option, + app: &App, +) { + if let Some(snapshot) = crate::infra::media_metadata::current_playback_snapshot(app) { + let new_metadata = WindowsMetadata { + title: snapshot.metadata.title.clone(), + artists: snapshot.metadata.artists.clone(), + album: snapshot.metadata.album.clone(), + duration: snapshot.metadata.duration_ms as u64, + art_url: snapshot.metadata.image_url.clone(), + }; + + if last_metadata.as_ref() != Some(&new_metadata) { + manager.set_metadata( + &snapshot.metadata.title, + &snapshot.metadata.artists, + &snapshot.metadata.album, + snapshot.metadata.duration_ms as u64, + snapshot.metadata.image_url, + ); + *last_metadata = Some(new_metadata); + } + } else if last_metadata.is_some() { + *last_metadata = None; + } +} + #[cfg(feature = "streaming")] fn subscription_level_label(level: rspotify::model::SubscriptionLevel) -> &'static str { match level { @@ -943,6 +984,26 @@ screens more often and cost more CPU. Animation-heavy views keep their separate None }; + #[cfg(all(feature = "windows-media", target_os = "windows"))] + let windows_media_manager: Option> = + if streaming_player.is_some() { + match smtc_tokio::WindowsMediaManager::new() { + Ok(mgr) => { + info!("windows smtc com registered - media keys enabled"); + Some(Arc::new(mgr)) + } + Err(e) => { + info!( + "failed to initialize windows smtc com: {} - media keys disabled", + e + ); + None + } + } + } else { + None + }; + #[cfg(feature = "discord-rpc")] let discord_rpc_manager: DiscordRpcHandle = if user_config.behavior.enable_discord_rpc { match resolve_discord_app_id(&user_config) @@ -1017,6 +1078,53 @@ screens more often and cost more CPU. Animation-heavy views keep their separate }); } + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + if let Some(event_rx) = windows_media.take_event_rx() { + let app_for_windows = Arc::clone(&app); + tokio::spawn(async move { + handle_windows_media_events(event_rx, app_for_windows).await; + }); + } + } + + #[cfg(all(feature = "windows-media", target_os = "windows"))] + if let Some(ref windows_media) = windows_media_manager { + let windows_media_for_metadata = Arc::clone(windows_media); + let app_for_windows_metadata = Arc::clone(&app); + tokio::spawn(async move { + let mut last_metadata: Option = None; + let mut last_playing: Option = None; // Track play state + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1)); + + loop { + interval.tick().await; + if let Ok(app) = app_for_windows_metadata.try_lock() { + update_windows_metadata(&windows_media_for_metadata, &mut last_metadata, &app); + let is_playing = if app.native_track_info.is_some() { + app.native_is_playing.unwrap_or(false) + } else { + app + .current_playback_context + .as_ref() + .map(|c| c.is_playing) + .unwrap_or(false) + }; + + if app.native_track_info.is_none() { + if last_playing != Some(is_playing) { + windows_media_for_metadata.set_playback_status(is_playing); + last_playing = Some(is_playing); + } + windows_media_for_metadata.set_position(app.song_progress_ms as u64); + } else { + last_playing = Some(is_playing); + } + } + } + }); + } + // Clone MPRIS manager for player event handler #[cfg(all(feature = "streaming", feature = "mpris", target_os = "linux"))] let mpris_for_events = mpris_manager.clone(); @@ -1029,6 +1137,9 @@ screens more often and cost more CPU. Animation-heavy views keep their separate #[cfg(all(feature = "mpris", target_os = "linux"))] let mpris_for_ui = mpris_manager.clone(); + #[cfg(all(feature = "windows-media", target_os = "windows"))] + let windows_media_for_events = windows_media_manager.clone(); + // Spawn player event listener (updates app state from native player events) #[cfg(feature = "streaming")] if let Some(ref player) = streaming_player { @@ -1042,6 +1153,8 @@ screens more often and cost more CPU. Animation-heavy views keep their separate mpris_manager: mpris_for_events, #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager: macos_media_for_events, + #[cfg(all(feature = "windows-media", target_os = "windows"))] + windows_media_manager: windows_media_for_events, }); } @@ -1059,6 +1172,8 @@ screens more often and cost more CPU. Animation-heavy views keep their separate mpris_manager: mpris_manager.clone(), #[cfg(all(feature = "macos-media", target_os = "macos"))] macos_media_manager: macos_media_manager.clone(), + #[cfg(all(feature = "windows-media", target_os = "windows"))] + windows_media_manager: windows_media_manager.clone(), }); } @@ -1405,6 +1520,80 @@ async fn handle_macos_media_events( } } +#[cfg(all(feature = "windows-media", target_os = "windows"))] +async fn handle_windows_media_events( + mut event_rx: tokio::sync::mpsc::UnboundedReceiver, + app: Arc>, +) { + use smtc_tokio::WindowsMediaEvent; + + while let Some(event) = event_rx.recv().await { + let player_opt = player::active_streaming_player(&app).await; + + let is_native_loaded = app.lock().await.native_track_info.is_some(); + + match event { + WindowsMediaEvent::Play => { + if let Some(player) = &player_opt { + if is_native_loaded { + player.play(); + continue; + } + } + app + .lock() + .await + .dispatch(IoEvent::StartPlayback(None, None, None)); + } + WindowsMediaEvent::Pause => { + if let Some(player) = &player_opt { + if is_native_loaded { + player.pause(); + continue; + } + } + app.lock().await.dispatch(IoEvent::PausePlayback); + } + WindowsMediaEvent::Next => { + if let Some(player) = &player_opt { + player.activate(); + player.next(); + player.play(); + } else { + app.lock().await.dispatch(IoEvent::NextTrack); + } + } + WindowsMediaEvent::Previous => { + if let Some(player) = &player_opt { + player.activate(); + player.prev(); + player.play(); + } else { + app.lock().await.dispatch(IoEvent::PreviousTrack); + } + } + WindowsMediaEvent::Stop => { + if let Some(player) = &player_opt { + player.stop(); + } else { + app.lock().await.dispatch(IoEvent::PausePlayback); + } + } + WindowsMediaEvent::SetPosition(pos) => { + if let Some(player) = &player_opt { + if is_native_loaded { + player.seek(pos as u32); + continue; + } + } + let mut app_lock = app.lock().await; + app_lock.song_progress_ms = pos as u128; + app_lock.dispatch(IoEvent::Seek(pos as u32)); + } + } + } +} + #[cfg(test)] mod tests { use super::{startup_device_decision, StartupDeviceEvent}; From 1550f65e42272e9e38c2a13840ab446b60a4a136 Mon Sep 17 00:00:00 2001 From: rlpratyoosh Date: Wed, 10 Jun 2026 17:30:48 +0530 Subject: [PATCH 2/2] docs: changelog for smtc integration --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab7bbac..945082a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- **SMTC Integration**: System Media Transport Controls is now integrated with the app for Windows users. Users can now control playback state using media keys and check playback state in media flyouts ([#229](https://github.com/LargeModGames/spotatui/issues/229)). - **Click and drag to seek on the playbar**: The progress bar is now interactive. Click anywhere on the gauge to jump to that position, or click and drag to scrub. Control buttons keep priority, the time label stays non-clickable, and seeks reuse the existing native and throttled-API paths ([#157](https://github.com/LargeModGames/spotatui/issues/157)). ### Fixed