git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/audio.rs (19.9K)

  1 use crate::app::{form_button, form_divider, AppAction, PageContent};
  2 use cce_ui::context::UiContext;
  3 use cce_ui::widget::Handle;
  4 use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
  5 use cce_ui::widget::{Spinbox, Slider};
  6 
  7 #[derive(Debug, Clone)]
  8 pub struct AudioSink {
  9     pub id: u32,
 10     pub name: String,
 11     pub volume: f32,
 12     pub muted: bool,
 13     pub active: bool,
 14 }
 15 
 16 #[derive(Debug, Clone)]
 17 pub struct AudioSource {
 18     pub id: u32,
 19     pub name: String,
 20     pub volume: f32,
 21     pub muted: bool,
 22     pub active: bool,
 23 }
 24 
 25 #[derive(Debug, Clone, Default)]
 26 pub struct AudioState {
 27     pub loaded: bool,
 28     pub sinks: Vec<AudioSink>,
 29     pub sources: Vec<AudioSource>,
 30     pub sink_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
 31     pub source_spinboxes: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Spinbox>>>,
 32     pub sink_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
 33     pub source_sliders: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::Slider>>>,
 34 }
 35 
 36 #[derive(Debug, Clone)]
 37 pub enum AudioMessage {
 38     Refreshed(AudioState),
 39     SinkVolume(u32, f32),
 40     SinkMute(u32),
 41     SourceVolume(u32, f32),
 42     SourceMute(u32),
 43 }
 44 
 45 fn drm_connected_ports() -> Vec<String> {
 46     let mut connected = Vec::new();
 47     let Ok(entries) = std::fs::read_dir("/sys/class/drm") else {
 48         return connected;
 49     };
 50     for entry in entries.flatten() {
 51         let name = entry.file_name().to_string_lossy().to_string();
 52         let status_path = entry.path().join("status");
 53         if status_path.exists() {
 54             if let Ok(s) = std::fs::read_to_string(&status_path) {
 55                 if s.trim() == "connected" {
 56                     connected.push(name);
 57                 }
 58             }
 59         }
 60     }
 61     connected
 62 }
 63 
 64 fn is_hdmi_active(name: &str, connected_ports: &[String]) -> bool {
 65     if !name.contains("HDMI") {
 66         return true;
 67     }
 68     for port in connected_ports {
 69         if !port.contains("HDMI") {
 70             continue;
 71         }
 72         if let Some(num) = name.split("HDMI").nth(1).and_then(|s| s.chars().next()) {
 73             if port.contains(&format!("HDMI-A-{}", num)) {
 74                 return true;
 75             }
 76         }
 77     }
 78     false
 79 }
 80 
 81 fn short_name(name: &str) -> String {
 82     if let Some(hifi_part) = name.split("HiFi__").nth(1) {
 83         let label = hifi_part.replace("__sink", "").replace("_", " ");
 84         return label;
 85     }
 86     if let Some(alsa) = name.strip_prefix("alsa_output.") {
 87         return alsa.split('.').next_back().unwrap_or(alsa).replace("_", " ");
 88     }
 89     if let Some(alsa) = name.strip_prefix("alsa_input.") {
 90         return alsa.split('.').next_back().unwrap_or(alsa).replace("_", " ");
 91     }
 92     name.to_string()
 93 }
 94 
 95 fn set_sink_volume(id: u32, vol: f32) {
 96     let pct = (vol * 100.0).round() as u32;
 97     let _ = tokio::process::Command::new("pactl")
 98         .args(["set-sink-volume", &id.to_string(), &format!("{}%", pct)])
 99         .spawn();
100 }
101 
102 fn set_sink_mute(id: u32, mute: bool) {
103     let _ = tokio::process::Command::new("pactl")
104         .args(["set-sink-mute", &id.to_string(), if mute { "1" } else { "0" }])
105         .spawn();
106 }
107 
108 fn set_source_volume(id: u32, vol: f32) {
109     let pct = (vol * 100.0).round() as u32;
110     let _ = tokio::process::Command::new("pactl")
111         .args(["set-source-volume", &id.to_string(), &format!("{}%", pct)])
112         .spawn();
113 }
114 
115 fn set_source_mute(id: u32, mute: bool) {
116     let _ = tokio::process::Command::new("pactl")
117         .args(["set-source-mute", &id.to_string(), if mute { "1" } else { "0" }])
118         .spawn();
119 }
120 
121 pub async fn fetch_audio_state() -> AudioState {
122     let connected = drm_connected_ports();
123     let sinks = fetch_sinks(&connected).await;
124     let sources = fetch_sources(&connected).await;
125     AudioState {
126         loaded: true,
127         sinks,
128         sources,
129         sink_spinboxes: Vec::new(),
130         source_spinboxes: Vec::new(),
131         sink_sliders: Vec::new(),
132         source_sliders: Vec::new(),
133     }
134 }
135 
136 async fn fetch_sinks(connected_ports: &[String]) -> Vec<AudioSink> {
137     let short_out = match tokio::process::Command::new("pactl")
138         .args(["list", "sinks", "short"]).output().await
139     {
140         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
141         Err(_) => return Vec::new(),
142     };
143 
144     let mut sinks = Vec::new();
145     for line in short_out.lines() {
146         let parts: Vec<&str> = line.split('\t').collect();
147         if parts.len() < 2 { continue; }
148         let id = parts[0].parse::<u32>().unwrap_or(0);
149         let name = parts[1].to_string();
150 
151         let vol = tokio::process::Command::new("pactl")
152             .args(["get-sink-volume", &id.to_string()]).output().await.ok()
153             .and_then(|o| {
154                 let s = String::from_utf8_lossy(&o.stdout);
155                 s.split('/').nth(1)
156                     .and_then(|v| v.trim().trim_end_matches('%').parse::<f32>().ok())
157             }).unwrap_or(50.0);
158 
159         let muted = tokio::process::Command::new("pactl")
160             .args(["get-sink-mute", &id.to_string()]).output().await.ok()
161             .map(|o| String::from_utf8_lossy(&o.stdout).contains("yes"))
162             .unwrap_or(false);
163 
164         sinks.push(AudioSink {
165             id, name: short_name(&name), volume: vol / 100.0, muted,
166             active: is_hdmi_active(&name, connected_ports),
167         });
168     }
169     sinks.sort_by(|a, b| b.id.cmp(&a.id));
170     sinks
171 }
172 
173 async fn fetch_sources(connected_ports: &[String]) -> Vec<AudioSource> {
174     let short_out = match tokio::process::Command::new("pactl")
175         .args(["list", "sources", "short"]).output().await
176     {
177         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
178         Err(_) => return Vec::new(),
179     };
180 
181     let mut sources = Vec::new();
182     for line in short_out.lines() {
183         let parts: Vec<&str> = line.split('\t').collect();
184         if parts.len() < 2 { continue; }
185         let id = parts[0].parse::<u32>().unwrap_or(0);
186         let name = parts[1].to_string();
187 
188         if name.contains(".monitor") { continue; }
189 
190         let vol = tokio::process::Command::new("pactl")
191             .args(["get-source-volume", &id.to_string()]).output().await.ok()
192             .and_then(|o| {
193                 let s = String::from_utf8_lossy(&o.stdout);
194                 s.split('/').nth(1)
195                     .and_then(|v| v.trim().trim_end_matches('%').parse::<f32>().ok())
196             }).unwrap_or(50.0);
197 
198         let muted = tokio::process::Command::new("pactl")
199             .args(["get-source-mute", &id.to_string()]).output().await.ok()
200             .map(|o| String::from_utf8_lossy(&o.stdout).contains("yes"))
201             .unwrap_or(false);
202 
203         sources.push(AudioSource {
204             id, name: short_name(&name), volume: vol / 100.0, muted,
205             active: is_hdmi_active(&name, connected_ports),
206         });
207     }
208     sources.sort_by(|a, b| b.id.cmp(&a.id));
209     sources
210 }
211 
212 #[allow(dead_code)]
213 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
214 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
215 #[allow(dead_code)]
216 const BLANK_BAR: [f32; 4] = [0.15, 0.15, 0.24, 1.0];
217 #[allow(dead_code)]
218 const FILL_BAR: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
219 #[allow(dead_code)]
220 const RED: [f32; 4] = [1.0, 0.33, 0.33, 1.0];
221 
222 const HEADING: [f32; 4] = [0.35, 0.65, 0.90, 1.0];
223 const BTN_NEUTRAL: ([f32; 4], [f32; 4]) = ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0]);
224 const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0]);
225 const TEXT_BTN: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
226 const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
227 
228 /// One device on one line: name, volume slider, spinbox, mute — or a dim "inactive" note.
229 /// The names stand in a column `name_w` wide, so every row's controls line up. The widgets
230 /// stay index-aligned with the device vecs.
231 #[allow(clippy::too_many_arguments)]
232 fn device_row<'w>(
233     col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
234     name: String,
235     name_w: f32,
236     active: bool,
237     muted: bool,
238     volume: f32,
239     ctx: &mut UiContext,
240     slider: Handle<cce_ui::widget::Adapted<Slider>>,
241     spin: Handle<cce_ui::widget::Adapted<Spinbox>>,
242     mute_action: AppAction,
243 ) {
244     let line_h = cce_ui::layout::form_line_height(12.0);
245     if !active {
246         col.row(|r| {
247             r.draw(name_w, line_h, false, move |pc, c, _| pc.text(&name, c.x, c.y, 12.0, TEXT_DIM));
248             r.text("inactive", 12.0, TEXT_DIM);
249         });
250         return;
251     }
252 
253     ctx[slider].set_value(volume);
254     ctx[spin].value = (volume * 100.0).round() as i32;
255     let (mute_label, colors, mute_text) = if muted {
256         ("Unmute", BTN_DANGER, TEXT_DANGER)
257     } else {
258         ("Mute", BTN_NEUTRAL, TEXT_BTN)
259     };
260     col.row(|r| {
261         r.draw(name_w, line_h, false, move |pc, c, _| {
262             pc.text_with_bounds(&name, c.x, c.y, 12.0, TEXT_FG, Some([c.x, c.y - 12.0, c.x + c.width, c.y + 24.0]));
263         });
264         r.widget_h(ctx, slider, cce_ui::layout::slider_height());
265         r.widget_w_h(ctx, spin, SPIN_W, cce_ui::layout::spinbox_height());
266         form_button(r, mute_label, MUTE_W, (colors.0, colors.1, mute_text), mute_action);
267     });
268 }
269 
270 /// The volume readout's width: three digits, a unit and the −/+ run.
271 const SPIN_W: f32 = 90.0;
272 /// The mute button's width: "Unmute" at the button font.
273 const MUTE_W: f32 = 80.0;
274 
275 pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
276     let mut final_pc = PageContent::new();
277     let sec_w = 320.0f32;
278     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
279 
280     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
281         let mut form = sec.form();
282         let mut col = form.column();
283         if !state.loaded {
284             col.text("Loading audio devices...", 12.0, TEXT_DIM);
285             sec.place(form, ctx);
286             return;
287         }
288         // One name column for both lists, as wide as the widest name (and never so wide that
289         // the slider has no room).
290         let name_w = state
291             .sinks
292             .iter()
293             .map(|d| d.name.as_str())
294             .chain(state.sources.iter().map(|d| d.name.as_str()))
295             .map(|n| cce_ui::layout::form_text_width(n, 12.0))
296             .fold(0.0, f32::max)
297             .min(col.form_width() / 3.0);
298 
299         col.text("Output", 14.0, HEADING);
300         if state.sinks.is_empty() {
301             col.text("No output devices found", 12.0, TEXT_DIM);
302         }
303         let devices = state.sinks.iter().zip(state.sink_sliders.iter().copied()).zip(state.sink_spinboxes.iter().copied());
304         for ((sink, slider), spin) in devices {
305             let action = AppAction::Audio(AudioMessage::SinkMute(sink.id));
306             device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, ctx, slider, spin, action);
307         }
308 
309         form_divider(&mut col);
310 
311         col.text("Input", 14.0, HEADING);
312         if state.sources.is_empty() {
313             col.text("No input devices found", 12.0, TEXT_DIM);
314         }
315         let devices = state.sources.iter().zip(state.source_sliders.iter().copied()).zip(state.source_spinboxes.iter().copied());
316         for ((src, slider), spin) in devices {
317             let action = AppAction::Audio(AudioMessage::SourceMute(src.id));
318             device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, ctx, slider, spin, action);
319         }
320         sec.place(form, ctx);
321     });
322 
323     final_pc
324 }
325 
326 
327 /// `rows` resized to `n` widgets, one per device: the context takes the new ones in and
328 /// gives the surplus back.
329 fn fit<W: cce_ui::widget::WidgetHost + 'static>(rows: &mut Vec<Handle<W>>, n: usize, ctx: &mut UiContext, make: impl Fn() -> W) {
330     for h in rows.drain(n.min(rows.len())..) {
331         ctx.remove(h);
332     }
333     while rows.len() < n {
334         rows.push(ctx.insert(make()));
335     }
336 }
337 
338 pub fn update(state: &mut AudioState, msg: AudioMessage, ctx: &mut UiContext) {
339     match msg {
340         AudioMessage::Refreshed(new) => {
341             state.loaded = new.loaded;
342             state.sinks = new.sinks;
343             state.sources = new.sources;
344             fit(&mut state.sink_spinboxes, state.sinks.len(), ctx, || Spinbox::new(50, 0, 100, 1));
345             fit(&mut state.source_spinboxes, state.sources.len(), ctx, || Spinbox::new(50, 0, 100, 1));
346             fit(&mut state.sink_sliders, state.sinks.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
347             fit(&mut state.source_sliders, state.sources.len(), ctx, || Slider::new().with_range(0.0, 1.0).with_scroll(true));
348         }
349         AudioMessage::SinkVolume(id, vol) => {
350             if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
351                 sink.volume = vol;
352                 set_sink_volume(id, vol);
353             }
354         }
355         AudioMessage::SinkMute(id) => {
356             if let Some(sink) = state.sinks.iter_mut().find(|s| s.id == id) {
357                 sink.muted = !sink.muted;
358                 set_sink_mute(id, sink.muted);
359             }
360         }
361         AudioMessage::SourceVolume(id, vol) => {
362             if let Some(src) = state.sources.iter_mut().find(|s| s.id == id) {
363                 src.volume = vol;
364                 set_source_volume(id, vol);
365             }
366         }
367         AudioMessage::SourceMute(id) => {
368             if let Some(src) = state.sources.iter_mut().find(|s| s.id == id) {
369                 src.muted = !src.muted;
370                 set_source_mute(id, src.muted);
371             }
372         }
373     }
374 }
375 
376 impl crate::pages::AppPage for AudioState {
377     // Sections: [Output, Input] — only active devices' controls, spinbox before slider
378     // per device (the old link order).
379     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
380         let mut output: Vec<cce_ui::widget::WidgetId> = Vec::new();
381         for (i, sink) in self.sinks.iter().enumerate() {
382             if sink.active {
383                 if let Some(sb) = self.sink_spinboxes.get_mut(i) {
384                     output.push(sb.id());
385                 }
386                 if let Some(sl) = self.sink_sliders.get_mut(i) {
387                     output.push(sl.id());
388                 }
389             }
390         }
391         let mut input: Vec<cce_ui::widget::WidgetId> = Vec::new();
392         for (i, src) in self.sources.iter().enumerate() {
393             if src.active {
394                 if let Some(sb) = self.source_spinboxes.get_mut(i) {
395                     input.push(sb.id());
396                 }
397                 if let Some(sl) = self.source_sliders.get_mut(i) {
398                     input.push(sl.id());
399                 }
400             }
401         }
402         output.extend(input);
403         vec![output]
404     }
405 
406     fn view(
407         &mut self,
408         cx: f32,
409         cy: f32,
410         cw: f32,
411         ch: f32,
412         _root_focused: bool,
413         sec_focused: &[bool],
414         layout: &mut cce_ui::layout::PageFlow,
415         ctx: &mut cce_ui::context::UiContext,
416     ) -> crate::app::PageContent {
417         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
418     }
419 
420     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
421         for (i, &sb) in self.sink_spinboxes.iter().enumerate() {
422             let sb = &mut ctx[sb];
423             if sb.take_change() {
424                 let id = self.sinks[i].id;
425                 actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, sb.value as f32 / 100.0)));
426             }
427         }
428         for (i, &sb) in self.source_spinboxes.iter().enumerate() {
429             let sb = &mut ctx[sb];
430             if sb.take_change() {
431                 let id = self.sources[i].id;
432                 actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, sb.value as f32 / 100.0)));
433             }
434         }
435         for (i, &slider) in self.sink_sliders.iter().enumerate() {
436             if ctx[slider].take_change() {
437                 let value = ctx[slider].value();
438                 let id = self.sinks[i].id;
439                 // Keep the paired spinbox display in step, as the old drag path did.
440                 if let Some(&sb) = self.sink_spinboxes.get(i) {
441                     ctx[sb].value = value;
442                 }
443                 actions.push(AppAction::Audio(AudioMessage::SinkVolume(id, value as f32 / 100.0)));
444             }
445         }
446         for (i, &slider) in self.source_sliders.iter().enumerate() {
447             if ctx[slider].take_change() {
448                 let value = ctx[slider].value();
449                 let id = self.sources[i].id;
450                 if let Some(&sb) = self.source_spinboxes.get(i) {
451                     ctx[sb].value = value;
452                 }
453                 actions.push(AppAction::Audio(AudioMessage::SourceVolume(id, value as f32 / 100.0)));
454             }
455         }
456     }
457 
458     // The pointer drag hooks are GONE (6bd routed events): slider drags ride the
459     // router's drag-target machinery — presses were already routed through the section
460     // roots, and DragUpdate/DragEnd now reach the sliders the same way. Value changes
461     // surface through the take_change drain above.
462 }
463 
464 #[cfg(test)]
465 mod tests {
466     use super::*;
467     use cce_ui::layout::PageFlow;
468 
469     #[test]
470     fn test_view_layout_grid() {
471         use cce_ui::widget::{Spinbox, Slider};
472         let mut ui = UiContext::new();
473         let mut state = AudioState {
474             loaded: true,
475             sinks: vec![
476                 AudioSink { id: 71, name: "Speaker".to_string(), volume: 0.57, muted: false, active: true },
477                 AudioSink { id: 70, name: "HDMI1".to_string(), volume: 0.5, muted: false, active: false },
478                 AudioSink { id: 69, name: "HDMI2".to_string(), volume: 0.5, muted: false, active: false },
479                 AudioSink { id: 68, name: "HDMI3".to_string(), volume: 0.5, muted: false, active: false },
480             ],
481             sources: vec![],
482             sink_spinboxes: vec![
483                 ui.insert(Spinbox::new(57, 0, 100, 1)),
484                 ui.insert(Spinbox::new(50, 0, 100, 1)),
485                 ui.insert(Spinbox::new(50, 0, 100, 1)),
486                 ui.insert(Spinbox::new(50, 0, 100, 1)),
487             ],
488             source_spinboxes: vec![],
489             sink_sliders: vec![
490                 ui.insert(Slider::new()),
491                 ui.insert(Slider::new()),
492                 ui.insert(Slider::new()),
493                 ui.insert(Slider::new()),
494             ],
495             source_sliders: vec![],
496         };
497         let mut layout = PageFlow::new();
498         // One flag: section_widgets() returns a single group (the output ids
499         // with input appended), so the view draws one section and reads [0].
500         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
501         for (i, (c, x, y, w, h, r, _)) in pc.rects.iter().enumerate() {
502             println!("TEST_PC_RECT {}: color={:?}, x={}, y={}, w={}, h={}, r={}", i, c, x, y, w, h, r);
503         }
504         for (i, (t, sz, x, y, c, _, _)) in pc.texts.iter().enumerate() {
505             println!("TEST_PC_TEXT {}: text='{}', size={}, x={}, y={}, color={:?}", i, t, sz, x, y, c);
506         }
507         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
508     }
509 
510     /// A device's spinbox with its context menu open, given back by the context when a
511     /// refresh drops the device: the menu goes with it rather than outliving its widget.
512     #[test]
513     fn a_removed_spinbox_takes_its_menu_with_it() {
514         use cce_ui::widget::{WidgetHost, Spinbox};
515         let mut ui = UiContext::new();
516         let mut state = AudioState::default();
517         fit(&mut state.sink_spinboxes, 1, &mut ui, || Spinbox::new(50, 0, 100, 1));
518         let sb = state.sink_spinboxes[0];
519         ui[sb].set_rect(0.0, 0.0, 100.0, 44.0);
520         let res = ui.lend_h(sb, |w, ctx| {
521             w.mouse_input(cce_ui::widget::MouseButton::Right, cce_ui::widget::ElementState::Pressed, 50.0, 20.0, ctx)
522         });
523         assert_eq!(res, Some(true));
524         assert!(cce_ui::widget::context_menu::is_visible());
525 
526         // The device is gone on the next refresh: its spinbox leaves the context.
527         fit(&mut state.sink_spinboxes, 0, &mut ui, || Spinbox::new(50, 0, 100, 1));
528         assert!(ui.get(sb).is_none());
529         assert!(!cce_ui::widget::context_menu::is_visible());
530     }
531 }
532 
533