Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/dashboard/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
}

Expand Down
204 changes: 204 additions & 0 deletions src/dashboard/widgets/volume.rs
Original file line number Diff line number Diff line change
@@ -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<ProcessVolume>,
}

pub struct VolumeWidget {
cfg: VolumeConfig,
cache: TimedCache<VolumeSnapshot>,
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<WidgetAction> {
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::<VolumeConfig>(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<WidgetAction> {
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
}
}
1 change: 1 addition & 0 deletions src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions src/gui/volume_data.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
if self.muted {
self.muted = false;
Some(format!("volume:pid_toggle_mute:{}", self.pid))
} else {
None
}
}
}

pub fn get_system_volume() -> Option<u8> {
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::<IAudioEndpointVolume>(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<ProcessVolume> {
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::<IAudioSessionManager2>(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::<IAudioSessionControl2>() {
if let Ok(pid) = c2.GetProcessId() {
if pid == 0 {
continue;
}
if let Ok(vol) = ctrl.cast::<ISimpleAudioVolume>() {
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);
}
}
Loading