Skip to content

Commit 6a69ceb

Browse files
authored
Merge pull request #654 from multiplex55/codex/extract-helper-functions-and-build-volume-widget
Add volume dashboard widget and extract shared volume helpers
2 parents 0aac9ff + 6bb9f05 commit 6a69ceb

5 files changed

Lines changed: 342 additions & 130 deletions

File tree

src/dashboard/widgets/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ mod system_status;
3030
mod tempfiles;
3131
mod todo;
3232
mod todo_focus;
33+
mod volume;
3334
mod weather_site;
3435
mod window_list;
3536
mod windows_overview;
@@ -57,6 +58,7 @@ pub use system_status::SystemStatusWidget;
5758
pub use tempfiles::TempfilesWidget;
5859
pub use todo::TodoWidget;
5960
pub use todo_focus::TodoFocusWidget;
61+
pub use volume::VolumeWidget;
6062
pub use weather_site::WeatherSiteWidget;
6163
pub use window_list::WindowsWidget;
6264
pub use windows_overview::WindowsOverviewWidget;
@@ -315,6 +317,10 @@ impl WidgetRegistry {
315317
"tempfiles",
316318
WidgetFactory::new(TempfilesWidget::new).with_settings_ui(TempfilesWidget::settings_ui),
317319
);
320+
reg.register(
321+
"volume",
322+
WidgetFactory::new(VolumeWidget::new).with_settings_ui(VolumeWidget::settings_ui),
323+
);
318324
reg
319325
}
320326

