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

src/pages/audio.rs (19.5K)

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