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