src/dashboard/widgets/volume.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
use super::{
2+
edit_typed_settings, refresh_interval_setting, TimedCache, Widget, WidgetAction,
3+
WidgetSettingsContext, WidgetSettingsUiResult,
4+
};
5+
use crate::actions::Action;
6+
use crate::dashboard::dashboard::{DashboardContext, WidgetActivation};
7+
use crate::gui::volume_data::{get_process_volumes, get_system_volume, ProcessVolume};
8+
use eframe::egui;
9+
use serde::{Deserialize, Serialize};
10+
use std::time::Duration;
11+
12+
fn default_refresh_interval() -> f32 {
13+
5.0
14+
}
15+
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
pub struct VolumeConfig {
18+
#[serde(default = "default_refresh_interval")]
19+
pub refresh_interval_secs: f32,
20+
#[serde(default)]
21+
pub manual_refresh_only: bool,
22+
}
23+
24+
impl Default for VolumeConfig {
25+
fn default() -> Self {
26+
Self {
27+
refresh_interval_secs: default_refresh_interval(),
28+
manual_refresh_only: false,
29+
}
30+
}
31+
}
32+
33+
#[derive(Clone, Default)]
34+
struct VolumeSnapshot {
35+
system_volume: u8,
36+
processes: Vec<ProcessVolume>,
37+
}
38+
39+
pub struct VolumeWidget {
40+
cfg: VolumeConfig,
41+
cache: TimedCache<VolumeSnapshot>,
42+
refresh_pending: bool,
43+
}
44+
45+
impl VolumeWidget {
46+
pub fn new(cfg: VolumeConfig) -> Self {
47+
let interval = Duration::from_secs_f32(cfg.refresh_interval_secs.max(1.0));
48+
Self {
49+
cfg,
50+
cache: TimedCache::new(VolumeSnapshot::default(), interval),
51+
refresh_pending: true,
52+
}
53+
}
54+
55+
pub fn settings_ui(
56+
ui: &mut egui::Ui,
57+
value: &mut serde_json::Value,
58+
ctx: &WidgetSettingsContext<'_>,
59+
) -> WidgetSettingsUiResult {
60+
edit_typed_settings(ui, value, ctx, |ui, cfg: &mut VolumeConfig, _ctx| {
61+
refresh_interval_setting(
62+
ui,
63+
&mut cfg.refresh_interval_secs,
64+
&mut cfg.manual_refresh_only,
65+
"Volume data is cached. The widget will skip refreshing until this many seconds have passed. Use Refresh to update immediately.",
66+
)
67+
})
68+
}
69+
70+
fn refresh_interval(&self) -> Duration {
71+
Duration::from_secs_f32(self.cfg.refresh_interval_secs.max(1.0))
72+
}
73+
74+
fn update_interval(&mut self) {
75+
self.cache.set_interval(self.refresh_interval());
76+
}
77+
78+
fn refresh(&mut self) {
79+
self.update_interval();
80+
let system_volume = get_system_volume().unwrap_or(50);
81+
let mut processes = get_process_volumes();
82+
processes.sort_by(|a, b| a.name.cmp(&b.name).then(a.pid.cmp(&b.pid)));
83+
self.cache.refresh(|data| {
84+
data.system_volume = system_volume;
85+
data.processes = processes;
86+
});
87+
}
88+
89+
fn maybe_refresh(&mut self) {
90+
self.update_interval();
91+
if self.refresh_pending {
92+
self.refresh_pending = false;
93+
self.refresh();
94+
} else if !self.cfg.manual_refresh_only && self.cache.should_refresh() {
95+
self.refresh();
96+
}
97+
}
98+
99+
fn action(label: String, action: String) -> WidgetAction {
100+
WidgetAction {
101+
action: Action {
102+
label,
103+
desc: "Volume".into(),
104+
action,
105+
args: None,
106+
},
107+
query_override: None,
108+
}
109+
}
110+
}
111+
112+
impl Default for VolumeWidget {
113+
fn default() -> Self {
114+
Self::new(VolumeConfig::default())
115+
}
116+
}
117+
118+
impl Widget for VolumeWidget {
119+
fn render(
120+
&mut self,
121+
ui: &mut egui::Ui,
122+
_ctx: &DashboardContext<'_>,
123+
_activation: WidgetActivation,
124+
) -> Option<WidgetAction> {
125+
self.maybe_refresh();
126+
127+
let mut clicked = None;
128+
129+
ui.vertical(|ui| {
130+
ui.label("System volume");
131+
ui.horizontal(|ui| {
132+
let resp = ui.add(
133+
egui::Slider::new(&mut self.cache.data.system_volume, 0..=100)
134+
.text("Level"),
135+
);
136+
if resp.changed() {
137+
let label = format!("Set system volume to {}%", self.cache.data.system_volume);
138+
let action = format!("volume:set:{}", self.cache.data.system_volume);
139+
clicked.get_or_insert_with(|| Self::action(label, action));
140+
}
141+
ui.label(format!("{}%", self.cache.data.system_volume));
142+
if ui.button("Mute active").clicked() {
143+
clicked.get_or_insert_with(|| {
144+
Self::action(
145+
"Toggle mute for active window".into(),
146+
"volume:mute_active".into(),
147+
)
148+
});
149+
}
150+
});
151+
});
152+
153+
ui.separator();
154+
if self.cache.data.processes.is_empty() {
155+
ui.label("No audio sessions found.");
156+
return clicked;
157+
}
158+
159+
for proc in &mut self.cache.data.processes {
160+
ui.horizontal(|ui| {
161+
ui.label(format!("{} (PID {})", proc.name, proc.pid));
162+
let resp = ui.add(egui::Slider::new(&mut proc.value, 0..=100).text("Level"));
163+
if resp.changed() {
164+
let label = format!("Set PID {} volume to {}%", proc.pid, proc.value);
165+
let action = format!("volume:pid:{}:{}", proc.pid, proc.value);
166+
clicked.get_or_insert_with(|| Self::action(label, action));
167+
}
168+
ui.label(format!("{}%", proc.value));
169+
let mute_label = if proc.muted { "Unmute" } else { "Mute" };
170+
if ui.button(mute_label).clicked() {
171+
let label = format!("Toggle mute for PID {}", proc.pid);
172+
let action = format!("volume:pid_toggle_mute:{}", proc.pid);
173+
clicked.get_or_insert_with(|| Self::action(label, action));
174+
proc.muted = !proc.muted;
175+
}
176+
if proc.muted {
177+
ui.colored_label(egui::Color32::RED, "muted");
178+
}
179+
});
180+
}
181+
182+
clicked
183+
}
184+
185+
fn on_config_updated(&mut self, settings: &serde_json::Value) {
186+
if let Ok(cfg) = serde_json::from_value::<VolumeConfig>(settings.clone()) {
187+
self.cfg = cfg;
188+
self.update_interval();
189+
self.cache.invalidate();
190+
self.refresh_pending = true;
191+
}
192+
}
193+
194+
fn header_ui(&mut self, ui: &mut egui::Ui, _ctx: &DashboardContext<'_>) -> Option<WidgetAction> {
195+
let tooltip = format!(
196+
"Cached for {:.0}s. Refresh to update volume data immediately.",
197+
self.cfg.refresh_interval_secs
198+
);
199+
if ui.small_button("Refresh").on_hover_text(tooltip).clicked() {
200+
self.refresh();
201+
}
202+
None
203+
}
204+
}

