diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index 44afa46e..823bae83 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -30,6 +30,7 @@ mod system_status; mod tempfiles; mod todo; mod todo_focus; +mod volume; mod weather_site; mod window_list; mod windows_overview; @@ -57,6 +58,7 @@ pub use system_status::SystemStatusWidget; pub use tempfiles::TempfilesWidget; pub use todo::TodoWidget; pub use todo_focus::TodoFocusWidget; +pub use volume::VolumeWidget; pub use weather_site::WeatherSiteWidget; pub use window_list::WindowsWidget; pub use windows_overview::WindowsOverviewWidget; @@ -315,6 +317,10 @@ impl WidgetRegistry { "tempfiles", WidgetFactory::new(TempfilesWidget::new).with_settings_ui(TempfilesWidget::settings_ui), ); + reg.register( + "volume", + WidgetFactory::new(VolumeWidget::new).with_settings_ui(VolumeWidget::settings_ui), + ); reg } diff --git a/src/dashboard/widgets/volume.rs b/src/dashboard/widgets/volume.rs new file mode 100644 index 00000000..ba9db768 --- /dev/null +++ b/src/dashboard/widgets/volume.rs @@ -0,0 +1,204 @@ +use super::{ + edit_typed_settings, refresh_interval_setting, TimedCache, Widget, WidgetAction, + WidgetSettingsContext, WidgetSettingsUiResult, +}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use crate::gui::volume_data::{get_process_volumes, get_system_volume, ProcessVolume}; +use eframe::egui; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +fn default_refresh_interval() -> f32 { + 5.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeConfig { + #[serde(default = "default_refresh_interval")] + pub refresh_interval_secs: f32, + #[serde(default)] + pub manual_refresh_only: bool, +} + +impl Default for VolumeConfig { + fn default() -> Self { + Self { + refresh_interval_secs: default_refresh_interval(), + manual_refresh_only: false, + } + } +} + +#[derive(Clone, Default)] +struct VolumeSnapshot { + system_volume: u8, + processes: Vec, +} + +pub struct VolumeWidget { + cfg: VolumeConfig, + cache: TimedCache, + refresh_pending: bool, +} + +impl VolumeWidget { + pub fn new(cfg: VolumeConfig) -> Self { + let interval = Duration::from_secs_f32(cfg.refresh_interval_secs.max(1.0)); + Self { + cfg, + cache: TimedCache::new(VolumeSnapshot::default(), interval), + refresh_pending: true, + } + } + + pub fn settings_ui( + ui: &mut egui::Ui, + value: &mut serde_json::Value, + ctx: &WidgetSettingsContext<'_>, + ) -> WidgetSettingsUiResult { + edit_typed_settings(ui, value, ctx, |ui, cfg: &mut VolumeConfig, _ctx| { + refresh_interval_setting( + ui, + &mut cfg.refresh_interval_secs, + &mut cfg.manual_refresh_only, + "Volume data is cached. The widget will skip refreshing until this many seconds have passed. Use Refresh to update immediately.", + ) + }) + } + + fn refresh_interval(&self) -> Duration { + Duration::from_secs_f32(self.cfg.refresh_interval_secs.max(1.0)) + } + + fn update_interval(&mut self) { + self.cache.set_interval(self.refresh_interval()); + } + + fn refresh(&mut self) { + self.update_interval(); + let system_volume = get_system_volume().unwrap_or(50); + let mut processes = get_process_volumes(); + processes.sort_by(|a, b| a.name.cmp(&b.name).then(a.pid.cmp(&b.pid))); + self.cache.refresh(|data| { + data.system_volume = system_volume; + data.processes = processes; + }); + } + + fn maybe_refresh(&mut self) { + self.update_interval(); + if self.refresh_pending { + self.refresh_pending = false; + self.refresh(); + } else if !self.cfg.manual_refresh_only && self.cache.should_refresh() { + self.refresh(); + } + } + + fn action(label: String, action: String) -> WidgetAction { + WidgetAction { + action: Action { + label, + desc: "Volume".into(), + action, + args: None, + }, + query_override: None, + } + } +} + +impl Default for VolumeWidget { + fn default() -> Self { + Self::new(VolumeConfig::default()) + } +} + +impl Widget for VolumeWidget { + fn render( + &mut self, + ui: &mut egui::Ui, + _ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + self.maybe_refresh(); + + let mut clicked = None; + + ui.vertical(|ui| { + ui.label("System volume"); + ui.horizontal(|ui| { + let resp = ui.add( + egui::Slider::new(&mut self.cache.data.system_volume, 0..=100) + .text("Level"), + ); + if resp.changed() { + let label = format!("Set system volume to {}%", self.cache.data.system_volume); + let action = format!("volume:set:{}", self.cache.data.system_volume); + clicked.get_or_insert_with(|| Self::action(label, action)); + } + ui.label(format!("{}%", self.cache.data.system_volume)); + if ui.button("Mute active").clicked() { + clicked.get_or_insert_with(|| { + Self::action( + "Toggle mute for active window".into(), + "volume:mute_active".into(), + ) + }); + } + }); + }); + + ui.separator(); + if self.cache.data.processes.is_empty() { + ui.label("No audio sessions found."); + return clicked; + } + + for proc in &mut self.cache.data.processes { + ui.horizontal(|ui| { + ui.label(format!("{} (PID {})", proc.name, proc.pid)); + let resp = ui.add(egui::Slider::new(&mut proc.value, 0..=100).text("Level")); + if resp.changed() { + let label = format!("Set PID {} volume to {}%", proc.pid, proc.value); + let action = format!("volume:pid:{}:{}", proc.pid, proc.value); + clicked.get_or_insert_with(|| Self::action(label, action)); + } + ui.label(format!("{}%", proc.value)); + let mute_label = if proc.muted { "Unmute" } else { "Mute" }; + if ui.button(mute_label).clicked() { + let label = format!("Toggle mute for PID {}", proc.pid); + let action = format!("volume:pid_toggle_mute:{}", proc.pid); + clicked.get_or_insert_with(|| Self::action(label, action)); + proc.muted = !proc.muted; + } + if proc.muted { + ui.colored_label(egui::Color32::RED, "muted"); + } + }); + } + + clicked + } + + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + self.update_interval(); + self.cache.invalidate(); + self.refresh_pending = true; + } + } + + fn header_ui(&mut self, ui: &mut egui::Ui, _ctx: &DashboardContext<'_>) -> Option { + let tooltip = format!( + "Cached for {:.0}s. Refresh to update volume data immediately.", + self.cfg.refresh_interval_secs + ); + if ui.small_button("Refresh").on_hover_text(tooltip).clicked() { + self.refresh(); + } + None + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 1d9abe3b..f282047d 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -22,6 +22,7 @@ mod toast_log_dialog; mod todo_dialog; mod todo_view_dialog; mod unused_assets_dialog; +pub(crate) mod volume_data; mod volume_dialog; pub use add_action_dialog::AddActionDialog; diff --git a/src/gui/volume_data.rs b/src/gui/volume_data.rs new file mode 100644 index 00000000..f85b6a67 --- /dev/null +++ b/src/gui/volume_data.rs @@ -0,0 +1,130 @@ +use sysinfo::System; + +#[derive(Clone)] +pub struct ProcessVolume { + pub pid: u32, + pub name: String, + pub value: u8, + pub muted: bool, +} + +impl ProcessVolume { + /// Returns an action string to toggle mute if the process is currently muted. + /// The caller is responsible for dispatching the action if returned. + pub fn slider_changed(&mut self) -> Option { + if self.muted { + self.muted = false; + Some(format!("volume:pid_toggle_mute:{}", self.pid)) + } else { + None + } + } +} + +pub fn get_system_volume() -> Option { + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + }; + + unsafe { + let mut percent = None; + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if let Ok(enm) = + CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) + { + if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { + if let Ok(vol) = device.Activate::(CLSCTX_ALL, None) { + if let Ok(val) = vol.GetMasterVolumeLevelScalar() { + percent = Some((val * 100.0).round() as u8); + } + } + } + } + CoUninitialize(); + percent + } +} + +pub fn get_process_volumes() -> Vec { + use windows::core::Interface; + use windows::Win32::Media::Audio::{ + eMultimedia, eRender, IAudioSessionControl2, IAudioSessionManager2, IMMDeviceEnumerator, + ISimpleAudioVolume, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, + }; + + let mut entries = Vec::new(); + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + if let Ok(enm) = + CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) + { + if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { + if let Ok(manager) = device.Activate::(CLSCTX_ALL, None) { + if let Ok(list) = manager.GetSessionEnumerator() { + let count = list.GetCount().unwrap_or(0); + let sys = System::new_all(); + for i in 0..count { + if let Ok(ctrl) = list.GetSession(i) { + if let Ok(c2) = ctrl.cast::() { + if let Ok(pid) = c2.GetProcessId() { + if pid == 0 { + continue; + } + if let Ok(vol) = ctrl.cast::() { + if let Ok(val) = vol.GetMasterVolume() { + let name = sys + .process(sysinfo::Pid::from_u32(pid)) + .map(|p| p.name().to_string_lossy().to_string()) + .unwrap_or_else(|| format!("PID {pid}")); + let muted = vol + .GetMute() + .map(|m| m.as_bool()) + .unwrap_or(false); + entries.push(ProcessVolume { + pid, + name, + value: (val * 100.0).round() as u8, + muted, + }); + } + } + } + } + } + } + } + } + } + } + CoUninitialize(); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slider_change_unmutes() { + let mut proc = ProcessVolume { + pid: 1, + name: "test".into(), + value: 50, + muted: true, + }; + let action = proc.slider_changed(); + assert_eq!( + action, + Some("volume:pid_toggle_mute:1".to_string()) + ); + assert!(!proc.muted); + } +} diff --git a/src/gui/volume_dialog.rs b/src/gui/volume_dialog.rs index 9e449c5f..5a8379d2 100644 --- a/src/gui/volume_dialog.rs +++ b/src/gui/volume_dialog.rs @@ -1,8 +1,8 @@ use crate::actions::Action; +use crate::gui::volume_data::{get_process_volumes, get_system_volume, ProcessVolume}; use crate::gui::LauncherApp; use crate::launcher::launch_action; use eframe::egui; -use sysinfo::System; pub struct VolumeDialog { pub open: bool, @@ -16,27 +16,6 @@ impl Default for VolumeDialog { } } -#[derive(Clone)] -struct ProcessVolume { - pid: u32, - name: String, - value: u8, - muted: bool, -} - -impl ProcessVolume { - /// Returns an action string to toggle mute if the process is currently muted. - /// The caller is responsible for dispatching the action if returned. - fn slider_changed(&mut self) -> Option { - if self.muted { - self.muted = false; - Some(format!("volume:pid_toggle_mute:{}", self.pid)) - } else { - None - } - } -} - impl VolumeDialog { pub fn open(&mut self) { self.open = true; @@ -108,111 +87,3 @@ impl VolumeDialog { if close { self.open = false; } } } - -fn get_system_volume() -> Option { - use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; - use windows::Win32::Media::Audio::{ - eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, - }; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, - }; - - unsafe { - let mut percent = None; - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - if let Ok(enm) = - CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) - { - if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { - if let Ok(vol) = device.Activate::(CLSCTX_ALL, None) { - if let Ok(val) = vol.GetMasterVolumeLevelScalar() { - percent = Some((val * 100.0).round() as u8); - } - } - } - } - CoUninitialize(); - percent - } -} - -fn get_process_volumes() -> Vec { - use windows::core::Interface; - use windows::Win32::Media::Audio::{ - eMultimedia, eRender, IAudioSessionControl2, IAudioSessionManager2, IMMDeviceEnumerator, - ISimpleAudioVolume, MMDeviceEnumerator, - }; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, - }; - - let mut entries = Vec::new(); - unsafe { - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - if let Ok(enm) = - CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL) - { - if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) { - if let Ok(manager) = device.Activate::(CLSCTX_ALL, None) { - if let Ok(list) = manager.GetSessionEnumerator() { - let count = list.GetCount().unwrap_or(0); - let sys = System::new_all(); - for i in 0..count { - if let Ok(ctrl) = list.GetSession(i) { - if let Ok(c2) = ctrl.cast::() { - if let Ok(pid) = c2.GetProcessId() { - if pid == 0 { - continue; - } - if let Ok(vol) = ctrl.cast::() { - if let Ok(val) = vol.GetMasterVolume() { - let name = sys - .process(sysinfo::Pid::from_u32(pid)) - .map(|p| p.name().to_string_lossy().to_string()) - .unwrap_or_else(|| format!("PID {pid}")); - let muted = vol - .GetMute() - .map(|m| m.as_bool()) - .unwrap_or(false); - entries.push(ProcessVolume { - pid, - name, - value: (val * 100.0).round() as u8, - muted, - }); - } - } - } - } - } - } - } - } - } - } - CoUninitialize(); - } - entries -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slider_change_unmutes() { - let mut proc = ProcessVolume { - pid: 1, - name: "test".into(), - value: 50, - muted: true, - }; - let action = proc.slider_changed(); - assert_eq!( - action, - Some("volume:pid_toggle_mute:1".to_string()) - ); - assert!(!proc.muted); - } -}