feat(sound): use cosmic-settings-daemon's varlink API

This commit is contained in:
Michael Murphy 2026-06-15 21:09:03 +02:00 committed by GitHub
parent 3a77442dbc
commit 9155a1e902
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1211 additions and 3470 deletions

15
pages/sound/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "cosmic-settings-sound"
version = "0.1.0"
edition = "2024"
rust-version.workspace = true
[dependencies]
futures = "0.3.32"
intmap = "3.1.3"
tokio = { workspace = true, features = ["time"] }
tracing = "0.1.44"
[dependencies.cosmic-settings-audio-client]
git = "https://github.com/pop-os/cosmic-settings-daemon"
features = ["codec"]

58
pages/sound/src/lib.rs Normal file
View file

@ -0,0 +1,58 @@
// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod model;
use std::sync::Arc;
pub use cosmic_settings_audio_client as audio_client;
use futures::{SinkExt, StreamExt};
pub async fn subscribe<T>(
mut emitter: futures::channel::mpsc::Sender<T>,
apply_fn: fn(Message) -> T,
) {
loop {
let mut client = match audio_client::connect().await {
Ok(client) => client,
Err(why) => {
if let audio_client::zlink::Error::Io(ref why) = why
&& why.kind() == std::io::ErrorKind::NotFound
{
tracing::error!("cosmic-settings-daemon varlink service not found.");
} else {
tracing::error!(
?why,
"failed to connect to cosmic-settings's varlink service"
);
}
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
continue;
}
};
if let Ok(Ok(mut stream)) = client.recv_events().await {
_ = emitter
.send(apply_fn(Message::Client(Arc::new(client))))
.await;
while let Some(message) = stream.next().await {
match message {
Ok(event) => {
_ = emitter.send(apply_fn(Message::Subscription(event))).await;
}
Err(why) => {
tracing::error!(?why, "event error");
}
}
}
}
}
}
#[derive(Clone, Debug)]
pub enum Message {
/// Connection to `com.system76.CosmicSettings`.
Client(Arc<audio_client::Client>),
/// Messages from the varlink audio client,
Subscription(audio_client::Event),
}

480
pages/sound/src/model.rs Normal file
View file

@ -0,0 +1,480 @@
// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::sync::Arc;
use cosmic_settings_audio_client::{self as audio_client, Availability, 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<usize>, Vec<u32>, Vec<String>)>,
pub device_names: IntMap<DeviceId, String>,
pub device_profiles: IntMap<DeviceId, Vec<ProfileInfo>>,
pub device_profiles_active: IntMap<DeviceId, ProfileInfo>,
pub device_routes: IntMap<DeviceId, Vec<RouteInfo>>,
pub node_devices: IntMap<NodeId, Option<u32>>,
pub sinks: Nodes,
pub sources: Nodes,
pub active_sink: ActiveNode,
pub active_source: ActiveNode,
pub default_sink: Option<NodeId>,
pub default_source: Option<NodeId>,
pub text: Text,
}
#[derive(Debug, Default)]
pub struct Text {
pub hd_audio: String,
pub usb_audio: String,
}
#[derive(Debug, Default)]
pub struct Nodes {
active: Option<usize>,
pub sorted_display: Vec<Arc<str>>,
pub sorted_index: Vec<u16>,
pub balance: Vec<Option<f32>>,
pub card_profile_device: Vec<Option<u32>>,
pub description: Vec<String>,
pub devices: Vec<Option<NodeId>>,
pub display: Vec<Arc<str>>,
pub mute: Vec<bool>,
pub name: Vec<String>,
pub id: Vec<NodeId>,
pub volume: Vec<u32>,
}
impl Nodes {
pub fn active(&self) -> Option<usize> {
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::<Vec<_>>();
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<f32>,
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(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.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::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);
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::<Vec<_>>();
self.device_profile_dropdowns.sort_by(|a, b| a.1.cmp(&b.1));
}
}
fn node_name(route: &str, node: &str) -> Arc<str> {
if route.is_empty() {
node.to_owned()
} else {
[route, " - ", node].concat()
}
.into()
}