// Copyright 2026 System76 // SPDX-License-Identifier: GPL-3.0-only use std::sync::Arc; use cosmic_settings_audio_client::{self as audio_client, Availability, RouteInfo}; use intmap::IntMap; pub type DeviceId = u32; pub type NodeId = u32; #[derive(Debug, Default)] pub struct Model { pub device_routes: IntMap>, pub node_devices: IntMap>, pub sinks: Nodes, pub sources: Nodes, pub active_sink: ActiveNode, pub active_source: ActiveNode, pub default_sink: Option, pub default_source: Option, } #[derive(Debug, Default)] pub struct Nodes { active: Option, pub sorted_display: Box<[Arc]>, pub sorted_index: Box<[u16]>, pub balance: Vec>, pub card_profile_device: Vec>, pub description: Vec, pub devices: Vec>, pub display: Vec>, pub mute: Vec, pub name: Vec, pub id: Vec, pub volume: Vec, } impl Nodes { pub fn active(&self) -> Option { self.active.and_then(|active| { self.sorted_index .iter() .position(|idx| *idx as usize == active) }) } pub fn dropdown_sort(&mut self) { let mut enumerated_displays = self .display .clone() .into_iter() .enumerate() .map(|(index, display)| (index as u16, display)) .collect::>(); enumerated_displays.sort_by_key(|v| v.1.clone()); let (indexes, displays): (Vec<_>, Vec<_>) = enumerated_displays.into_iter().unzip(); self.sorted_display = displays.into_boxed_slice(); self.sorted_index = indexes.into_boxed_slice(); } pub fn remove(&mut self, node_id: u32) -> bool { let Some(pos) = self.id.iter().position(|id| node_id == *id) else { return false; }; self.balance.remove(pos); self.card_profile_device.remove(pos); self.description.remove(pos); self.devices.remove(pos); self.display.remove(pos); self.mute.remove(pos); self.name.remove(pos); self.id.remove(pos); self.volume.remove(pos); self.dropdown_sort(); match self.active { Some(active) if active == pos => self.active = None, Some(active) if active > pos => self.active = Some(active - 1), _ => {} } true } } #[derive(Debug, Default)] pub struct ActiveNode { pub volume_text: String, pub volume: u32, pub mute: bool, } impl Model { pub fn update(&mut self, event: audio_client::Event) { tracing::debug!(?event, "update"); match event { audio_client::Event::NodeMute(node_id, mute) => { if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) { self.sinks.mute[pos] = mute; if self.sinks.active == Some(pos) { self.active_sink.mute = mute; } } else if let Some(pos) = self.sources.id.iter().position(|id| node_id == *id) { self.sources.mute[pos] = mute; if self.sources.active == Some(pos) { self.active_source.mute = mute; } } } audio_client::Event::NodeVolume(node_id, volume, balance) => { if let Some(pos) = self.sinks.id.iter().position(|id| node_id == *id) { self.sinks.volume[pos] = volume; self.sinks.balance[pos] = balance; if self.default_sink.as_ref().is_some_and(|&id| id == node_id) && let Some(pos) = self.sinks.active { self.active_sink.mute = self.sinks.mute[pos]; self.active_sink.volume = self.sinks.volume[pos]; self.active_sink.volume_text = self.active_sink.volume.to_string(); } } else if let Some(pos) = self.sources.id.iter().position(|id| node_id == *id) { self.sources.volume[pos] = volume; self.sources.balance[pos] = balance; if self .default_source .as_ref() .is_some_and(|&id| id == node_id) && let Some(pos) = self.sources.active { self.active_source.mute = self.sources.mute[pos]; self.active_source.volume = self.sources.volume[pos]; self.active_source.volume_text = self.active_source.volume.to_string(); } } } audio_client::Event::DefaultSink(node_id) => { self.default_sink = Some(node_id); if let Some(pos) = self.sinks.id.iter().position(|&id| id == node_id) { self.sinks.active = Some(pos); self.active_sink.mute = self.sinks.mute[pos]; self.active_sink.volume = self.sinks.volume[pos]; self.active_sink.volume_text = self.active_sink.volume.to_string(); } } audio_client::Event::DefaultSource(node_id) => { self.default_source = Some(node_id); if let Some(pos) = self.sources.id.iter().position(|&id| id == node_id) { self.sources.active = Some(pos); self.active_source.mute = self.sources.mute[pos]; self.active_source.volume = self.sources.volume[pos]; self.active_source.volume_text = self.active_source.volume.to_string(); } } audio_client::Event::Node(node_id, node) => { self.node_devices.insert(node_id, node.device_id); if node.is_sink { let pos = if let Some(pos) = self.sinks.id.iter().position(|&id| id == node_id) { self.sinks.description[pos] = self.translate(&node.description); self.sinks.name[pos] = node.name; self.sinks.card_profile_device[pos] = node.card_profile_device; pos } else { self.sinks.display.push(Arc::default()); self.sinks .description .push(self.translate(&node.description)); self.sinks.id.push(node_id); self.sinks.volume.push(0); self.sinks.balance.push(None); self.sinks.mute.push(false); self.sinks.name.push(node.name); self.sinks.devices.push(node.device_id); self.sinks .card_profile_device .push(node.card_profile_device); self.sinks.id.len() - 1 }; self.sinks.display[pos] = node .device_id .zip(node.card_profile_device) .and_then(|(device_id, node_card_profile_device)| { let routes = self.device_routes.get(device_id)?; for route in routes { if matches!(route.availability, Availability::No) || !route.is_sink { continue; } if route.devices.contains(&node_card_profile_device) { return Some(node_name( &self.translate(&route.description), &self.sinks.description[pos], )); } } None }) .unwrap_or_else(|| { node_name( &node.device_profile_description, &self.sinks.description[pos], ) }); self.sinks.dropdown_sort(); if let Some(default_node_id) = self.default_sink && default_node_id == node_id { self.sinks.active = Some(pos); self.active_sink.mute = self.sinks.mute[pos]; self.active_sink.volume = self.sinks.volume[pos]; self.active_sink.volume_text = self.active_sink.volume.to_string(); } } else { let pos = if let Some(pos) = self.sources.id.iter().position(|&id| id == node_id) { self.sources.description[pos] = self.translate(&node.description); self.sources.name[pos] = node.name; self.sources.card_profile_device[pos] = node.card_profile_device; pos } else { self.sources .description .push(self.translate(&node.description)); self.sources.display.push(Arc::default()); self.sources.id.push(node_id); self.sources.volume.push(0); self.sources.balance.push(None); self.sources.mute.push(false); self.sources.name.push(node.name); self.sources.devices.push(node.device_id); self.sources .card_profile_device .push(node.card_profile_device); self.sources.id.len() - 1 }; if let Some(name) = node .device_id .zip(node.card_profile_device) .map(|(device_id, node_card_profile_device)| { let routes = self.device_routes.get(device_id)?; for route in routes { if route.is_sink || matches!(route.availability, Availability::No) { continue; } if route.devices.contains(&node_card_profile_device) { return Some(node_name( &self.translate(&route.description), &self.sources.description[pos], )); } } None }) .unwrap_or_else(|| { Some(node_name( &node.device_profile_description, &self.sources.description[pos], )) }) { self.sources.display[pos] = name; self.sources.dropdown_sort(); } else { // Remove sources that are unplugged. self.sources.remove(node_id); return; } if let Some(default_node_id) = self.default_source && default_node_id == node_id { self.sources.active = Some(pos); self.active_source.mute = self.sources.mute[pos]; self.active_source.volume = self.sources.volume[pos]; self.active_source.volume_text = self.active_source.volume.to_string(); } } } audio_client::Event::ActiveRoute(device_id, _index, route) => { self.update_device_names(device_id, &route); } audio_client::Event::Route(device_id, index, route) => { let routes = self.device_routes.entry(device_id).or_default(); if index == 0 { *routes = vec![route]; } else { if routes.len() < index as usize + 1 { let additional = (index as usize + 1) - routes.capacity(); routes.reserve_exact(additional); routes.extend(std::iter::repeat_n(RouteInfo::default(), additional)); } routes[index as usize] = route; } } audio_client::Event::RemoveNode(node_id) => { self.node_devices.remove(node_id); if !self.sinks.remove(node_id) { self.sources.remove(node_id); } } audio_client::Event::RemoveDevice(device_id) => { self.device_routes.remove(device_id); } _ => (), } } fn update_device_names(&mut self, device_id: DeviceId, route: &RouteInfo) { if matches!(route.availability, Availability::No) { return; } let compatible_nodes = self.node_devices.iter().filter_map(|(node, &dev_id)| { if dev_id? == device_id { Some(node) } else { None } }); if route.is_sink { for n_id in compatible_nodes { let Some(pos) = self.sinks.id.iter().position(|&node| node == n_id) else { continue; }; let Some(card_profile_device) = self.sinks.card_profile_device[pos] else { continue; }; if route.devices.contains(&card_profile_device) { self.sinks.display[pos] = node_name( &self.translate(&route.description), &self.sinks.description[pos], ); self.sinks.dropdown_sort(); break; } } } else { for n_id in compatible_nodes { let Some(pos) = self.sources.id.iter().position(|&node| node == n_id) else { continue; }; let Some(card_profile_device) = self.sources.card_profile_device[pos] else { continue; }; if route.devices.contains(&card_profile_device) { self.sources.display[pos] = node_name( &self.translate(&route.description), &self.sources.description[pos], ); self.sources.dropdown_sort(); break; } } } } pub fn translate(&self, description: &str) -> String { description .replace("High Definition", "HD") .replace("DisplayPort", "DP") .replace("Controller", "") } } fn node_name(route: &str, node: &str) -> Arc { if route.is_empty() { node.to_owned() } else { [route, " - ", node].concat() } .into() }