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"] }
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
|
||||
# Media controls (MPRIS on Linux)
|
||||
souvlaki = "0.7"
|
||||
|
||||
# Storage
|
||||
# sled = "0.34"
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ pub async fn play_track(track_id: u64, state: State<'_, AppState>) -> Result<(),
|
||||
#[tauri::command]
|
||||
pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
|
||||
log::info!("Command: pause_playback");
|
||||
state.media_controls.set_playback(false);
|
||||
state.player.pause()
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
|
||||
log::info!("Command: resume_playback");
|
||||
state.media_controls.set_playback(true);
|
||||
state.player.resume()
|
||||
}
|
||||
|
||||
@@ -38,9 +40,33 @@ pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
pub fn stop_playback(state: State<'_, AppState>) -> Result<(), String> {
|
||||
log::info!("Command: stop_playback");
|
||||
state.media_controls.set_stopped();
|
||||
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)
|
||||
#[tauri::command]
|
||||
pub fn set_volume(volume: f32, state: State<'_, AppState>) -> Result<(), String> {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod api;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod media_controls;
|
||||
pub mod player;
|
||||
pub mod queue;
|
||||
|
||||
@@ -13,12 +14,14 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use api::QobuzClient;
|
||||
use media_controls::{MediaControlsManager, TrackInfo};
|
||||
use player::Player;
|
||||
|
||||
/// Application state shared across commands
|
||||
pub struct AppState {
|
||||
pub client: Arc<Mutex<QobuzClient>>,
|
||||
pub player: Player,
|
||||
pub media_controls: MediaControlsManager,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -26,6 +29,7 @@ impl AppState {
|
||||
Self {
|
||||
client: Arc::new(Mutex::new(QobuzClient::default())),
|
||||
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)]
|
||||
pub fn run() {
|
||||
// Initialize logging
|
||||
@@ -69,6 +92,7 @@ pub fn run() {
|
||||
commands::set_volume,
|
||||
commands::seek,
|
||||
commands::get_playback_state,
|
||||
commands::set_media_metadata,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user