feat: add MPRIS media controls integration

Backend (Rust):
- Add souvlaki crate for MPRIS support on Linux
- Create media_controls module with MediaControlsManager
- Track metadata (title, artist, album, duration, cover)
- Playback state (playing/paused/stopped)
- Wire pause/resume/stop commands to update MPRIS
- Add set_media_metadata Tauri command

Frontend (Svelte):
- Call set_media_metadata when track starts playing
- Send track info to MPRIS for system integration

Now shows track info in system media controls and
supports media key events (logged for now).
This commit is contained in:
vicrodh committed 2026-01-09 09:30:26 -06:00
1 parent d1a13110ea
commit 62e59f599a
5 files changed
+213

No files matched your search

+3
View File
@@ -49,6 +49,9 @@ env_logger = "0.11"
rodio = { version = "0.19", features = ["symphonia-all"] } rodio = { version = "0.19", features = ["symphonia-all"] }
symphonia = { version = "0.5", features = ["all"] } symphonia = { version = "0.5", features = ["all"] }
# Media controls (MPRIS on Linux)
souvlaki = "0.7"
# Storage # Storage
# sled = "0.34" # sled = "0.34"
+26
View File
@@ -24,6 +24,7 @@ pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(),
#[tauri::command] #[tauri::command]
pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> { pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: pause_playback"); log::info!("Command: pause_playback");
state.media_controls.set_playback(false);
state.player.pause() state.player.pause()
} }
@@ -31,6 +32,7 @@ pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> { pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: resume_playback"); log::info!("Command: resume_playback");
state.media_controls.set_playback(true);
state.player.resume() state.player.resume()
} }
@@ -38,9 +40,33 @@ pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn stop_playback(state: State<'_, AppState>) -> Result<(), String> { pub fn stop_playback(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: stop_playback"); log::info!("Command: stop_playback");
state.media_controls.set_stopped();
state.player.stop() state.player.stop()
} }
/// Set media controls metadata (for MPRIS integration)
#[tauri::command]
pub fn set_media_metadata(
title: String,
artist: String,
album: String,
duration_secs: Option<u64>,
cover_url: Option<String>,
state: State<'_, AppState>,
) -> Result<(), String> {
log::info!("Command: set_media_metadata - {} by {}", title, artist);
crate::update_media_controls_metadata(
&state.media_controls,
&title,
&artist,
&album,
duration_secs,
cover_url,
);
state.media_controls.set_playback(true);
Ok(())
}
/// Set volume (0.0 - 1.0) /// Set volume (0.0 - 1.0)
#[tauri::command] #[tauri::command]
pub fn set_volume(volume: f32, state: State<'_, AppState>) -> Result<(), String> { pub fn set_volume(volume: f32, state: State<'_, AppState>) -> Result<(), String> {
+24
View File
@@ -6,6 +6,7 @@
pub mod api; pub mod api;
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod media_controls;
pub mod player; pub mod player;
pub mod queue; pub mod queue;
@@ -13,12 +14,14 @@ use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use api::QobuzClient; use api::QobuzClient;
use media_controls::{MediaControlsManager, TrackInfo};
use player::Player; use player::Player;
/// Application state shared across commands /// Application state shared across commands
pub struct AppState { pub struct AppState {
pub client: Arc<Mutex<QobuzClient>>, pub client: Arc<Mutex<QobuzClient>>,
pub player: Player, pub player: Player,
pub media_controls: MediaControlsManager,
} }
impl AppState { impl AppState {
@@ -26,6 +29,7 @@ impl AppState {
Self { Self {
client: Arc::new(Mutex::new(QobuzClient::default())), client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(), player: Player::new(),
media_controls: MediaControlsManager::new(),
} }
} }
} }
@@ -36,6 +40,25 @@ impl Default for AppState {
} }
} }
/// Update MPRIS metadata when track changes
pub fn update_media_controls_metadata(
media_controls: &MediaControlsManager,
title: &str,
artist: &str,
album: &str,
duration_secs: Option<u64>,
cover_url: Option<String>,
) {
let track_info = TrackInfo {
title: title.to_string(),
artist: artist.to_string(),
album: album.to_string(),
duration_secs,
cover_url,
};
media_controls.set_metadata(&track_info);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logging // Initialize logging
@@ -69,6 +92,7 @@ pub fn run() {
commands::set_volume, commands::set_volume,
commands::seek, commands::seek,
commands::get_playback_state, commands::get_playback_state,
commands::set_media_metadata,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+142
View File
@@ -0,0 +1,142 @@
//! MPRIS/Media controls integration
//!
//! Provides system-level media control integration:
//! - MPRIS on Linux (D-Bus based)
//! - Media key support
//! - Now playing notifications
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use std::sync::{Arc, Mutex};
use std::thread;
/// Track metadata for media controls
#[derive(Debug, Clone, Default)]
pub struct TrackInfo {
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: Option<u64>,
pub cover_url: Option<String>,
}
/// Media controls manager
pub struct MediaControlsManager {
controls: Arc<Mutex<Option<MediaControls>>>,
}
impl MediaControlsManager {
/// Create a new media controls manager
pub fn new() -> Self {
let controls = Arc::new(Mutex::new(None));
let controls_clone = controls.clone();
// Initialize media controls in a separate thread
// (souvlaki requires a window handle on some platforms)
thread::spawn(move || {
let config = PlatformConfig {
dbus_name: "qbz",
display_name: "QBZ",
hwnd: None, // Not needed on Linux
};
match MediaControls::new(config) {
Ok(mut mc) => {
// Set up event handler for media key presses
// For now, just log events - we'll wire them up later
if let Err(e) = mc.attach(move |event: MediaControlEvent| {
log::info!("Media control event: {:?}", event);
// TODO: Wire these to player commands via Tauri events
}) {
log::error!("Failed to attach media controls handler: {}", e);
return;
}
log::info!("Media controls initialized successfully (MPRIS)");
// Store the controls
if let Ok(mut guard) = controls_clone.lock() {
*guard = Some(mc);
}
}
Err(e) => {
log::warn!("Failed to initialize media controls: {}. Media keys won't work.", e);
}
}
});
Self {
controls,
}
}
/// Update the currently playing track metadata
pub fn set_metadata(&self, track: &TrackInfo) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() {
let metadata = MediaMetadata {
title: Some(track.title.as_str()),
artist: Some(track.artist.as_str()),
album: Some(track.album.as_str()),
duration: track.duration_secs.map(|d| std::time::Duration::from_secs(d)),
cover_url: track.cover_url.as_deref(),
};
if let Err(e) = controls.set_metadata(metadata) {
log::debug!("Failed to set media metadata: {}", e);
}
}
}
}
/// Update playback state
pub fn set_playback(&self, playing: bool) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() {
let playback = if playing {
MediaPlayback::Playing { progress: None }
} else {
MediaPlayback::Paused { progress: None }
};
if let Err(e) = controls.set_playback(playback) {
log::debug!("Failed to set playback state: {}", e);
}
}
}
}
/// Update playback state with progress
pub fn set_playback_with_progress(&self, playing: bool, position_secs: u64) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() {
let progress = Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs)));
let playback = if playing {
MediaPlayback::Playing { progress }
} else {
MediaPlayback::Paused { progress }
};
if let Err(e) = controls.set_playback(playback) {
log::debug!("Failed to set playback state: {}", e);
}
}
}
}
/// Set stopped state (no track playing)
pub fn set_stopped(&self) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() {
if let Err(e) = controls.set_playback(MediaPlayback::Stopped) {
log::debug!("Failed to set stopped state: {}", e);
}
}
}
}
}
impl Default for MediaControlsManager {
fn default() -> Self {
Self::new()
}
}
+18
View File
@@ -236,6 +236,15 @@
console.log('play_track invoke succeeded'); console.log('play_track invoke succeeded');
isPlaying = true; isPlaying = true;
showToast(`Playing: ${track.title}`, 'success'); showToast(`Playing: ${track.title}`, 'success');
// Update MPRIS metadata for system media controls
await invoke('set_media_metadata', {
title: track.title,
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '',
durationSecs: track.duration,
coverUrl: artwork || null
});
} catch (err) { } catch (err) {
console.error('Failed to play track:', err); console.error('Failed to play track:', err);
showToast(`Playback error: ${err}`, 'error'); showToast(`Playback error: ${err}`, 'error');
@@ -274,6 +283,15 @@
console.log('play_track invoke succeeded'); console.log('play_track invoke succeeded');
isPlaying = true; isPlaying = true;
showToast(`Playing: ${track.title}`, 'success'); showToast(`Playing: ${track.title}`, 'success');
// Update MPRIS metadata for system media controls
await invoke('set_media_metadata', {
title: track.title,
artist: track.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
durationSecs: track.durationSeconds,
coverUrl: artwork || null
});
} catch (err) { } catch (err) {
console.error('Failed to play track:', err); console.error('Failed to play track:', err);
showToast(`Playback error: ${err}`, 'error'); showToast(`Playback error: ${err}`, 'error');