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:
1 parent
d1a13110ea
commit
62e59f599a
5 files changed
+213
No files matched your search
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user