src/gui/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod toast_log_dialog;
2222
mod todo_dialog;
2323
mod todo_view_dialog;
2424
mod unused_assets_dialog;
25+
pub(crate) mod volume_data;
2526
mod volume_dialog;
2627

2728
pub use add_action_dialog::AddActionDialog;

src/gui/volume_data.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use sysinfo::System;
2+
3+
#[derive(Clone)]
4+
pub struct ProcessVolume {
5+
pub pid: u32,
6+
pub name: String,
7+
pub value: u8,
8+
pub muted: bool,
9+
}
10+
11+
impl ProcessVolume {
12+
/// Returns an action string to toggle mute if the process is currently muted.
13+
/// The caller is responsible for dispatching the action if returned.
14+
pub fn slider_changed(&mut self) -> Option<String> {
15+
if self.muted {
16+
self.muted = false;
17+
Some(format!("volume:pid_toggle_mute:{}", self.pid))
18+
} else {
19+
None
20+
}
21+
}
22+
}
23+
24+
pub fn get_system_volume() -> Option<u8> {
25+
use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume;
26+
use windows::Win32::Media::Audio::{
27+
eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator,
28+
};
29+
use windows::Win32::System::Com::{
30+
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED,
31+
};
32+
33+
unsafe {
34+
let mut percent = None;
35+
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
36+
if let Ok(enm) =
37+
CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL)
38+
{
39+
if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) {
40+
if let Ok(vol) = device.Activate::<IAudioEndpointVolume>(CLSCTX_ALL, None) {
41+
if let Ok(val) = vol.GetMasterVolumeLevelScalar() {
42+
percent = Some((val * 100.0).round() as u8);
43+
}
44+
}
45+
}
46+
}
47+
CoUninitialize();
48+
percent
49+
}
50+
}
51+
52+
pub fn get_process_volumes() -> Vec<ProcessVolume> {
53+
use windows::core::Interface;
54+
use windows::Win32::Media::Audio::{
55+
eMultimedia, eRender, IAudioSessionControl2, IAudioSessionManager2, IMMDeviceEnumerator,
56+
ISimpleAudioVolume, MMDeviceEnumerator,
57+
};
58+
use windows::Win32::System::Com::{
59+
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED,
60+
};
61+
62+
let mut entries = Vec::new();
63+
unsafe {
64+
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
65+
if let Ok(enm) =
66+
CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL)
67+
{
68+
if let Ok(device) = enm.GetDefaultAudioEndpoint(eRender, eMultimedia) {
69+
if let Ok(manager) = device.Activate::<IAudioSessionManager2>(CLSCTX_ALL, None) {
70+
if let Ok(list) = manager.GetSessionEnumerator() {
71+
let count = list.GetCount().unwrap_or(0);
72+
let sys = System::new_all();
73+
for i in 0..count {
74+
if let Ok(ctrl) = list.GetSession(i) {
75+
if let Ok(c2) = ctrl.cast::<IAudioSessionControl2>() {
76+
if let Ok(pid) = c2.GetProcessId() {
77+
if pid == 0 {
78+
continue;
79+
}
80+
if let Ok(vol) = ctrl.cast::<ISimpleAudioVolume>() {
81+
if let Ok(val) = vol.GetMasterVolume() {
82+
let name = sys
83+
.process(sysinfo::Pid::from_u32(pid))
84+
.map(|p| p.name().to_string_lossy().to_string())
85+
.unwrap_or_else(|| format!("PID {pid}"));
86+
let muted = vol
87+
.GetMute()
88+
.map(|m| m.as_bool())
89+
.unwrap_or(false);
90+
entries.push(ProcessVolume {
91+
pid,
92+
name,
93+
value: (val * 100.0).round() as u8,
94+
muted,
95+
});
96+
}
97+
}
98+
}
99+
}
100+
}
101+
}
102+
}
103+
}
104+
}
105+
}
106+
CoUninitialize();
107+
}
108+
entries
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::*;
114+
115+
#[test]
116+
fn slider_change_unmutes() {
117+
let mut proc = ProcessVolume {
118+
pid: 1,
119+
name: "test".into(),
120+
value: 50,
121+
muted: true,
122+
};
123+
let action = proc.slider_changed();
124+
assert_eq!(
125+
action,
126+
Some("volume:pid_toggle_mute:1".to_string())
127+
);
128+
assert!(!proc.muted);
129+
}
130+
}

0 commit comments

Comments
 (0)