diff --git a/dash-frontend/assets/gui/view/audio_settings.xml b/dash-frontend/assets/gui/view/audio_settings.xml index f2e4c8d..e2e68eb 100644 --- a/dash-frontend/assets/gui/view/audio_settings.xml +++ b/dash-frontend/assets/gui/view/audio_settings.xml @@ -1,26 +1,26 @@ - - + + - + - - - + + + - + - + - + @@ -28,9 +28,9 @@ - - - + + + @@ -39,9 +39,9 @@ min_width="24" width="24" height="24" margin="4" /> - - - + + + \ No newline at end of file diff --git a/dash-frontend/src/frontend.rs b/dash-frontend/src/frontend.rs index f4894c5..0f6fc95 100644 --- a/dash-frontend/src/frontend.rs +++ b/dash-frontend/src/frontend.rs @@ -1,4 +1,4 @@ -use std::{cell::RefCell, collections::VecDeque, rc::Rc}; +use std::{cell::RefCell, rc::Rc}; use chrono::Timelike; use glam::Vec2; @@ -8,7 +8,7 @@ use wgui::{ font_config::WguiFontConfig, globals::WguiGlobals, i18n::Translation, - layout::{Layout, LayoutParams, RcLayout, WidgetID}, + layout::{LayoutParams, RcLayout, WidgetID}, parser::{Fetchable, ParseDocumentParams, ParserState}, widget::{label::WidgetLabel, rectangle::WidgetRectangle}, windowing::{WguiWindow, WguiWindowParams, WguiWindowParamsExtra, WguiWindowPlacement}, @@ -20,6 +20,7 @@ use crate::{ Tab, TabParams, TabType, apps::TabApps, games::TabGames, home::TabHome, monado::TabMonado, processes::TabProcesses, settings::TabSettings, }, + task::Tasks, util::popup_manager::{MountPopupParams, PopupManager, PopupManagerParams}, views, }; @@ -29,18 +30,7 @@ pub struct FrontendWidgets { pub id_rect_content: WidgetID, } -#[derive(Clone)] -pub struct FrontendTasks(pub Rc>>); - -impl FrontendTasks { - fn new() -> Self { - Self(Rc::new(RefCell::new(VecDeque::new()))) - } - - pub fn push(&self, task: FrontendTask) { - self.0.borrow_mut().push_back(task); - } -} +pub type FrontendTasks = Tasks; pub struct Frontend { pub layout: RcLayout, @@ -61,6 +51,7 @@ pub struct Frontend { popup_manager: PopupManager, window_audio_settings: WguiWindow, + view_audio_settings: Option, } pub struct InitParams { @@ -77,6 +68,7 @@ pub enum FrontendTask { MountPopup(MountPopupParams), RefreshPopupManager, ShowAudioSettings, + UpdateAudioSettingsView, RecenterPlayspace, } @@ -137,6 +129,7 @@ impl Frontend { settings: params.settings, popup_manager, window_audio_settings: WguiWindow::default(), + view_audio_settings: None, }; // init some things first @@ -151,10 +144,7 @@ impl Frontend { } pub fn update(&mut self, rc_this: &RcFrontend, width: f32, height: f32, timestep_alpha: f32) -> anyhow::Result<()> { - let mut tasks = { - let mut tasks = self.tasks.0.borrow_mut(); - std::mem::take(&mut *tasks) - }; + let mut tasks = self.tasks.drain(); while let Some(task) = tasks.pop_front() { self.process_task(rc_this, task)?; @@ -260,6 +250,7 @@ impl Frontend { FrontendTask::MountPopup(params) => self.mount_popup(params)?, FrontendTask::RefreshPopupManager => self.refresh_popup_manager()?, FrontendTask::ShowAudioSettings => self.action_show_audio_settings()?, + FrontendTask::UpdateAudioSettingsView => self.action_update_audio_settings()?, FrontendTask::RecenterPlayspace => self.action_recenter_playspace()?, } Ok(()) @@ -390,11 +381,28 @@ impl Frontend { let content = self.window_audio_settings.get_content(); - views::audio_settings::View::new(views::audio_settings::Params { + self.view_audio_settings = Some(views::audio_settings::View::new(views::audio_settings::Params { globals: self.globals.clone(), layout: &mut layout, parent_id: content.id, - })?; + on_update: { + let tasks = self.tasks.clone(); + Rc::new(move || { + tasks.push(FrontendTask::UpdateAudioSettingsView); + }) + }, + })?); + Ok(()) + } + + fn action_update_audio_settings(&mut self) -> anyhow::Result<()> { + let Some(view) = &mut self.view_audio_settings else { + return Ok(()); + }; + + let mut layout = self.layout.borrow_mut(); + view.update(&mut layout)?; + Ok(()) } diff --git a/dash-frontend/src/lib.rs b/dash-frontend/src/lib.rs index 5266836..e930036 100644 --- a/dash-frontend/src/lib.rs +++ b/dash-frontend/src/lib.rs @@ -2,6 +2,7 @@ mod assets; pub mod frontend; pub mod settings; mod tab; +mod task; mod util; mod various; mod views; diff --git a/dash-frontend/src/task.rs b/dash-frontend/src/task.rs new file mode 100644 index 0000000..41eab72 --- /dev/null +++ b/dash-frontend/src/task.rs @@ -0,0 +1,21 @@ +use std::{cell::RefCell, collections::VecDeque, rc::Rc}; + +#[derive(Clone)] +pub struct Tasks(Rc>>) +where + TaskType: Clone; + +impl Tasks { + pub fn new() -> Self { + Self(Rc::new(RefCell::new(VecDeque::new()))) + } + + pub fn push(&self, task: TaskType) { + self.0.borrow_mut().push_back(task); + } + + pub fn drain(&mut self) -> VecDeque { + let mut tasks = self.0.borrow_mut(); + std::mem::take(&mut *tasks) + } +} diff --git a/dash-frontend/src/util/mod.rs b/dash-frontend/src/util/mod.rs index 7e87a39..0fec6be 100644 --- a/dash-frontend/src/util/mod.rs +++ b/dash-frontend/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod desktop_finder; +pub mod pactl_wrapper; pub mod popup_manager; diff --git a/dash-frontend/src/util/pactl_wrapper.rs b/dash-frontend/src/util/pactl_wrapper.rs new file mode 100644 index 0000000..b5918a4 --- /dev/null +++ b/dash-frontend/src/util/pactl_wrapper.rs @@ -0,0 +1,320 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct VolumeChannel { + pub value: u32, // 48231 + pub value_percent: String, // "80%" + pub db: String, // "-5.81 dB" +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Volume { + // WiVRn and other devices + pub aux0: Option, + pub aux1: Option, + + // Analog and HDMI devices + #[serde(rename = "front-left")] + pub front_left: Option, + + #[serde(rename = "front-right")] + pub front_right: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Sink { + pub index: u32, // 123 + pub state: String, // "RUNNING" / "SUSPENDED" + pub name: String, // alsa_output.pci-0000_0c_00.4.analog-stereo + pub description: String, // Starship/Matisse HD Audio Controller Analog Stereo + pub mute: bool, // false + pub volume: Volume, + pub properties: HashMap, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SourceProperties { + #[serde(rename = "device.name")] + pub device_name: Option, // "alsa_card.pci-0000_0b_00.1" + #[serde(rename = "device.class")] + pub device_class: Option, // "monitor", "sound" + #[serde(rename = "alsa.card_name")] + pub card_name: Option, // "Valve VR Radio & HMD Mic" + #[serde(rename = "alsa.components")] + pub components: Option, // USB28de:2102 + #[serde(rename = "device.vendor_name")] + pub vendor_name: Option, // Valve Software +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Source { + pub index: u32, // 123 + pub state: String, // "RUNNING" / "SUSPENDED" + pub name: String, // alsa_input.pci-0000_0c_00.4.analog-stereo + pub description: String, // Valve VR Radio & HMD Mic Mono + pub mute: bool, // false + pub volume: Volume, + pub properties: SourceProperties, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct CardProperties { + #[serde(rename = "device.description")] + pub device_description: String, // Starship/Matisse HD Audio Controller + + #[serde(rename = "device.name")] + pub device_name: String, // alsa_card.pci-0000_0c_00.4 + + #[serde(rename = "device.nick")] + pub device_nick: String, // HD-Audio Generic +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct CardProfile { + pub description: String, // "Digital Stereo (HDMI 2) Output", "Analog Stereo Output", + pub sinks: u32, // 1 + pub sources: u32, // 0 + pub priority: u32, // 6500 + pub available: bool, // true +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct CardPort { + pub description: String, // "HDMI / DisplayPort 2" + pub r#type: String, // "HDMI" + pub profiles: Vec, // "output:hdmi-stereo-extra1", "output:hdmi-surround-extra1", "output:analog-stereo", "output:analog-stereo+input:analog-stereo" + + // example: + // "port.type": "hdmi" + // "device.product_name": "Index HMD" + pub properties: HashMap, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Card { + pub index: u32, // 57 + pub name: String, // alsa_card.pci-0000_0c_00.4 + pub active_profile: String, // output:analog-stereo + pub properties: CardProperties, + pub profiles: HashMap, // key: "output:analog-stereo" + pub ports: HashMap, // key: "analog-output-lineout" +} + +// ######################################## +// ~ sinks ~ +// ######################################## + +pub fn list_sinks() -> anyhow::Result> { + let output = std::process::Command::new("pactl") + .arg("--format=json") + .arg("list") + .arg("sinks") + .output()?; + + if !output.status.success() { + anyhow::bail!("pactl exit status {}", output.status); + } + + let json_str = std::str::from_utf8(&output.stdout)?; + let sinks: Vec = serde_json::from_str(json_str)?; + Ok(sinks) +} + +pub fn get_default_sink(sinks: &[Sink]) -> anyhow::Result> { + let output = std::process::Command::new("pactl").arg("get-default-sink").output()?; + + let utf8_name = std::str::from_utf8(&output.stdout)?.trim(); + + for sink in sinks { + if sink.name == utf8_name { + return Ok(Some(sink.clone())); + } + } + + Ok(None) +} + +pub fn set_default_sink(sink_index: u32) -> anyhow::Result<()> { + std::process::Command::new("pactl") + .arg("set-default-sink") + .arg(format!("{}", sink_index)) + .output()?; + + Ok(()) +} + +pub fn get_sink_from_index(sinks: &[Sink], index: u32) -> Option<&Sink> { + sinks.iter().find(|&sink| sink.index == index) +} + +pub fn get_sink_volume(sink: &Sink) -> anyhow::Result { + let volume_channel = { + if let Some(front_left) = &sink.volume.front_left { + front_left + } else if let Some(aux0) = &sink.volume.aux0 { + aux0 + } else { + return Ok(0.0); // fail silently + } + }; + + let Some(pair) = volume_channel.value_percent.split_once("%") else { + anyhow::bail!("volume percentage invalid"); // shouldn't happen + }; + + let percent_num: f32 = pair.0.parse().unwrap_or(0.0); + Ok(percent_num / 100.0) +} + +pub fn set_sink_volume(sink_index: u32, volume: f32) -> anyhow::Result<()> { + let target_vol = (volume * 100.0).clamp(0.0, 150.0); // limit to 150% + + std::process::Command::new("pactl") + .arg("set-sink-volume") + .arg(format!("{}", sink_index)) + .arg(format!("{}%", target_vol)) + .output()?; + + Ok(()) +} + +pub fn set_sink_mute(sink_index: u32, mute: bool) -> anyhow::Result<()> { + std::process::Command::new("pactl") + .arg("set-sink-mute") + .arg(format!("{}", sink_index)) + .arg(format!("{}", mute as i32)) + .output()?; + + Ok(()) +} + +// ######################################## +// ~ sources ~ +// ######################################## + +pub fn list_sources() -> anyhow::Result> { + let output = std::process::Command::new("pactl") + .arg("--format=json") + .arg("list") + .arg("sources") + .output()?; + + if !output.status.success() { + anyhow::bail!("pactl exit status {}", output.status); + } + + let json_str = std::str::from_utf8(&output.stdout)?; + let mut sources: Vec = serde_json::from_str(json_str)?; + + // exclude all monitor sources + sources.retain(|source| match &source.properties.device_class { + Some(c) => c != "monitor", + None => false, + }); + + Ok(sources) +} + +pub fn get_default_source(sources: &[Source]) -> anyhow::Result> { + let output = std::process::Command::new("pactl").arg("get-default-source").output()?; + + let utf8_name = std::str::from_utf8(&output.stdout)?.trim(); + + for source in sources { + if source.name == utf8_name { + return Ok(Some(source.clone())); + } + } + + Ok(None) +} + +pub fn set_default_source(source_index: u32) -> anyhow::Result<()> { + std::process::Command::new("pactl") + .arg("set-default-source") + .arg(format!("{}", source_index)) + .output()?; + + Ok(()) +} + +pub fn get_source_from_index(sources: &[Source], index: u32) -> Option<&Source> { + sources.iter().find(|&source| source.index == index) +} + +pub fn get_source_volume(source: &Source) -> anyhow::Result { + let volume_channel = { + if let Some(front_left) = &source.volume.front_left { + front_left + } else if let Some(aux0) = &source.volume.aux0 { + aux0 + } else { + return Ok(0.0); // fail silently + } + }; + + let Some(pair) = volume_channel.value_percent.split_once("%") else { + anyhow::bail!("volume percentage invalid"); // shouldn't happen + }; + + let percent_num: f32 = pair.0.parse().unwrap_or(0.0); + Ok(percent_num / 100.0) +} + +pub fn set_source_volume(source_index: u32, volume: f32) -> anyhow::Result<()> { + let target_vol = (volume * 100.0).clamp(0.0, 150.0); // limit to 150% + + std::process::Command::new("pactl") + .arg("set-source-volume") + .arg(format!("{}", source_index)) + .arg(format!("{}%", target_vol)) + .output()?; + + Ok(()) +} + +pub fn set_source_mute(source_index: u32, mute: bool) -> anyhow::Result<()> { + std::process::Command::new("pactl") + .arg("set-source-mute") + .arg(format!("{}", source_index)) + .arg(format!("{}", mute as i32)) + .output()?; + + Ok(()) +} + +// ######################################## +// ~ cards ~ +// ######################################## + +pub fn list_cards() -> anyhow::Result> { + let output = std::process::Command::new("pactl") + .arg("--format=json") + .arg("list") + .arg("cards") + .output()?; + + if !output.status.success() { + anyhow::bail!("pactl exit status {}", output.status); + } + + let json_str = std::str::from_utf8(&output.stdout)?; + let mut cards: Vec = serde_json::from_str(json_str)?; + + // exclude card which has "Loopback" in name + cards.retain(|card| card.properties.device_nick != "Loopback"); + + Ok(cards) +} + +pub fn set_card_profile(card_index: u32, profile: &str) -> anyhow::Result<()> { + std::process::Command::new("pactl") + .arg("set-card-profile") + .arg(format!("{}", card_index)) + .arg(profile) + .output()?; + + Ok(()) +} diff --git a/dash-frontend/src/views/audio_settings.rs b/dash-frontend/src/views/audio_settings.rs index a4f5239..9592587 100644 --- a/dash-frontend/src/views/audio_settings.rs +++ b/dash-frontend/src/views/audio_settings.rs @@ -2,35 +2,583 @@ use std::{collections::HashMap, rc::Rc}; use wgui::{ assets::AssetPath, + components::{ + button::{ButtonClickCallback, ComponentButton}, + checkbox::ComponentCheckbox, + slider::ComponentSlider, + }, globals::WguiGlobals, - i18n::Translation, layout::{Layout, WidgetID}, parser::{Fetchable, ParseDocumentParams, ParserState}, - widget::label::WidgetLabel, }; +use crate::{task::Tasks, util::pactl_wrapper}; + +#[derive(Clone)] +enum CurrentMode { + Sinks, + Sources, + Cards, +} + +#[derive(Clone)] +struct IndexAndVolume { + idx: u32, + volume: f32, +} + +#[derive(Clone)] +enum ViewTask { + Remount, + SetMode(CurrentMode), + SetSinkVolume(IndexAndVolume), + SetSourceVolume(IndexAndVolume), +} + +type ViewTasks = Tasks; + pub struct View { + tasks: ViewTasks, + on_update: Rc, + + globals: WguiGlobals, + #[allow(dead_code)] - pub state: ParserState, + state: ParserState, + //entry: DesktopEntry, + mode: CurrentMode, + + id_devices: WidgetID, } pub struct Params<'a> { pub globals: WguiGlobals, pub layout: &'a mut Layout, pub parent_id: WidgetID, + pub on_update: Rc, } -impl View { - pub fn new(params: Params) -> anyhow::Result { - let doc_params = &ParseDocumentParams { - globals: params.globals.clone(), - path: AssetPath::BuiltIn("gui/view/audio_settings.xml"), - extra: Default::default(), +struct ProfileDisplayName { + name: String, + icon_path: &'static str, + is_vr: bool, +} + +fn get_card_from_sink<'a>( + sink: &pactl_wrapper::Sink, + cards: &'a [pactl_wrapper::Card], +) -> Option<&'a pactl_wrapper::Card> { + let Some(sink_dev_name) = &sink.properties.get("device.name") else { + return None; + }; + + cards.iter().find(|&card| **sink_dev_name == card.name).map(|v| v as _) +} + +fn get_card_from_source<'a>( + source: &pactl_wrapper::Source, + cards: &'a [pactl_wrapper::Card], +) -> Option<&'a pactl_wrapper::Card> { + let Some(source_dev_name) = &source.properties.device_name else { + return None; + }; + + cards + .iter() + .find(|&card| **source_dev_name == card.name) + .map(|v| v as _) +} + +fn does_string_mention_hmd_sink(input: &str) -> bool { + let lwr = input.to_lowercase(); + lwr.contains("hmd") || // generic hmd name detected + lwr.contains("index") || // Valve hardware + lwr.contains("oculus") || // Oculus + lwr.contains("rift") || // Also Oculus + lwr.contains("beyond") // Bigscreen Beyond +} + +fn does_string_mention_hmd_source(input: &str) -> bool { + let lwr = input.to_lowercase(); + lwr.contains("hmd") || // generic hmd name detected + lwr.contains("valve") || // Valve hardware + lwr.contains("oculus") || // Oculus + lwr.contains("beyond") // Bigscreen Beyond +} + +fn is_card_mentioning_hmd(card: &pactl_wrapper::Card) -> bool { + does_string_mention_hmd_sink(&card.properties.device_name) +} + +fn is_source_mentioning_hmd(source: &pactl_wrapper::Source) -> bool { + if let Some(source_card_name) = &source.properties.card_name + && does_string_mention_hmd_source(source_card_name) + { + return true; + } + + // WiVRn + if source.name == "wivrn.source" { + return true; + } + + false +} + +fn get_profile_display_name(profile_name: &str, card: &pactl_wrapper::Card) -> ProfileDisplayName { + let Some(profile) = card.profiles.get(profile_name) else { + // fallback + return ProfileDisplayName { + name: profile_name.into(), + icon_path: "dashboard/binary.svg", + is_vr: false, }; + }; - let state = wgui::parser::parse_from_assets(doc_params, params.layout, params.parent_id)?; + let mut out_icon_path: &'static str; + let mut is_vr = false; - Ok(Self { state }) + let prof = profile_name.to_lowercase(); + if prof.contains("analog") { + out_icon_path = "dashboard/minijack.svg"; + } else if prof.contains("iec" /* digital */) { + out_icon_path = "dashboard/binary.svg"; + } else if prof.contains("hdmi") { + out_icon_path = "dashboard/displayport.svg"; + } else if prof.contains("off") { + out_icon_path = "dashboard/sleep.svg"; + } else if prof.contains("input") { + out_icon_path = "dashboard/microphone.svg"; + } else { + out_icon_path = "dashboard/volume.svg"; // Default fallback + } + + // All ports are tied to this VR headset, assign all of them to the VR icon + if is_card_mentioning_hmd(card) { + if prof.contains("mic") { + // Probably microphone + out_icon_path = "dashboard/microphone.svg"; + } else { + out_icon_path = "dashboard/vr.svg"; + } + } + + let mut out_name: Option = None; + + for port in card.ports.values() { + // Find profile + for port_profile in &port.profiles { + if !port_profile.contains("stereo") { + continue; // we only want stereo, not surround or other types + } + + if port_profile != profile_name { + continue; + } + + // Exact match! Use its device name + let Some(product_name) = port.properties.get("device.product.name") else { + continue; + }; + + out_name = Some(product_name.clone()); + + if does_string_mention_hmd_sink(product_name) { + // VR icon + out_icon_path = "dashboard/vr.svg"; + is_vr = true; + } else { + // Monitor icon + out_icon_path = "dashboard/displayport.svg"; + } + + break; + } + } + + ProfileDisplayName { + name: if let Some(name) = out_name { + name + } else { + profile.description.clone() + }, + icon_path: out_icon_path, + is_vr, + } +} + +fn doc_params(globals: &WguiGlobals) -> ParseDocumentParams<'_> { + ParseDocumentParams { + globals: globals.clone(), + path: AssetPath::BuiltIn("gui/view/audio_settings.xml"), + extra: Default::default(), + } +} + +trait DeviceControl { + fn on_volume_request(&self) -> anyhow::Result; + fn on_check(&self) -> anyhow::Result<()>; + fn on_mute_toggle(&self) -> anyhow::Result<()>; + fn on_volume_change(&self, volume: f32) -> anyhow::Result<()>; +} + +struct ControlSink { + tasks: ViewTasks, + on_update: Rc, + sink: pactl_wrapper::Sink, +} + +impl ControlSink { + fn new(tasks: ViewTasks, on_update: Rc, sink: pactl_wrapper::Sink) -> Self { + Self { tasks, sink, on_update } + } +} + +impl DeviceControl for ControlSink { + fn on_volume_request(&self) -> anyhow::Result { + let volume = pactl_wrapper::get_sink_volume(&self.sink)?; + Ok(volume) + } + + fn on_check(&self) -> anyhow::Result<()> { + pactl_wrapper::set_default_sink(self.sink.index)?; + self.tasks.push(ViewTask::Remount); + (*self.on_update)(); + Ok(()) + } + + fn on_mute_toggle(&self) -> anyhow::Result<()> { + pactl_wrapper::set_sink_mute(self.sink.index, !self.sink.mute)?; + self.tasks.push(ViewTask::Remount); + (*self.on_update)(); + Ok(()) + } + + fn on_volume_change(&self, volume: f32) -> anyhow::Result<()> { + self.tasks.push(ViewTask::SetSinkVolume(IndexAndVolume { + idx: self.sink.index, + volume, + })); + (*self.on_update)(); + Ok(()) + } +} + +struct ControlSource { + tasks: ViewTasks, + on_update: Rc, + source: pactl_wrapper::Source, +} + +impl ControlSource { + fn new(tasks: ViewTasks, on_update: Rc, source: pactl_wrapper::Source) -> Self { + Self { + tasks, + source, + on_update, + } + } +} + +impl DeviceControl for ControlSource { + fn on_volume_request(&self) -> anyhow::Result { + let volume = pactl_wrapper::get_source_volume(&self.source)?; + Ok(volume) + } + + fn on_check(&self) -> anyhow::Result<()> { + pactl_wrapper::set_default_source(self.source.index)?; + self.tasks.push(ViewTask::Remount); + (*self.on_update)(); + Ok(()) + } + + fn on_mute_toggle(&self) -> anyhow::Result<()> { + pactl_wrapper::set_source_mute(self.source.index, !self.source.mute)?; + self.tasks.push(ViewTask::Remount); + (*self.on_update)(); + Ok(()) + } + + fn on_volume_change(&self, volume: f32) -> anyhow::Result<()> { + self.tasks.push(ViewTask::SetSourceVolume(IndexAndVolume { + idx: self.source.index, + volume, + })); + (*self.on_update)(); + Ok(()) + } +} + +struct MountCardParams<'a> { + layout: &'a mut Layout, + card: &'a pactl_wrapper::Card, +} + +struct MountDeviceSliderParams<'a> { + layout: &'a mut Layout, + control: Rc, + checked: bool, + muted: bool, + disp: Option, + alt_desc: String, +} + +const ONE_HUNDRED_PERCENT: f32 = 100.0; +const VOLUME_MULT: f32 = 1.0 / ONE_HUNDRED_PERCENT; + +impl View { + fn handle_func_button_click(&self, task: ViewTask) -> ButtonClickCallback { + let tasks = self.tasks.clone(); + let on_update = self.on_update.clone(); + Box::new(move |_common, _evt| { + tasks.push(task.clone()); + (*on_update)(); + Ok(()) + }) + } + + pub fn new(params: Params) -> anyhow::Result { + let tasks = ViewTasks::new(); + + let state = wgui::parser::parse_from_assets(&doc_params(¶ms.globals), params.layout, params.parent_id)?; + + let id_devices = state.get_widget_id("devices")?; + + let btn_sinks = state.fetch_component_as::("btn_sinks")?; + let btn_sources = state.fetch_component_as::("btn_sources")?; + let btn_cards = state.fetch_component_as::("btn_cards")?; + + let mut res = Self { + globals: params.globals, + state, + mode: CurrentMode::Sinks, + id_devices, + tasks, + on_update: params.on_update, + }; + + btn_sinks.on_click(res.handle_func_button_click(ViewTask::SetMode(CurrentMode::Sinks))); + btn_sources.on_click(res.handle_func_button_click(ViewTask::SetMode(CurrentMode::Sources))); + btn_cards.on_click(res.handle_func_button_click(ViewTask::SetMode(CurrentMode::Cards))); + + res.init_mode_sinks(params.layout)?; + + Ok(res) + } + + fn process_tasks(&mut self, layout: &mut Layout) -> anyhow::Result { + let tasks = self.tasks.drain(); + if tasks.is_empty() { + return Ok(false); + } + + let mut set_sink_volume: Option = None; + let mut set_source_volume: Option = None; + + for task in tasks { + match task { + ViewTask::Remount => match self.mode { + CurrentMode::Sinks => self.init_mode_sinks(layout)?, + CurrentMode::Sources => self.init_mode_sources(layout)?, + CurrentMode::Cards => self.init_mode_cards(layout)?, + }, + ViewTask::SetSinkVolume(s) => { + set_sink_volume = Some(s); + } + ViewTask::SetSourceVolume(s) => { + set_source_volume = Some(s); + } + ViewTask::SetMode(current_mode) => { + self.mode = current_mode; + self.tasks.push(ViewTask::Remount); + } + } + } + + // set volume only to the latest event (prevent cpu time starvation + // due to excessive input motion events) + if let Some(s) = set_sink_volume { + pactl_wrapper::set_sink_volume(s.idx, s.volume)?; + } + + if let Some(s) = set_source_volume { + pactl_wrapper::set_source_volume(s.idx, s.volume)?; + } + + Ok(true) + } + + pub fn update(&mut self, layout: &mut Layout) -> anyhow::Result<()> { + while self.process_tasks(layout)? {} + + Ok(()) + } + + fn mount_card(&mut self, params: MountCardParams) -> anyhow::Result<()> { + log::info!("mount card TODO: {}", params.card.name); + Ok(()) + } + + fn mount_device_slider(&mut self, params: MountDeviceSliderParams) -> anyhow::Result<()> { + let mut par = HashMap::, Rc>::new(); + + if let Some(disp) = ¶ms.disp { + par.insert("device_name".into(), disp.name.as_str().into()); + par.insert("device_icon".into(), disp.icon_path.into()); + } else { + par.insert("device_name".into(), params.alt_desc.into()); + par.insert("device_icon".into(), "dashboard/binary.svg".into()); + } + + par.insert( + "volume_icon".into(), + if params.muted { + "dashboard/volume_off.svg".into() + } else { + "dashboard/volume.svg".into() + }, + ); + + let data = self.state.parse_template( + &doc_params(&self.globals), + "DeviceSlider", + params.layout, + self.id_devices, + par, + )?; + + let mut c = params.layout.start_common(); + let mut common = c.common(); + + let checkbox = data.fetch_component_as::("checkbox")?; + let btn_mute = data.fetch_component_as::("btn_mute")?; + let slider = data.fetch_component_as::("slider")?; + + slider.set_value(&mut common, params.control.on_volume_request()? / VOLUME_MULT); + + checkbox.set_checked(&mut common, params.checked); + + checkbox.on_toggle({ + let control = params.control.clone(); + Box::new(move |_common, _event| { + control.on_check()?; + Ok(()) + }) + }); + + slider.on_value_changed({ + let control = params.control.clone(); + Box::new(move |_common, event| { + control.on_volume_change(event.value * VOLUME_MULT)?; + Ok(()) + }) + }); + + btn_mute.on_click({ + let control = params.control.clone(); + Box::new(move |_common, _event| { + control.on_mute_toggle()?; + Ok(()) + }) + }); + + c.finish()?; + + Ok(()) + } + + fn init_mode_sinks(&mut self, layout: &mut Layout) -> anyhow::Result<()> { + log::info!("refreshing sink list"); + + let sinks = pactl_wrapper::list_sinks()?; + let cards = pactl_wrapper::list_cards()?; + let default_sink = pactl_wrapper::get_default_sink(&sinks)?; + + layout.remove_children(self.id_devices); + + for sink in sinks { + let card = get_card_from_sink(&sink, &cards); + + let checked = if let Some(default_sink) = &default_sink { + sink.index == default_sink.index + } else { + false + }; + + let alt_desc = sink.description.clone(); + let muted = sink.mute; + + let control = Rc::new(ControlSink::new(self.tasks.clone(), self.on_update.clone(), sink)); + + let disp = card + .as_ref() + .map(|card| get_profile_display_name(&card.active_profile, card)); + + self.mount_device_slider(MountDeviceSliderParams { + checked, + disp, + alt_desc, + layout, + control, + muted, + })?; + } + + Ok(()) + } + + fn init_mode_sources(&mut self, layout: &mut Layout) -> anyhow::Result<()> { + log::info!("refreshing source list"); + + let sources = pactl_wrapper::list_sources()?; + let cards = pactl_wrapper::list_cards()?; + let default_source = pactl_wrapper::get_default_source(&sources)?; + + layout.remove_children(self.id_devices); + + for source in sources { + let card = get_card_from_source(&source, &cards); + + let checked = if let Some(default_source) = &default_source { + source.index == default_source.index + } else { + false + }; + + let alt_desc = source.description.clone(); + let muted = source.mute; + + let control = Rc::new(ControlSource::new(self.tasks.clone(), self.on_update.clone(), source)); + + let disp = card + .as_ref() + .map(|card| get_profile_display_name(&card.active_profile, card)); + + self.mount_device_slider(MountDeviceSliderParams { + checked, + disp, + alt_desc, + layout, + control, + muted, + })?; + } + + Ok(()) + } + + fn init_mode_cards(&mut self, layout: &mut Layout) -> anyhow::Result<()> { + log::info!("refreshing card list"); + + let cards = pactl_wrapper::list_cards()?; + layout.remove_children(self.id_devices); + + for card in cards { + self.mount_card(MountCardParams { layout, card: &card })?; + } + + Ok(()) } } diff --git a/uidev/src/profiler.rs b/uidev/src/profiler.rs index 3d4d523..f450310 100644 --- a/uidev/src/profiler.rs +++ b/uidev/src/profiler.rs @@ -31,7 +31,7 @@ impl Profiler { self.frametime_sum_us += frametime; if self.last_measure_us + self.interval_us < cur_micros { - log::debug!( + log::trace!( "avg frametime: {:.3}ms", (self.frametime_sum_us / self.measure_frames) as f32 / 1000.0 ); diff --git a/wgui/src/layout.rs b/wgui/src/layout.rs index 17caee9..b6dcd66 100644 --- a/wgui/src/layout.rs +++ b/wgui/src/layout.rs @@ -595,7 +595,7 @@ impl Layout { return Ok(()); } - log::debug!("re-computing layout, size {}x{}", size.x, size.y); + log::trace!("re-computing layout, size {}x{}", size.x, size.y); self.mark_redraw(); self.prev_size = size; @@ -603,7 +603,7 @@ impl Layout { self.refresh_recursively(self.tree_root_node, &mut to_refresh); if !to_refresh.is_empty() { - log::debug!("refreshing {} registered components", to_refresh.len()); + log::trace!("refreshing {} registered components", to_refresh.len()); for c in &to_refresh { self.components_to_refresh_once.insert(c.clone()); }