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