// Copyright 2026 System76 // SPDX-License-Identifier: GPL-3.0-only use std::sync::Arc; use cosmic_settings_audio_client::{ self as audio_client, Availability, PlaybackInfo, ProfileInfo, RouteInfo, }; use intmap::IntMap; pub type DeviceId = u32; pub type NodeId = u32; #[derive(Debug, Default)] pub struct Model { #[allow(clippy::type_complexity)] pub device_profile_dropdowns: Vec<(DeviceId, String, Option, Vec, Vec)>, pub device_names: IntMap, pub device_profiles: IntMap>, pub device_profiles_active: IntMap, pub device_routes: IntMap>, pub node_devices: IntMap>, pub playback: Vec, pub sinks: Nodes, pub sources: Nodes, pub active_sink: ActiveNode, pub active_source: ActiveNode, pub default_sink: Option, pub default_source: Option, pub text: Text, } #[derive(Clone, Debug)] pub struct Playback { pub id: NodeId, pub info: PlaybackInfo, pub mute: bool, pub volume: u32, pub balance: Option, /// Explicitly selected output. `None` means the playback follows the default sink. pub sink_id: Option, } impl Playback { pub fn name(&self) -> &str { [ self.info.application_name.as_deref(), self.info.application_id.as_deref(), self.info.media_name.as_deref(), Some(self.info.node_name.as_str()), ] .into_iter() .flatten() .find(|value| !value.is_empty()) .unwrap_or_default() } pub fn description(&self) -> Option<&str> { self.info .media_name .as_deref() .filter(|description| !description.is_empty() && *description != self.name()) } pub fn selected_sink(&self, default_sink: Option) -> Option { self.sink_id.or(default_sink) } } #[derive(Debug, Default)] pub struct Text { pub hd_audio: String, pub usb_audio: String, } #[derive(Debug, Default)] pub struct Nodes { active: Option, pub sorted_display: Vec>, pub sorted_index: Vec, 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; self.sorted_index = indexes; } 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(); if self.active == Some(pos) { self.active = None; } true } } #[derive(Debug, Default)] pub struct ActiveNode { pub volume_text: String, pub volume: u32, pub balance: Option, pub mute: bool, } impl Model { pub fn update(&mut self, event: audio_client::Event) { tracing::info!(target: "sound", ?event, "update"); match event { audio_client::Event::NodeMute(node_id, mute) => { if let Some(playback) = self.playback.iter_mut().find(|item| item.id == node_id) { playback.mute = mute; } else 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(playback) = self.playback.iter_mut().find(|item| item.id == node_id) { playback.volume = volume; playback.balance = balance; } else 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.balance = balance; 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::Device(device_id, device) => { self.device_names .insert(device_id, self.translate(&device.description)); } 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_or_else( || { Some(node_name( &node.device_profile_description, &self.sources.description[pos], )) }, |(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 }, ) { 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::Playback(node_id, info) => { if let Some(playback) = self.playback.iter_mut().find(|item| item.id == node_id) { playback.info = info; } else { self.playback.push(Playback { id: node_id, info, mute: false, volume: 0, balance: None, sink_id: None, }); } self.playback .sort_by(|a, b| a.name().cmp(b.name()).then(a.id.cmp(&b.id))); } audio_client::Event::PlaybackTarget(playback_id, sink_id) => { if let Some(playback) = self .playback .iter_mut() .find(|playback| playback.id == playback_id) { playback.sink_id = sink_id; } } 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 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.clone(); // self.update_device_names(device_id, &route); } audio_client::Event::ActiveProfile(device_id, profile) => { self.device_profiles_active.insert(device_id, profile); self.update_device_profile_dropdowns(); } audio_client::Event::Profile(device_id, index, profile) => { let profiles = self.device_profiles.entry(device_id).or_default(); if profiles.len() < index as usize + 1 { let additional = (index as usize + 1) - profiles.capacity(); profiles.reserve_exact(additional); profiles.extend(std::iter::repeat_n(ProfileInfo::default(), additional)); } profiles[index as usize] = profile; self.update_device_profile_dropdowns(); } audio_client::Event::RemoveNode(node_id) => { self.node_devices.remove(node_id); self.playback.retain(|item| item.id != node_id); for playback in &mut self.playback { if playback.sink_id == Some(node_id) { playback.sink_id = None; } } if !self.sinks.remove(node_id) { self.sources.remove(node_id); } } audio_client::Event::RemoveDevice(device_id) => { self.device_names.remove(device_id); self.device_profiles.remove(device_id); self.device_profiles_active.remove(device_id); self.device_routes.remove(device_id); self.update_device_profile_dropdowns(); } _ => (), } } pub fn translate(&self, description: &str) -> String { description .replace("High Definition", "HD") .replace("DisplayPort", "DP") .replace("Controller", "") .replace("HD Audio", &self.text.hd_audio) .replace("USB Audio", &self.text.usb_audio) } 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(&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(&route.description, &self.sources.description[pos]); self.sources.dropdown_sort(); break; } } } } fn update_device_profile_dropdowns(&mut self) { self.device_profile_dropdowns = self .device_profiles .iter() .filter_map(|(device_id, profiles)| { let name = self.device_names.get(device_id)?.as_str(); let (active_profile, indexes, descriptions) = self .device_profiles_active .get(device_id) .map(|profile| { let (indexes, descriptions): (Vec<_>, Vec<_>) = profiles .iter() .filter(|p| { p.index == profile.index || !matches!(p.availability, audio_client::Availability::No) }) .map(|p| (p.index, p.description.clone())) .collect(); let pos = profiles .iter() .filter(|p| { p.index == profile.index || !matches!(p.availability, audio_client::Availability::No) }) .enumerate() .find(|(_, p)| p.index == profile.index) .map(|(pos, _)| pos); (pos, indexes, descriptions) }) .unwrap_or_else(|| { let (indexes, descriptions): (Vec<_>, Vec<_>) = profiles .iter() .filter(|p| !matches!(p.availability, audio_client::Availability::No)) .map(|p| (p.index, p.description.clone())) .collect(); (None, indexes, descriptions) }); Some(( device_id, name.to_owned(), active_profile, indexes, descriptions, )) }) .collect::>(); self.device_profile_dropdowns.sort_by(|a, b| a.1.cmp(&b.1)); } } fn node_name(route: &str, node: &str) -> Arc { if route.is_empty() { node.to_owned() } else { [route, " - ", node].concat() } .into() }