cosmic-settings/subscriptions/sound/src/lib.rs
Vukašin Vojinović 787c5ed49f chore: clippy
2026-02-20 12:46:57 +01:00

955 lines
34 KiB
Rust

// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use cosmic::Task;
use cosmic::iced_futures::MaybeSend;
use cosmic_pipewire as pipewire;
use futures::{SinkExt, Stream};
use intmap::IntMap;
use pipewire::Availability;
use std::{
process::Stdio,
sync::{Arc, Mutex},
time::Duration,
};
pub type DeviceId = u32;
pub type NodeId = u32;
pub type ProfileId = i32;
pub type RouteId = u32;
pub fn watch() -> impl Stream<Item = Message> + MaybeSend + 'static {
cosmic::iced_futures::stream::channel(1, |mut emitter| async move {
loop {
let (cancel_tx, cancel_rx) = futures::channel::oneshot::channel::<()>();
let sender = Arc::new((Mutex::new(Vec::new()), tokio::sync::Notify::const_new()));
let receiver = sender.clone();
_ = emitter
.send(Message::SubHandle(Arc::new(SubscriptionHandle {
cancel_tx,
pipewire: pipewire::run(move |event| {
sender.0.lock().unwrap().push(event);
sender.1.notify_one();
}),
})))
.await;
let forwarder = Box::pin(async {
loop {
_ = receiver.1.notified().await;
let events = std::mem::take(&mut *receiver.0.lock().unwrap());
if !events.is_empty() {
_ = emitter.send(Message::Server(Arc::from(events))).await;
tokio::time::sleep(Duration::from_millis(64)).await;
}
}
});
futures::future::select(cancel_rx, forwarder).await;
}
})
}
#[derive(Default)]
pub struct Model {
subscription_handle: Option<SubscriptionHandle>,
pub device_profile_dropdowns: Vec<(DeviceId, String, Option<usize>, Vec<u32>, Vec<String>)>,
// Translated text
pub unplugged_text: String,
pub hd_audio_text: String,
pub usb_audio_text: String,
device_ids: IntMap<NodeId, DeviceId>,
node_names: IntMap<NodeId, String>,
card_profile_devices: IntMap<NodeId, u32>,
node_route_indexes: IntMap<NodeId, i32>,
device_names: IntMap<DeviceId, String>,
device_profiles: IntMap<DeviceId, Vec<pipewire::Profile>>,
active_profiles: IntMap<DeviceId, pipewire::Profile>,
device_routes: IntMap<DeviceId, Vec<pipewire::Route>>,
/** Sink devices */
/// Description of a sink device and its port
sinks: Vec<String>,
/// Node IDs for sinks
sink_node_ids: Vec<NodeId>,
/// Index of active sink device.
active_sink: Option<usize>,
/// Node ID of active sink device.
active_sink_node: Option<NodeId>,
/// Device ID of active sink device.
active_sink_device: Option<DeviceId>,
/// Device identifier of the default sink.
active_sink_node_name: String,
/** Source devices */
/// Product names for source devices.
sources: Vec<String>,
/// Node IDs for sources
source_node_ids: Vec<NodeId>,
/// Index of active source device.
active_source: Option<usize>,
/// Node ID of active source device.
active_source_node: Option<NodeId>,
/// Device ID of active source device.
active_source_device: Option<DeviceId>,
/// Node identifier of the default source.
active_source_node_name: String,
changing_sink_device: Option<DeviceId>,
changing_source_device: Option<DeviceId>,
pub sink_volume_text: String,
pub source_volume_text: String,
pub sink_balance: Option<f32>,
pub sink_volume: u32,
pub source_volume: u32,
pub sink_mute: bool,
sink_volume_debounce: bool,
pub source_mute: bool,
source_volume_debounce: bool,
}
impl Model {
pub fn active_sink(&self) -> Option<usize> {
self.active_sink
}
pub fn active_source(&self) -> Option<usize> {
self.active_source
}
pub fn sinks(&self) -> &[String] {
&self.sinks
}
pub fn sources(&self) -> &[String] {
&self.sources
}
pub fn clear(&mut self) {
if let Some(handle) = self.subscription_handle.take() {
_ = handle.cancel_tx.send(());
_ = handle.pipewire.send(pipewire::Request::Quit);
}
}
/// Send a message to the pipewire-rs thread.
pub fn pipewire_send(&self, request: pipewire::Request) {
if let Some(handle) = self.subscription_handle.as_ref() {
_ = handle.pipewire.send(request);
}
}
/// Sets and applies a profile to a device with wpctl.
///
/// Requires using the device ID rather than a node ID.
pub fn set_profile(&mut self, device_id: DeviceId, index: u32, save: bool) {
if save {
self.changing_sink_device = self
.device_ids
.iter()
.find(|(node_id, _device)| self.active_sink_node == Some(*node_id))
.and_then(|(_node_id, &device)| {
if device == device_id {
Some(device_id)
} else {
None
}
});
self.changing_source_device = self
.device_ids
.iter()
.find(|(node_id, _device)| self.active_source_node == Some(*node_id))
.and_then(|(_node_id, &device)| {
if device == device_id {
Some(device_id)
} else {
None
}
});
}
let mut update = false;
if let Some(profiles) = self.device_profiles.get(device_id) {
for profile in profiles {
if profile.index as u32 == index {
self.active_profiles.insert(device_id, profile.clone());
self.pipewire_send(pipewire::Request::SetProfile(device_id, index, save));
update = true;
}
}
if update {
self.update_ui_profiles();
}
// Use pw-cli as a fallback in case it wasn't set correctly.
tokio::spawn(async move {
set_profile(device_id, index, save).await;
});
}
}
/// Change the balance of channel volumes on the sink device.
pub fn set_sink_balance(&mut self, balance: u32) -> Task<Message> {
self.sink_balance = (balance != 100).then(|| balance as f32 / 100.);
if self.sink_volume_debounce {
return Task::none();
}
if let Some(id) = self.active_sink_node {
self.sink_volume_debounce = true;
return cosmic::Task::future(async move {
tokio::time::sleep(Duration::from_millis(128)).await;
Message::SinkVolumeApply(id)
});
}
Task::none()
}
/// Change the default sink device
pub fn set_default_sink(&mut self, pos: usize) -> Task<Message> {
if let Some(&node_id) = self.sink_node_ids.get(pos) {
self.set_default_sink_node_id(node_id);
}
Task::none()
}
pub fn set_default_sink_node_id(&mut self, node_id: NodeId) {
tracing::debug!(target: "sound", "set default sink node {node_id}");
self.set_default_sink_id(node_id);
// Use pactl if the node is not a device node.
let virtual_sink_name: Option<String> =
if let Some(device) = self.device_ids.get(node_id).cloned() {
// Get route index of the selected node and apply it to the device.
if let Some((card_profile_device, route_index)) = self
.card_profile_devices
.get(node_id)
.cloned()
.zip(self.node_route_indexes.get(node_id).cloned())
{
self.pipewire_send(pipewire::Request::SetRoute(
device,
card_profile_device,
route_index as u32,
));
}
None
} else {
self.node_names.get(node_id).cloned()
};
tokio::task::spawn(async move {
if let Some(node_name) = virtual_sink_name {
pactl_set_default_sink(&node_name).await
} else {
set_default(node_id).await
}
});
}
/// Toggle the mute property of the sink device.
pub fn toggle_sink_mute(&mut self) {
self.sink_mute = !self.sink_mute;
if let Some(node_id) = self.active_sink_node {
let mute = self.sink_mute;
if let Some(handle) = self.subscription_handle.as_mut() {
_ = handle
.pipewire
.send(pipewire::Request::SetNodeMute(node_id, mute));
}
}
}
/// Change the sink device's volume.
pub fn set_sink_volume(&mut self, volume: u32) -> Task<Message> {
self.sink_volume = volume;
self.sink_volume_text = numtoa::BaseN::<10>::u32(volume).as_str().to_owned();
if self.sink_volume_debounce {
return Task::none();
}
// Wait for the debounce duration before applying the volume change.
if let Some(node_id) = self.active_sink_node {
self.sink_volume_debounce = true;
return cosmic::Task::future(async move {
tokio::time::sleep(Duration::from_millis(128)).await;
Message::SinkVolumeApply(node_id)
});
}
Task::none()
}
/// Change the default source device.
pub fn set_default_source(&mut self, pos: usize) -> Task<Message> {
if let Some(&node_id) = self.source_node_ids.get(pos) {
self.set_default_source_node_id(node_id);
}
Task::none()
}
pub fn set_default_source_node_id(&mut self, node_id: NodeId) {
tracing::debug!(target: "sound", "set default source node {node_id}");
self.set_default_source_id(node_id);
// Use pactl if the node is not a device node.
let virtual_source_name: Option<String> =
if let Some(device) = self.device_ids.get(node_id).cloned() {
// Get route index of the selected node and apply it to the device.
if let Some((card_profile_device, route_index)) = self
.card_profile_devices
.get(node_id)
.cloned()
.zip(self.node_route_indexes.get(node_id).cloned())
{
self.pipewire_send(pipewire::Request::SetRoute(
device,
card_profile_device,
route_index as u32,
));
}
None
} else {
self.node_names.get(node_id).cloned()
};
tokio::task::spawn(async move {
if let Some(node_name) = virtual_source_name {
pactl_set_default_source(&node_name).await
} else {
set_default(node_id).await
}
});
}
/// Toggle the mute property of the source device.
pub fn toggle_source_mute(&mut self) {
self.source_mute = !self.source_mute;
if let Some(node_id) = self.active_source_node {
let mute = self.source_mute;
if let Some(handle) = self.subscription_handle.as_mut() {
_ = handle
.pipewire
.send(pipewire::Request::SetNodeMute(node_id, mute));
}
}
}
/// Change the source device's volume.
pub fn set_source_volume(&mut self, volume: u32) -> Task<Message> {
self.source_volume = volume;
self.source_volume_text = numtoa::BaseN::<10>::u32(volume).as_str().to_owned();
if self.source_volume_debounce {
return Task::none();
}
// Wait for the debounce duration before applying the volume change.
if let Some(node_id) = self.active_source_node {
self.source_volume_debounce = true;
return cosmic::Task::future(async move {
tokio::time::sleep(Duration::from_millis(128)).await;
Message::SourceVolumeApply(node_id)
});
}
Task::none()
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Server(events) => {
Arc::into_inner(events)
.into_iter()
.flatten()
.for_each(|event| self.pipewire_update(event));
}
Message::SinkVolumeApply(node_id) => {
self.sink_volume_debounce = false;
self.pipewire_send(pipewire::Request::SetNodeVolume(
node_id,
self.sink_volume as f32 / 100.0,
self.sink_balance,
));
}
Message::SourceVolumeApply(node_id) => {
self.source_volume_debounce = false;
self.pipewire_send(pipewire::Request::SetNodeVolume(
node_id,
self.source_volume as f32 / 100.0,
None,
));
}
Message::SubHandle(handle) => {
if let Some(handle) = Arc::into_inner(handle) {
self.subscription_handle = Some(handle);
}
}
}
Task::none()
}
fn pipewire_update(&mut self, event: pipewire::Event) {
match event {
pipewire::Event::NodeProperties(id, props) => {
if self.active_sink_node == Some(id) {
if self.sink_volume_debounce {
return;
}
if let Some(mute) = props.mute {
self.sink_mute = mute;
}
if let Some(channel_volumes) = props.channel_volumes {
let (volume, balance) =
pipewire::volume::from_channel_volumes(&channel_volumes);
self.sink_balance = balance;
self.sink_volume = (volume * 100.0) as u32;
self.sink_volume_text = numtoa::BaseN::<10>::u32(self.sink_volume)
.as_str()
.to_owned();
}
} else if self.active_source_node == Some(id) {
if self.source_volume_debounce {
return;
}
if let Some(mute) = props.mute {
self.source_mute = mute;
}
if let Some(channel_volumes) = props.channel_volumes {
let (volume, _balance) =
pipewire::volume::from_channel_volumes(&channel_volumes);
self.source_volume = (volume * 100.0) as u32;
self.source_volume_text = numtoa::BaseN::<10>::u32(self.source_volume)
.as_str()
.to_owned();
}
}
}
pipewire::Event::ActiveProfile(id, profile) => {
tracing::debug!(
target: "sound",
"Device {id} active profile changed to {}: {}",
profile.index,
profile.description
);
let prev = self.active_profiles.insert(id, profile.clone());
self.update_ui_profiles();
if let Some(prev) = prev {
if prev.index == profile.index {
return;
}
tracing::debug!(
target: "sound",
"Device {id} profile changed from {} to {}: {}",
prev.index, profile.index, profile.description
);
} else {
#[cfg(feature = "auto-profile-init")]
if profile.index != 0 {
// Use pw-cli to re-set the profile in case wireplumber has invalid state.
// Profiles set by us do not need to use this. Only sets if profile is not `Off`.
tracing::debug!(
target: "sound",
"Device {id} initialized with profile {}: {}", profile.index, profile.description
);
self.set_profile(id, profile.index as u32, false);
}
}
}
pipewire::Event::ActiveRoute(id, _index, route) => {
tracing::debug!(
target: "sound",
"Device {id} active route changed to {}: {}",
route.index,
route.description
);
self.update_device_route_name(&route, id);
let (active_device, node_ids, set_default_node): (
Option<DeviceId>,
&[NodeId],
fn(&mut Self, NodeId),
) = match route.direction {
pipewire::Direction::Output => (
self.active_sink_device,
&self.sink_node_ids,
Self::set_default_sink_id,
),
pipewire::Direction::Input => (
self.active_source_device,
&self.source_node_ids,
Self::set_default_source_id,
),
};
if active_device == Some(id) {
for (node_id, &device) in &self.device_ids {
if device == id && node_ids.contains(&node_id) {
set_default_node(self, node_id);
break;
}
}
}
}
pipewire::Event::AddProfile(id, index, profile) => {
if let Some(p) = self.active_profiles.get_mut(id)
&& p.index == profile.index
{
*p = profile.clone();
}
let profiles = self.device_profiles.entry(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(
pipewire::Profile::default(),
additional,
));
}
profiles[index as usize] = profile;
self.update_ui_profiles();
}
pipewire::Event::AddRoute(id, index, route) => self.add_route(id, index, route),
pipewire::Event::AddDevice(device) => {
tracing::debug!(target: "sound", "Device {} added: {}", device.id, device.name);
self.device_names
.insert(device.id, self.translate_device_name(&device.name));
}
pipewire::Event::AddNode(node) => {
tracing::debug!(target: "sound", "Node {} added: {}", node.object_id, node.node_name);
// Device nodes will have device and card profile device IDs.
// Virtual sinks/sources do not have these.
if let Some(device_id) = node.device_id {
self.device_ids.insert(node.object_id, device_id);
// This is the device number of the route. This is used with the
// device ID to set properties for a route.
if let Some(card_profile_device) = node.card_profile_device {
self.card_profile_devices
.insert(node.object_id, card_profile_device);
}
}
let description = self.translate_device_name(&node.description);
// The default sink/source is defined by a node's name. We use this when setting
// virtual sink/source nodes with pactl; and when pipewire notifies us of a new
// default sink/source.
if self
.node_names
.insert(node.object_id, node.node_name.clone())
.is_none()
{
// Use the device.profile.description as the route name by default for the UI.
let name = if node.device_profile_description.is_empty() {
description
} else {
[&node.device_profile_description, " - ", &description].concat()
};
// Check if the node is a sink or a source, and append it to the relevant collections.
match node.media_class {
pipewire::MediaClass::Sink => {
self.sinks.push(name);
self.sink_node_ids.push(node.object_id);
// Set the sink as the default if it matches the server.
if self.active_sink_node_name == node.node_name {
tracing::debug!(
target: "sound",
"Node {} ({}) was the default sink",
node.object_id,
node.node_name
);
self.set_default_sink_node_id(node.object_id);
} else if let Some(device_id) = self.changing_sink_device {
for (node_id, &device) in &self.device_ids {
if device == device_id && self.sink_node_ids.contains(&node_id)
{
self.changing_sink_device = None;
self.set_default_sink_node_id(node_id);
return;
}
}
}
}
pipewire::MediaClass::Source => {
self.sources.push(name);
self.source_node_ids.push(node.object_id);
// Set the source as the default if it matches the server.
if self.active_source_node_name == node.node_name {
tracing::debug!(
target: "sound",
"Node {} ({}) was the default source",
node.object_id,
node.node_name
);
self.set_default_source_node_id(node.object_id);
} else if let Some(device_id) = self.changing_source_device {
for (node_id, &device) in &self.device_ids {
if device == device_id
&& self.source_node_ids.contains(&node_id)
{
self.changing_source_device = None;
self.set_default_source_node_id(node_id);
return;
}
}
}
}
}
}
}
pipewire::Event::DefaultSink(node_name) => {
tracing::debug!(target: "sound", "default sink node changed to {node_name}");
if self.active_sink_node_name == node_name {
return;
}
if let Some(id) = self.node_id_from_name(&node_name) {
self.set_default_sink_id(id);
}
self.active_sink_node_name = node_name;
}
pipewire::Event::DefaultSource(node_name) => {
tracing::debug!(target: "sound", "default source node changed to {node_name}");
if self.active_source_node_name == node_name {
return;
}
if let Some(id) = self.node_id_from_name(&node_name) {
self.set_default_source_id(id);
}
self.active_source_node_name = node_name;
}
pipewire::Event::RemoveDevice(id) => self.remove_device(id),
pipewire::Event::RemoveNode(id) => self.remove_node(id),
}
}
fn add_route(&mut self, id: DeviceId, index: u32, route: pipewire::Route) {
self.update_device_route_name(&route, id);
tracing::debug!(target: "sound",
"Device {} added route {} ({:?}); {:?}",
id,
route.name,
route.direction,
route.available
);
let routes = self.device_routes.entry(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(pipewire::Route::default(), additional));
}
routes[index as usize] = route;
}
fn node_id_from_name(&self, name: &str) -> Option<u32> {
self.node_names
.iter()
.find(|&(_, n)| *n == name)
.map(|(id, _)| id)
}
fn remove_device(&mut self, id: DeviceId) {
tracing::debug!(target: "sound", "Device {id} removed");
_ = self.device_names.remove(id);
_ = self.device_profiles.remove(id);
_ = self.active_profiles.remove(id);
_ = self.device_routes.remove(id);
}
fn remove_node(&mut self, id: NodeId) {
tracing::debug!(target: "sound", "Node {id} removed");
if let Some(pos) = self.sink_node_ids.iter().position(|&node_id| node_id == id) {
self.sink_node_ids.remove(pos);
self.sinks.remove(pos);
if let Some(node_id) = self.active_sink_node
&& id == node_id
{
self.active_sink = None;
self.active_sink_node = None;
self.active_sink_node_name.clear();
}
} else if let Some(pos) = self
.source_node_ids
.iter()
.position(|&node_id| node_id == id)
{
self.source_node_ids.remove(pos);
self.sources.remove(pos);
if let Some(node_id) = self.active_source_node
&& id == node_id
{
self.active_source = None;
self.active_source_node = None;
self.active_source_node_name.clear();
}
}
_ = self.device_ids.remove(id);
_ = self.node_names.remove(id);
_ = self.card_profile_devices.remove(id);
}
/// Set the default sink device by its the node ID.
fn set_default_sink_id(&mut self, node_id: NodeId) {
self.active_sink = self.sink_node_ids.iter().position(|&id| id == node_id);
self.active_sink_node = Some(node_id);
self.active_sink_node_name = self.node_names.get(node_id).cloned().unwrap_or_default();
self.active_sink_device = self
.device_ids
.iter()
.find_map(|(nid, did)| if nid == node_id { Some(*did) } else { None });
}
/// Set the default source device by its the node ID.
fn set_default_source_id(&mut self, node_id: NodeId) {
self.active_source = self.source_node_ids.iter().position(|&id| id == node_id);
self.active_source_node = Some(node_id);
self.active_source_node_name = self.node_names.get(node_id).cloned().unwrap_or_default();
self.active_source_device = self
.device_ids
.iter()
.find_map(|(nid, did)| if nid == node_id { Some(*did) } else { None });
}
fn update_device_route_name(&mut self, route: &pipewire::Route, id: DeviceId) {
if matches!(route.available, Availability::No) {
return;
}
let (devices, node_ids) = match route.direction {
pipewire::Direction::Output => (&mut self.sinks, &self.sink_node_ids),
pipewire::Direction::Input => (&mut self.sources, &self.source_node_ids),
};
for (pos, &node) in node_ids.iter().enumerate() {
let Some(&device) = self.device_ids.get(node) else {
continue;
};
if device != id {
continue;
}
let Some(profile) = self.active_profiles.get(id) else {
continue;
};
if !profile.name.starts_with("pro-audio") {
let Some(&card_profile_device) = self.card_profile_devices.get(node) else {
continue;
};
if !route.devices.contains(&(card_profile_device as i32)) {
continue;
}
}
let Some(device_name) = self.device_names.get(id) else {
continue;
};
tracing::debug!(target: "sound", "matched route {} on {}: {}", route.index, id, route.description);
devices[pos] = [&route.description, " - ", device_name].concat();
self.node_route_indexes.insert(node, route.index);
break;
}
}
// Update the cached profiles for the UI.
fn update_ui_profiles(&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
.active_profiles
.get(device_id)
.map(|profile| {
let (indexes, descriptions): (Vec<_>, Vec<_>) = profiles
.iter()
.filter(|p| {
p.index == profile.index
|| !matches!(p.available, pipewire::Availability::No)
})
.map(|p| (p.index as u32, p.description.clone()))
.collect();
let pos = profiles
.iter()
.filter(|p| {
p.index == profile.index
|| !matches!(p.available, pipewire::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.available, pipewire::Availability::No))
.map(|p| (p.index as u32, 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 translate_device_name(&self, input: &str) -> String {
input
.replacen(" Controller", "", 1)
.replacen("High Definition Audio", &self.hd_audio_text, 1)
.replacen("HD Audio", &self.hd_audio_text, 1)
.replacen("USB Audio Device", &self.usb_audio_text, 1)
}
}
#[derive(Clone, Debug)]
pub enum Message {
/// Handle messages from the sound server.
Server(Arc<Vec<pipewire::Event>>),
/// Change the output volume.
SinkVolumeApply(NodeId),
/// Change the input volume.
SourceVolumeApply(NodeId),
/// On init of the subscription, channels for closing background threads are given to the app.
SubHandle(Arc<SubscriptionHandle>),
}
pub struct SubscriptionHandle {
cancel_tx: futures::channel::oneshot::Sender<()>,
pipewire: pipewire::Sender,
}
impl std::fmt::Debug for SubscriptionHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SubscriptionHandle")
}
}
// TODO: Use pipewire library
pub async fn set_default(id: u32) {
tracing::debug!(target: "sound", "setting default node {id}");
let id = numtoa::BaseN::<10>::u32(id);
_ = tokio::process::Command::new("wpctl")
.args(["set-default", id.as_str()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
/// Use this to set a virtual sink as a default.
/// TODO: We should be able to set this with pipewire-rs somehow.
pub async fn pactl_set_default_sink(node_name: &str) {
tracing::debug!(target: "sound", "setting default virtual node {node_name}");
_ = tokio::process::Command::new("pactl")
.args(["set-default-sink", node_name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
/// Use this to set a virtual sink as a default.
/// TODO: We should be able to set this with pipewire-rs somehow.
pub async fn pactl_set_default_source(node_name: &str) {
_ = tokio::process::Command::new("pactl")
.args(["set-default-source", node_name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
// TODO: Use pipewire library
pub async fn set_profile(id: u32, index: u32, save: bool) {
let id = numtoa::BaseN::<10>::u32(id);
let index = numtoa::BaseN::<10>::u32(index);
let value = [
"{ index: ",
index.as_str(),
if save {
", save: true }"
} else {
", save: false }"
},
]
.concat();
_ = tokio::process::Command::new("pw-cli")
.args(["s", id.as_str(), "Profile", &value])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}