diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28f010cd..04400fa2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5433,6 +5433,8 @@ pub fn run() { if let Err(error) = initialize_app_log(app.handle()) { std::eprintln!("[internal][tauri][warn] {}", error.message); } + #[cfg(target_os = "linux")] + linux_media::mirror_volume(app.handle()); // Always built, so toggling the setting takes effect without a restart. if let Err(error) = build_tray(app.handle()) { std::eprintln!("[internal][tauri][warn] tray unavailable: {error}"); diff --git a/src-tauri/src/linux_media.rs b/src-tauri/src/linux_media.rs index 4ac4622d..dc051e0a 100644 --- a/src-tauri/src/linux_media.rs +++ b/src-tauri/src/linux_media.rs @@ -4,9 +4,17 @@ use souvlaki::{ }; use std::sync::Mutex; use std::time::Duration; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Listener, Manager, State}; const MEDIA_CONTROL_EVENT: &str = "linux-media-control"; +/// Emitted by the main window on every volume or mute change; the mini player follows it too. +const VOLUME_SYNC_EVENT: &str = "player-volume-sync"; + +#[derive(Deserialize)] +struct VolumeSync { + muted: bool, + volume: f64, +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -31,6 +39,7 @@ struct CachedMetadata { pub struct LinuxMediaSession { controls: Mutex>, metadata: Mutex>, + volume: Mutex>, } impl LinuxMediaSession { @@ -38,6 +47,19 @@ impl LinuxMediaSession { Self { controls: Mutex::new(None), metadata: Mutex::new(None), + volume: Mutex::new(None), + } + } + + /// Remembers the volume even before MPRIS is up, so the session starts with the right one. + fn set_volume(&self, volume: f64) { + if let Ok(mut stored) = self.volume.lock() { + *stored = Some(volume); + } + if let Ok(mut controls) = self.controls.lock() { + if let Some(controls) = controls.as_mut() { + let _ = controls.set_volume(volume); + } } } @@ -83,10 +105,20 @@ impl LinuxMediaSession { }), ); } + MediaControlEvent::SetVolume(volume) => { + let _ = app_handle.emit( + MEDIA_CONTROL_EVENT, + serde_json::json!({ "action": "setVolume", "volume": volume.clamp(0.0, 1.0) }), + ); + } _ => {} }) .map_err(|e| e.to_string())?; + if let Some(volume) = self.volume.lock().ok().and_then(|stored| *stored) { + let _ = controls.set_volume(volume); + } + *controls_guard = Some(controls); Ok(()) } @@ -164,3 +196,19 @@ pub fn update_linux_media_session( } state.update(&app, update) } + +/// Mirrors the app's volume to MPRIS, which is what lets the desktop's media widget show it and +/// change it (on KDE Plasma, the mouse wheel over the media player tray icon). +/// +/// It follows the event the app already emits on every volume change, so the published level +/// always comes from the app itself: a change made from the desktop goes to the app first and +/// comes back through here, and an in-app change reaches the desktop the same way. +pub fn mirror_volume(app: &AppHandle) { + let handle = app.clone(); + app.listen(VOLUME_SYNC_EVENT, move |event| { + if let Ok(sync) = serde_json::from_str::(event.payload()) { + let volume = if sync.muted { 0.0 } else { sync.volume }; + handle.state::().set_volume(volume); + } + }); +} diff --git a/src/player/useMediaSession.ts b/src/player/useMediaSession.ts index eef8824c..7fb5fea3 100644 --- a/src/player/useMediaSession.ts +++ b/src/player/useMediaSession.ts @@ -13,7 +13,8 @@ type NativeMediaAction = | "playPause" | "next" | "previous" - | { action: "seekTo"; positionSec: number }; + | { action: "seekTo"; positionSec: number } + | { action: "setVolume"; volume: number }; const usesNativeWindowsMediaSession = isTauri() && /Windows/i.test(navigator.userAgent); @@ -127,6 +128,7 @@ export function useMediaSession( ({ payload }) => { if (typeof payload === "object") { if (payload.action === "seekTo") void controller.seekTo(payload.positionSec); + if (payload.action === "setVolume") void controller.setVolume(payload.volume); return; }