feat(sound): use cosmic-settings-daemon's varlink API
This commit is contained in:
parent
3a77442dbc
commit
9155a1e902
19 changed files with 1211 additions and 3470 deletions
15
pages/sound/Cargo.toml
Normal file
15
pages/sound/Cargo.toml
Normal 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
58
pages/sound/src/lib.rs
Normal 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
480
pages/sound/src/model.rs
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue