From a37262d9ca14504888de28811a7ef6d5179ff3bd Mon Sep 17 00:00:00 2001 From: zebus3d Date: Fri, 9 Oct 2026 12:25:12 -0300 Subject: [PATCH] fix: let desktop media widgets control the volume on Linux Zuno already publishes playback over MPRIS, but not its volume, so desktop media widgets could neither show nor change it. On KDE Plasma, for example, the mouse wheel over the media player tray icon did nothing for Zuno. The Linux session now handles MPRIS volume both ways. A change made from the desktop is sent to the app, and the app's volume is mirrored back from the `player-volume-sync` event it already emits on every change (the one the mini player follows). The published level therefore always comes from the app itself, and an in-app change reaches the desktop right away rather than on the next once-a-second position update. --- src-tauri/src/lib.rs | 2 ++ src-tauri/src/linux_media.rs | 50 ++++++++++++++++++++++++++++++++++- src/player/useMediaSession.ts | 4 ++- 3 files changed, 54 insertions(+), 2 deletions(-) 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; }