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
1051
Cargo.lock
generated
1051
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["cosmic-settings", "crates/*", "page", "pages/*", "subscriptions/*"]
|
||||
members = ["cosmic-settings", "page", "pages/*", "subscriptions/*"]
|
||||
default-members = ["cosmic-settings"]
|
||||
resolver = "3"
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,8 @@ cosmic-settings-a11y-manager-subscription = { path = "../subscriptions/a11y-mana
|
|||
cosmic-settings-airplane-mode-subscription = { path = "../subscriptions/airplane-mode", optional = true }
|
||||
cosmic-settings-bluetooth-subscription = { path = "../subscriptions/bluetooth", optional = true }
|
||||
cosmic-settings-network-manager-subscription = { path = "../subscriptions/network-manager", optional = true }
|
||||
cosmic-settings-sound = { path = "../pages/sound", optional = true }
|
||||
cosmic-settings-upower-subscription = { path = "../subscriptions/upower", optional = true }
|
||||
cosmic-settings-sound-subscription = { path = "../subscriptions/sound", optional = true, features = [
|
||||
"auto-profile-init",
|
||||
] }
|
||||
cosmic-settings-wallpaper = { path = "../pages/wallpapers" }
|
||||
cosmic-settings-daemon-config = { git = "https://github.com/pop-os/cosmic-settings-daemon", optional = true }
|
||||
derive_setters = "0.1.9"
|
||||
|
|
@ -98,6 +96,13 @@ gettext-rs = { version = "0.7.7", features = [
|
|||
num-traits = "0.2"
|
||||
pwhash = "1"
|
||||
which = "8.0.0"
|
||||
zlink = "0.5.0"
|
||||
intmap = "3.1.3"
|
||||
|
||||
[dependencies.cosmic-settings-audio-client]
|
||||
git = "https://github.com/pop-os/cosmic-settings-daemon"
|
||||
features = ["codec"]
|
||||
optional = true
|
||||
|
||||
[dependencies.icu]
|
||||
version = "2.1.1"
|
||||
|
|
@ -183,7 +188,7 @@ page-region = [
|
|||
"dep:zbus",
|
||||
"dep:accounts-zbus",
|
||||
]
|
||||
page-sound = ["dep:cosmic-settings-sound-subscription"]
|
||||
page-sound = ["dep:cosmic-settings-audio-client", "dep:cosmic-settings-sound"]
|
||||
page-users = ["xdg-portal", "dep:accounts-zbus", "dep:zbus", "dep:zbus_polkit"]
|
||||
page-window-management = ["cosmic-comp-config", "dep:cosmic-settings-config"]
|
||||
page-workspaces = ["cosmic-comp-config"]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
// Copyright 2025 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use cosmic::{Apply, widget};
|
||||
use super::model;
|
||||
use cosmic::iced::futures;
|
||||
use cosmic::{Apply, iced, widget};
|
||||
use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
|
||||
use cosmic_settings_page::{self as page, Section, section};
|
||||
use cosmic_settings_sound_subscription::{self as subscription};
|
||||
use futures::executor::block_on;
|
||||
use slotmap::SlotMap;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {}
|
||||
pub enum Message {
|
||||
/// Update for the model.
|
||||
Model(cosmic_settings_sound::Message),
|
||||
/// Set the profile of a sound device.
|
||||
SetProfile(u32, u32),
|
||||
/// Surface Action
|
||||
Surface(cosmic::surface::Action),
|
||||
}
|
||||
|
||||
impl From<Message> for crate::pages::Message {
|
||||
fn from(message: Message) -> Self {
|
||||
|
|
@ -24,6 +37,8 @@ impl From<Message> for crate::Message {
|
|||
#[derive(Default)]
|
||||
pub struct Page {
|
||||
entity: page::Entity,
|
||||
model: model::Model,
|
||||
client: Option<Rc<RefCell<audio_client::Client>>>,
|
||||
}
|
||||
|
||||
impl page::AutoBind<crate::pages::Message> for Page {}
|
||||
|
|
@ -41,55 +56,83 @@ impl page::Page<crate::pages::Message> for Page {
|
|||
Some(vec![sections.insert(view())])
|
||||
}
|
||||
|
||||
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
|
||||
cosmic::Task::done(crate::pages::Message::Sound(super::Message::Reload))
|
||||
}
|
||||
|
||||
fn set_id(&mut self, entity: cosmic_settings_page::Entity) {
|
||||
self.entity = entity;
|
||||
}
|
||||
|
||||
fn subscription(
|
||||
&self,
|
||||
_core: &cosmic::Core,
|
||||
) -> cosmic::iced::Subscription<crate::pages::Message> {
|
||||
cosmic::iced::Subscription::run(subscription::watch)
|
||||
.map(|message| super::Message::Subscription(message).into())
|
||||
fn on_leave(&mut self) -> cosmic::Task<crate::pages::Message> {
|
||||
*self = Page {
|
||||
entity: self.entity,
|
||||
..Page::default()
|
||||
};
|
||||
cosmic::Task::none()
|
||||
}
|
||||
|
||||
fn subscription(&self, _core: &cosmic::Core) -> iced::Subscription<crate::pages::Message> {
|
||||
iced::Subscription::run(|| {
|
||||
iced::stream::channel(
|
||||
1,
|
||||
move |emitter: futures::channel::mpsc::Sender<crate::pages::Message>| async move {
|
||||
cosmic_settings_sound::subscribe(emitter, |m| Message::Model(m).into()).await
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn update(&mut self, _message: Message) -> cosmic::Task<crate::app::Message> {
|
||||
pub fn update(&mut self, message: Message) -> cosmic::Task<crate::app::Message> {
|
||||
match message {
|
||||
Message::Model(cosmic_settings_sound::Message::Subscription(message)) => {
|
||||
self.model.update(message);
|
||||
}
|
||||
|
||||
Message::Model(cosmic_settings_sound::Message::Client(client)) => {
|
||||
if let Some(client) = Arc::into_inner(client) {
|
||||
self.client = Some(Rc::new(RefCell::new(client)));
|
||||
self.model = model::Model {
|
||||
text: model::Text {
|
||||
hd_audio: fl!("sound-hd-audio"),
|
||||
usb_audio: fl!("sound-usb-audio"),
|
||||
},
|
||||
..model::Model::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Message::Surface(a) => return cosmic::task::message(crate::app::Message::Surface(a)),
|
||||
|
||||
Message::SetProfile(id, index) => {
|
||||
if let Some(client) = self.client.clone() {
|
||||
block_on(async move {
|
||||
_ = client.borrow_mut().conn.set_profile(id, index, true).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cosmic::Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view() -> Section<crate::pages::Message> {
|
||||
Section::default().view::<Page>(move |binder, _page, _section| {
|
||||
let sound_page_id = binder.find_page_by_id("sound").unwrap().0;
|
||||
let sound_page = binder.page[sound_page_id]
|
||||
.downcast_ref::<super::Page>()
|
||||
.unwrap();
|
||||
|
||||
let devices = sound_page
|
||||
.model
|
||||
.device_profile_dropdowns
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(device_id, name, active_profile, indexes, descriptions)| {
|
||||
Section::default().view::<Page>(move |_, page, _section| {
|
||||
let devices = page.model.device_profile_dropdowns.iter().cloned().map(
|
||||
|(device_id, name, active_profile, indexes, descriptions)| {
|
||||
let dropdown = widget::dropdown::popup_dropdown(
|
||||
descriptions,
|
||||
active_profile,
|
||||
move |id| super::Message::SetProfile(device_id, indexes[id]),
|
||||
move |id| Message::SetProfile(device_id, indexes[id]),
|
||||
cosmic::iced::window::Id::RESERVED,
|
||||
super::Message::Surface,
|
||||
Message::Surface,
|
||||
crate::Message::from,
|
||||
)
|
||||
.apply(cosmic::Element::from)
|
||||
.map(crate::pages::Message::from);
|
||||
|
||||
widget::settings::item::builder(name).control(dropdown)
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
widget::settings::section().extend(devices).into()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@
|
|||
|
||||
pub mod device_profiles;
|
||||
|
||||
use cosmic::iced::{Alignment, Length, window};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cosmic::iced::{self, Alignment, Length, window};
|
||||
use cosmic::widget::space::horizontal as horizontal_space;
|
||||
use cosmic::widget::{self, settings};
|
||||
use cosmic::{Apply, Element, Task, surface};
|
||||
use cosmic_config::{Config, ConfigGet, ConfigSet};
|
||||
use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
|
||||
use cosmic_settings_page::{self as page, Section, section};
|
||||
use cosmic_settings_sound_subscription as subscription;
|
||||
use cosmic_settings_sound::model;
|
||||
use futures::executor::block_on;
|
||||
use slotmap::SlotMap;
|
||||
|
||||
const AUDIO_CONFIG: &str = "com.system76.CosmicAudio";
|
||||
|
|
@ -18,22 +24,18 @@ const AMPLIFICATION_SOURCE: &str = "amplification_source";
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
/// Reload the model
|
||||
Reload,
|
||||
/// Updates for the model.
|
||||
Model(cosmic_settings_sound::Message),
|
||||
/// Change the default output.
|
||||
SetDefaultSink(usize),
|
||||
/// Change the default input output.
|
||||
SetDefaultSource(usize),
|
||||
/// Set the profile of a sound device.
|
||||
SetProfile(u32, u32),
|
||||
/// Change the balance of the active sink.
|
||||
SetSinkBalance(u32),
|
||||
SetSinkBalance(f32),
|
||||
/// Request to change the default output volume.
|
||||
SetSinkVolume(u32),
|
||||
/// Request to change the input volume.
|
||||
SetSourceVolume(u32),
|
||||
/// Messages handled by the sound module in cosmic-settings-subscriptions
|
||||
Subscription(subscription::Message),
|
||||
/// Surface Action
|
||||
Surface(surface::Action),
|
||||
/// Toggle the mute status of the output.
|
||||
|
|
@ -58,16 +60,11 @@ impl From<Message> for crate::Message {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<subscription::Message> for Message {
|
||||
fn from(val: subscription::Message) -> Self {
|
||||
Message::Subscription(val)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Page {
|
||||
entity: page::Entity,
|
||||
device_profiles: page::Entity,
|
||||
pub(self) model: subscription::Model,
|
||||
client: Option<Rc<RefCell<audio_client::Client>>>,
|
||||
model: model::Model,
|
||||
sound_config: Option<Config>,
|
||||
amplification_sink: bool,
|
||||
amplification_source: bool,
|
||||
|
|
@ -75,14 +72,17 @@ pub struct Page {
|
|||
|
||||
impl Default for Page {
|
||||
fn default() -> Self {
|
||||
let mut model = subscription::Model::default();
|
||||
model.unplugged_text = fl!("sound-device-port-unplugged");
|
||||
model.hd_audio_text = fl!("sound-hd-audio");
|
||||
model.usb_audio_text = fl!("sound-usb-audio");
|
||||
Self {
|
||||
entity: page::Entity::default(),
|
||||
device_profiles: page::Entity::default(),
|
||||
model,
|
||||
client: None,
|
||||
model: model::Model {
|
||||
text: model::Text {
|
||||
hd_audio: fl!("sound-hd-audio"),
|
||||
usb_audio: fl!("sound-usb-audio"),
|
||||
},
|
||||
..model::Model::default()
|
||||
},
|
||||
sound_config: None,
|
||||
amplification_sink: false,
|
||||
amplification_source: false,
|
||||
|
|
@ -129,12 +129,15 @@ impl page::Page<crate::pages::Message> for Page {
|
|||
self.entity = entity;
|
||||
}
|
||||
|
||||
fn subscription(
|
||||
&self,
|
||||
_core: &cosmic::Core,
|
||||
) -> cosmic::iced::Subscription<crate::pages::Message> {
|
||||
cosmic::iced::Subscription::run(subscription::watch)
|
||||
.map(|message| Message::Subscription(message).into())
|
||||
fn subscription(&self, _core: &cosmic::Core) -> iced::Subscription<crate::pages::Message> {
|
||||
iced::Subscription::run(|| {
|
||||
iced::stream::channel(
|
||||
1,
|
||||
move |emitter: futures::channel::mpsc::Sender<crate::pages::Message>| async move {
|
||||
cosmic_settings_sound::subscribe(emitter, |m| Message::Model(m).into()).await
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn on_leave(&mut self) -> Task<crate::pages::Message> {
|
||||
|
|
@ -161,53 +164,96 @@ impl page::AutoBind<crate::pages::Message> for Page {
|
|||
|
||||
impl Page {
|
||||
pub fn update(&mut self, message: Message) -> Task<crate::app::Message> {
|
||||
tracing::debug!(target: "sound", ?message, "update");
|
||||
match message {
|
||||
Message::Surface(a) => return cosmic::task::message(crate::app::Message::Surface(a)),
|
||||
|
||||
Message::Subscription(message) => {
|
||||
return self
|
||||
.model
|
||||
.update(message)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
Message::Model(cosmic_settings_sound::Message::Subscription(message)) => {
|
||||
self.model.update(message);
|
||||
}
|
||||
|
||||
Message::SetSinkBalance(balance) => {
|
||||
return self
|
||||
.model
|
||||
.set_sink_balance(balance)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
Message::Model(cosmic_settings_sound::Message::Client(client)) => {
|
||||
if let Some(client) = Arc::into_inner(client) {
|
||||
self.client = Some(Rc::new(RefCell::new(client)));
|
||||
self.model = model::Model {
|
||||
text: model::Text {
|
||||
hd_audio: fl!("sound-hd-audio"),
|
||||
usb_audio: fl!("sound-usb-audio"),
|
||||
},
|
||||
..model::Model::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetDefaultSink(pos) => {
|
||||
return self
|
||||
.model
|
||||
.set_default_sink(pos)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
if let Some(&pos) = self.model.sinks.sorted_index.get(pos)
|
||||
&& let Some(&node_id) = self.model.sinks.id.get(pos as usize)
|
||||
&& let Some(client) = self.client.as_mut()
|
||||
{
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.set_default(node_id, true).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetDefaultSource(pos) => {
|
||||
return self
|
||||
.model
|
||||
.set_default_source(pos)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
if let Some(&pos) = self.model.sources.sorted_index.get(pos)
|
||||
&& let Some(&node_id) = self.model.sources.id.get(pos as usize)
|
||||
&& let Some(client) = self.client.as_mut()
|
||||
{
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.set_default(node_id, true).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::ToggleSinkMute => self.model.toggle_sink_mute(),
|
||||
Message::ToggleSinkMute => {
|
||||
if let Some(ref mut client) = self.client {
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.sink_mute_toggle().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::ToggleSourceMute => self.model.toggle_source_mute(),
|
||||
Message::ToggleSourceMute => {
|
||||
if let Some(ref mut client) = self.client {
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.source_mute_toggle().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetSinkVolume(volume) => {
|
||||
return self
|
||||
.model
|
||||
.set_sink_volume(volume)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
if let Some(ref mut client) = self.client {
|
||||
self.model.active_sink.volume = volume;
|
||||
self.model.active_sink.volume_text = volume.to_string();
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.set_sink_volume(volume).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetSourceVolume(volume) => {
|
||||
return self
|
||||
.model
|
||||
.set_source_volume(volume)
|
||||
.map(|message| Message::Subscription(message).into());
|
||||
if let Some(ref mut client) = self.client {
|
||||
self.model.active_source.volume = volume;
|
||||
self.model.active_source.volume_text = volume.to_string();
|
||||
block_on(async {
|
||||
_ = client.borrow_mut().conn.set_source_volume(volume).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetSinkBalance(balance) => {
|
||||
if let Some((client, sink_id)) = self.client.as_ref().zip(self.model.default_sink) {
|
||||
self.model.active_sink.balance = Some(balance);
|
||||
block_on(async {
|
||||
_ = client
|
||||
.borrow_mut()
|
||||
.conn
|
||||
.set_node_volume_balance(sink_id, Some(balance))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Message::ToggleOverAmplificationSink(enabled) => {
|
||||
|
|
@ -229,18 +275,6 @@ impl Page {
|
|||
tracing::error!(?why, "Failed to save over amplification setting");
|
||||
}
|
||||
}
|
||||
|
||||
Message::SetProfile(object_id, index) => {
|
||||
self.model.set_profile(object_id, index, true);
|
||||
}
|
||||
|
||||
Message::Reload => {
|
||||
let mut model = subscription::Model::default();
|
||||
model.hd_audio_text = std::mem::take(&mut self.model.hd_audio_text);
|
||||
model.unplugged_text = std::mem::take(&mut self.model.unplugged_text);
|
||||
model.usb_audio_text = std::mem::take(&mut self.model.usb_audio_text);
|
||||
self.model = model;
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
|
|
@ -260,17 +294,17 @@ fn input() -> Section<crate::pages::Message> {
|
|||
.title(fl!("sound-input"))
|
||||
.descriptions(descriptions)
|
||||
.view::<Page>(move |_binder, page, section| {
|
||||
if page.model.sources().is_empty() {
|
||||
if page.model.sources.id.is_empty() {
|
||||
return widget::space().into();
|
||||
}
|
||||
|
||||
let slider = if page.amplification_source {
|
||||
widget::slider(0..=150, page.model.source_volume, |change| {
|
||||
widget::slider(0..=150, page.model.active_source.volume, |change| {
|
||||
Message::SetSourceVolume(change).into()
|
||||
})
|
||||
.breakpoints(&[100])
|
||||
} else {
|
||||
widget::slider(0..=100, page.model.source_volume, |change| {
|
||||
widget::slider(0..=100, page.model.active_source.volume, |change| {
|
||||
Message::SetSourceVolume(change).into()
|
||||
})
|
||||
}
|
||||
|
|
@ -281,23 +315,25 @@ fn input() -> Section<crate::pages::Message> {
|
|||
let volume_control = widget::row::with_capacity(4)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
widget::button::icon(widget::icon::from_name(if page.model.source_mute {
|
||||
"microphone-sensitivity-muted-symbolic"
|
||||
} else {
|
||||
"audio-input-microphone-symbolic"
|
||||
}))
|
||||
widget::button::icon(widget::icon::from_name(
|
||||
if page.model.active_source.mute {
|
||||
"microphone-sensitivity-muted-symbolic"
|
||||
} else {
|
||||
"audio-input-microphone-symbolic"
|
||||
},
|
||||
))
|
||||
.on_press(Message::ToggleSourceMute.into()),
|
||||
)
|
||||
.push(
|
||||
widget::text::body(&page.model.source_volume_text)
|
||||
widget::text::body(&page.model.active_source.volume_text)
|
||||
.width(Length::Fixed(22.0))
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.push(horizontal_space().width(8.))
|
||||
.push(slider);
|
||||
let devices = widget::dropdown::popup_dropdown(
|
||||
page.model.sources(),
|
||||
Some(page.model.active_source().unwrap_or(0)),
|
||||
&page.model.sources.sorted_display,
|
||||
page.model.sources.active(),
|
||||
Message::SetDefaultSource,
|
||||
window::Id::RESERVED,
|
||||
Message::Surface,
|
||||
|
|
@ -344,12 +380,12 @@ fn output() -> Section<crate::pages::Message> {
|
|||
.descriptions(descriptions)
|
||||
.view::<Page>(move |_binder, page, section| {
|
||||
let slider = if page.amplification_sink {
|
||||
widget::slider(0..=150, page.model.sink_volume, |change| {
|
||||
widget::slider(0..=150, page.model.active_sink.volume, |change| {
|
||||
Message::SetSinkVolume(change).into()
|
||||
})
|
||||
.breakpoints(&[100])
|
||||
} else {
|
||||
widget::slider(0..=100, page.model.sink_volume, |change| {
|
||||
widget::slider(0..=100, page.model.active_sink.volume, |change| {
|
||||
Message::SetSinkVolume(change).into()
|
||||
})
|
||||
}
|
||||
|
|
@ -360,7 +396,7 @@ fn output() -> Section<crate::pages::Message> {
|
|||
let volume_control = widget::row::with_capacity(4)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
widget::button::icon(if page.model.sink_mute {
|
||||
widget::button::icon(if page.model.active_sink.mute {
|
||||
widget::icon::from_name("audio-volume-muted-symbolic")
|
||||
} else {
|
||||
widget::icon::from_name("audio-volume-high-symbolic")
|
||||
|
|
@ -368,7 +404,7 @@ fn output() -> Section<crate::pages::Message> {
|
|||
.on_press(Message::ToggleSinkMute.into()),
|
||||
)
|
||||
.push(
|
||||
widget::text::body(&page.model.sink_volume_text)
|
||||
widget::text::body(&page.model.active_sink.volume_text)
|
||||
.width(Length::Fixed(22.0))
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
|
|
@ -376,8 +412,8 @@ fn output() -> Section<crate::pages::Message> {
|
|||
.push(slider);
|
||||
|
||||
let devices = widget::dropdown::popup_dropdown(
|
||||
page.model.sinks(),
|
||||
Some(page.model.active_sink().unwrap_or(0)),
|
||||
&page.model.sinks.sorted_display,
|
||||
page.model.sinks.active(),
|
||||
Message::SetDefaultSink,
|
||||
window::Id::RESERVED,
|
||||
Message::Surface,
|
||||
|
|
@ -410,12 +446,12 @@ fn output() -> Section<crate::pages::Message> {
|
|||
.push(horizontal_space().width(8.))
|
||||
.push(
|
||||
widget::slider(
|
||||
0..=200,
|
||||
(page.model.sink_balance.unwrap_or(1.0).max(0.) * 100.).round()
|
||||
as u32,
|
||||
0.0..=2.0,
|
||||
page.model.active_sink.balance.unwrap_or(1.0),
|
||||
|change| Message::SetSinkBalance(change).into(),
|
||||
)
|
||||
.breakpoints(&[100]),
|
||||
.step(0.01)
|
||||
.breakpoints(&[1.0]),
|
||||
)
|
||||
.push(horizontal_space().width(8.))
|
||||
.push(
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
[package]
|
||||
name = "cosmic-pipewire"
|
||||
version = "1.0.7"
|
||||
edition = "2024"
|
||||
repository = "https://github.com/pop-os/cosmic-settings"
|
||||
rust-version.workspace = true
|
||||
license = "MPL-2.0"
|
||||
publish = true
|
||||
|
||||
[dependencies]
|
||||
intmap = "3.1.3"
|
||||
libspa = "0.9.2"
|
||||
libspa-sys = "0.9.2"
|
||||
pipewire = "0.9"
|
||||
serde = { version = "1.0.228", features = ["derive"]}
|
||||
serde_json = "1.0.149"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[features]
|
||||
# Cache route port types
|
||||
route-port-type = []
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
### 1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
### 1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
### 1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
### 1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
### 1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
+ (a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
+ (b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
### 1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
### 1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
### 1.8. "License"
|
||||
means this document.
|
||||
|
||||
### 1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
### 1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
+ (a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
+ (b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
### 1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
### 1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
### 1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
### 1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
## 2. License Grants and Conditions
|
||||
|
||||
### 2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
+ (a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
+ (b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
### 2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
### 2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
+ (a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
+ (b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
+ (c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
### 2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
### 2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
### 2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
### 2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
## 3. Responsibilities
|
||||
|
||||
### 3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
### 3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
+ (a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
+ (b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
### 3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
### 3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
### 3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
## 4. Inability to Comply Due to Statute or Regulation
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
## 5. Termination
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
|
||||
## 6. Disclaimer of Warranty
|
||||
|
||||
**Covered Software is provided under this License on an "as is"
|
||||
basis, without warranty of any kind, either expressed, implied, or
|
||||
statutory, including, without limitation, warranties that the
|
||||
Covered Software is free of defects, merchantable, fit for a
|
||||
particular purpose or non-infringing. The entire risk as to the
|
||||
quality and performance of the Covered Software is with You.
|
||||
Should any Covered Software prove defective in any respect, You
|
||||
(not any Contributor) assume the cost of any necessary servicing,
|
||||
repair, or correction. This disclaimer of warranty constitutes an
|
||||
essential part of this License. No use of any Covered Software is
|
||||
authorized under this License except under this disclaimer.**
|
||||
|
||||
|
||||
#7. Limitation of Liability
|
||||
|
||||
**Under no circumstances and under no legal theory, whether tort
|
||||
(including negligence), contract, or otherwise, shall any
|
||||
Contributor, or anyone who distributes Covered Software as
|
||||
permitted above, be liable to You for any direct, indirect,
|
||||
special, incidental, or consequential damages of any character
|
||||
including, without limitation, damages for lost profits, loss of
|
||||
goodwill, work stoppage, computer failure or malfunction, or any
|
||||
and all other commercial damages or losses, even if such party
|
||||
shall have been informed of the possibility of such damages. This
|
||||
limitation of liability shall not apply to liability for death or
|
||||
personal injury resulting from such party's negligence to the
|
||||
extent applicable law prohibits such limitation. Some
|
||||
jurisdictions do not allow the exclusion or limitation of
|
||||
incidental or consequential damages, so this exclusion and
|
||||
limitation may not apply to You.**
|
||||
|
||||
|
||||
## 8. Litigation
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
## 9. Miscellaneous
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
## 10. Versions of the License
|
||||
|
||||
### 10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
### 10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
### 10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
### 10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
## Exhibit A - Source Code Form License Notice
|
||||
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
## Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// Copyright 2025 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
use pipewire::device::DeviceInfoRef;
|
||||
|
||||
/// Device information
|
||||
#[must_use]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Device {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// Attains process info from a pipewire info node.
|
||||
#[must_use]
|
||||
pub fn from_device(info: &DeviceInfoRef) -> Option<Self> {
|
||||
let props = info.props()?;
|
||||
|
||||
let device = Device {
|
||||
id: props.get("object.id")?.parse::<u32>().ok()?,
|
||||
name: props.get("device.description")?.to_owned(),
|
||||
};
|
||||
|
||||
Some(device)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
// Copyright 2025 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
//! Currently unusued
|
||||
|
||||
use crate::pipewire::Direction;
|
||||
use pipewire::port::PortInfoRef;
|
||||
|
||||
#[must_use]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Port {
|
||||
pub node_id: u32,
|
||||
pub object_id: u32,
|
||||
pub port_id: u32,
|
||||
pub audio_channel: String,
|
||||
pub format_dsp: String,
|
||||
pub object_path: String,
|
||||
pub port_direction: Direction,
|
||||
pub port_group: String,
|
||||
pub port_name: String,
|
||||
pub port_alias: String,
|
||||
pub port_physical: bool,
|
||||
pub port_terminal: bool,
|
||||
pub port_monitor: bool,
|
||||
}
|
||||
|
||||
impl Port {
|
||||
/// Attains process info from a pipewire info port.
|
||||
#[must_use]
|
||||
pub fn from_port(info: &PortInfoRef) -> Option<Self> {
|
||||
let props = info.props()?;
|
||||
let object_id = info.id();
|
||||
let port_direction = match info.direction() {
|
||||
libspa::utils::Direction::Input => Direction::Input,
|
||||
libspa::utils::Direction::Output => Direction::Output,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut node_id = 0;
|
||||
let mut port_id = 0;
|
||||
let mut port_monitor = false;
|
||||
let mut port_physical = false;
|
||||
let mut port_terminal = false;
|
||||
|
||||
let mut audio_channel = String::new();
|
||||
let mut format_dsp = String::new();
|
||||
let mut object_path = String::new();
|
||||
let mut port_alias = String::new();
|
||||
let mut port_group = String::new();
|
||||
let mut port_name = String::new();
|
||||
|
||||
for (entry, value) in props.iter() {
|
||||
match entry {
|
||||
// 32 bit float mono audio
|
||||
"format.dsp" => format_dsp = value.to_owned(),
|
||||
// FR
|
||||
"audio.channel" => audio_channel = value.to_owned(),
|
||||
// playback
|
||||
"port.group" => port_group = value.to_owned(),
|
||||
// 1
|
||||
"port.id" => port_id = value.parse::<u32>().ok()?,
|
||||
// false
|
||||
"port.monitor" => port_monitor = value == "true",
|
||||
// true
|
||||
"port.physical" => port_physical = value == "true",
|
||||
// true
|
||||
"port.terminal" => port_terminal = value == "true",
|
||||
// alsa:acp:Device:3:playback:playback_1
|
||||
"object.path" => object_path = value.to_owned(),
|
||||
// playback_FR
|
||||
"port.name" => port_name = value.to_owned(),
|
||||
// MosArt USB Audio Device:playback_FR
|
||||
"port.alias" => port_alias = value.to_owned(),
|
||||
// 59
|
||||
"node.id" => node_id = value.parse::<u32>().ok()?,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
let port = Port {
|
||||
format_dsp,
|
||||
audio_channel,
|
||||
port_id,
|
||||
port_direction,
|
||||
object_path,
|
||||
port_name,
|
||||
port_alias,
|
||||
port_group,
|
||||
port_monitor,
|
||||
port_physical,
|
||||
port_terminal,
|
||||
node_id,
|
||||
object_id,
|
||||
};
|
||||
|
||||
Some(port)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
// Copyright 2025 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
use libspa::pod::Pod;
|
||||
use std::ffi::CStr;
|
||||
|
||||
/// Read a `Pod`'s string if it contains a string.
|
||||
pub fn string_from_pod(pod: &Pod) -> Option<String> {
|
||||
if !pod.is_string() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cstr = std::ptr::null();
|
||||
|
||||
unsafe {
|
||||
// SAFETY: Pod is checked to be a string beforehand
|
||||
if libspa_sys::spa_pod_get_string(pod.as_raw_ptr(), &mut cstr) == 0 && !cstr.is_null() {
|
||||
return Some(String::from_utf8_lossy(CStr::from_ptr(cstr).to_bytes()).into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// SAFETY: Must be absolutely certain that the array is a compatible array.
|
||||
pub unsafe fn array_from_pod<CType: Copy>(pod: &Pod) -> Option<Vec<CType>> {
|
||||
if !pod.is_array() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut len = 0;
|
||||
|
||||
unsafe {
|
||||
let array: *mut CType = libspa_sys::spa_pod_get_array(pod.as_raw_ptr(), &mut len).cast();
|
||||
|
||||
if array.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(std::slice::from_raw_parts(array, len as usize).to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(u32)]
|
||||
#[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
UNKNOWN = 0, // unspecified
|
||||
NA, // N/A, silent
|
||||
MONO, // mono stream
|
||||
FL, // front left
|
||||
FR, // front right
|
||||
FC, // front center
|
||||
LFE, // LFE
|
||||
SL, // side left
|
||||
SR, // side right
|
||||
FLC, // front left center
|
||||
FRC, // front right center
|
||||
RC, // rear center
|
||||
RL, // rear left
|
||||
RR, // rear right
|
||||
TC, // top center
|
||||
TFL, // top front left
|
||||
TFC, // top front center
|
||||
TFR, // top front right
|
||||
TRL, // top rear left
|
||||
TRC, // top rear center
|
||||
TRR, // top rear right
|
||||
RLC, // rear left center
|
||||
RRC, // rear right center
|
||||
FLW, // front left wide
|
||||
FRW, // front right wide
|
||||
LFE2, // LFE 2
|
||||
FLH, // front left high
|
||||
FCH, // front center high
|
||||
FRH, // front right high
|
||||
TFLC, // top front left center
|
||||
TFRC, // top front right center
|
||||
TSL, // top side left
|
||||
TSR, // top side right
|
||||
LLFE, // left LFE
|
||||
RLFE, // right LFE
|
||||
BC, // bottom center
|
||||
BLC, // bottom left center
|
||||
BRC = 37, // bottom right center
|
||||
AUX0 = 4096, // aux channels
|
||||
AUX1,
|
||||
AUX2,
|
||||
AUX3,
|
||||
AUX4,
|
||||
AUX5,
|
||||
AUX6,
|
||||
AUX7,
|
||||
AUX8,
|
||||
AUX9,
|
||||
AUX10,
|
||||
AUX11,
|
||||
AUX12,
|
||||
AUX13,
|
||||
AUX14,
|
||||
AUX15,
|
||||
AUX16,
|
||||
AUX17,
|
||||
AUX18,
|
||||
AUX19,
|
||||
AUX20,
|
||||
AUX21,
|
||||
AUX22,
|
||||
AUX23,
|
||||
AUX24,
|
||||
AUX25,
|
||||
AUX26,
|
||||
AUX27,
|
||||
AUX28,
|
||||
AUX29,
|
||||
AUX30,
|
||||
AUX31,
|
||||
AUX32,
|
||||
AUX33,
|
||||
AUX34,
|
||||
AUX35,
|
||||
AUX36,
|
||||
AUX37,
|
||||
AUX38,
|
||||
AUX39,
|
||||
AUX40,
|
||||
AUX41,
|
||||
AUX42,
|
||||
AUX43,
|
||||
AUX44,
|
||||
AUX45,
|
||||
AUX46,
|
||||
AUX47,
|
||||
AUX48,
|
||||
AUX49,
|
||||
AUX50,
|
||||
AUX51,
|
||||
AUX52,
|
||||
AUX53,
|
||||
AUX54,
|
||||
AUX55,
|
||||
AUX56,
|
||||
AUX57,
|
||||
AUX58,
|
||||
AUX59,
|
||||
AUX60,
|
||||
AUX61,
|
||||
AUX62,
|
||||
AUX63 = 4159,
|
||||
}
|
||||
3
debian/control
vendored
3
debian/control
vendored
|
|
@ -12,11 +12,9 @@ Build-Depends:
|
|||
libfontconfig-dev,
|
||||
libfreetype-dev,
|
||||
libinput-dev,
|
||||
libpipewire-0.3-dev,
|
||||
libudev-dev,
|
||||
libwayland-dev,
|
||||
libxkbcommon-dev,
|
||||
mold,
|
||||
pkg-config,
|
||||
rust-all,
|
||||
Standards-Version: 4.6.2
|
||||
|
|
@ -29,6 +27,7 @@ Depends:
|
|||
${shlibs:Depends},
|
||||
accountsservice,
|
||||
cosmic-randr,
|
||||
cosmic-settings-daemon,
|
||||
gettext,
|
||||
iso-codes,
|
||||
network-manager-gnome,
|
||||
|
|
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
[package]
|
||||
name = "cosmic-settings-pulse-subscription"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
libpulse-binding = { version = "2.30.1" }
|
||||
rustix = { version = "1.1.3", features = ["pipe"] }
|
||||
iced_futures = { git = "https://github.com/pop-os/libcosmic" }
|
||||
futures = "0.3.32"
|
||||
log = "0.4.27"
|
||||
|
|
@ -1,744 +0,0 @@
|
|||
// Copyright 2024 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Make sure not to fail if pulse not found, and reconnect?
|
||||
// change to device shouldn't send osd?
|
||||
|
||||
use futures::SinkExt;
|
||||
use futures::executor::block_on;
|
||||
use iced_futures::{Subscription, stream};
|
||||
use libpulse_binding::callbacks::ListResult;
|
||||
use libpulse_binding::channelmap::Map;
|
||||
use libpulse_binding::context::introspect::{
|
||||
CardInfo, CardProfileInfo, Introspector, ServerInfo, SinkInfo, SourceInfo,
|
||||
};
|
||||
use libpulse_binding::context::subscribe::{Facility, InterestMaskSet, Operation};
|
||||
use libpulse_binding::context::{Context, FlagSet, State};
|
||||
use libpulse_binding::def::{PortAvailable, Retval};
|
||||
use libpulse_binding::mainloop::api::MainloopApi;
|
||||
use libpulse_binding::mainloop::events::io::IoEventInternal;
|
||||
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
|
||||
use libpulse_binding::volume::{ChannelVolumes, Volume};
|
||||
use std::borrow::Cow;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::convert::Infallible;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
|
||||
use std::os::raw::c_void;
|
||||
use std::rc::Rc;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub fn subscription() -> iced_futures::Subscription<Event> {
|
||||
Subscription::run_with("pulse", |_| {
|
||||
stream::channel(20, |sender| async {
|
||||
std::thread::spawn(move || thread(sender));
|
||||
futures::future::pending().await
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread(sender: futures::channel::mpsc::Sender<Event>) {
|
||||
let Some(mut main_loop) = Mainloop::new() else {
|
||||
log::error!("Failed to create PA main loop");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(mut context) = Context::new(&main_loop, "cosmic-osd") else {
|
||||
log::error!("Failed to create PA context");
|
||||
return;
|
||||
};
|
||||
|
||||
let data = Rc::new(Data {
|
||||
main_loop: RefCell::new(Mainloop {
|
||||
_inner: Rc::clone(&main_loop._inner),
|
||||
}),
|
||||
introspector: context.introspect(),
|
||||
sink_volume: Cell::new(None),
|
||||
sink_mute: Cell::new(None),
|
||||
source_volume: Cell::new(None),
|
||||
source_mute: Cell::new(None),
|
||||
default_sink_name: RefCell::new(None),
|
||||
default_source_name: RefCell::new(None),
|
||||
sender: RefCell::new(sender.clone()),
|
||||
});
|
||||
|
||||
let data_clone = data.clone();
|
||||
context.set_subscribe_callback(Some(Box::new(move |facility, operation, index| {
|
||||
data_clone.subscribe_cb(facility.unwrap(), operation, index);
|
||||
})));
|
||||
|
||||
let _ = context.connect(None, FlagSet::NOFAIL, None);
|
||||
|
||||
loop {
|
||||
if sender.is_closed() {
|
||||
return;
|
||||
}
|
||||
|
||||
match main_loop.iterate(false) {
|
||||
IterateResult::Success(_) => {}
|
||||
IterateResult::Err(_e) => {
|
||||
return;
|
||||
}
|
||||
IterateResult::Quit(_e) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if context.get_state() == State::Ready {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Inspect all available cards on startup
|
||||
data.introspector.get_card_info_list({
|
||||
let data_weak = Rc::downgrade(&data);
|
||||
move |card_info_res| {
|
||||
if let Some(data) = data_weak.upgrade() {
|
||||
data.card_info_cb(card_info_res)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
data.get_server_info();
|
||||
context.subscribe(
|
||||
InterestMaskSet::SERVER | InterestMaskSet::SINK | InterestMaskSet::SOURCE,
|
||||
|_| {},
|
||||
);
|
||||
|
||||
if let Err((err, retval)) = main_loop.run() {
|
||||
log::error!("PA main loop returned {:?}, error {}", retval, err);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Event {
|
||||
Balance(Option<f32>),
|
||||
CardInfo(Card),
|
||||
DefaultSink(String),
|
||||
DefaultSource(String),
|
||||
SinkVolume(u32),
|
||||
Channels(PulseChannels),
|
||||
SinkMute(bool),
|
||||
SourceVolume(u32),
|
||||
SourceMute(bool),
|
||||
}
|
||||
|
||||
enum Request {
|
||||
Volume(u32, f32),
|
||||
Balance(u32, f32),
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PulseChannels {
|
||||
tx: mpsc::Sender<Request>,
|
||||
pipe_tx: std::fs::File,
|
||||
index: u32,
|
||||
}
|
||||
|
||||
impl Clone for PulseChannels {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
tx: self.tx.clone(),
|
||||
pipe_tx: self
|
||||
.pipe_tx
|
||||
.try_clone()
|
||||
.expect("failed to clone PulseChannels pipe writer"),
|
||||
index: self.index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data used by the [`handle_balance_io_new`] callback.
|
||||
struct HandleBalanceData(
|
||||
Context,
|
||||
ChannelVolumes,
|
||||
Map,
|
||||
std::sync::mpsc::Receiver<Request>,
|
||||
);
|
||||
|
||||
/// Callback for creating an IO event source [`MainloopApi::io_new`].
|
||||
extern "C" fn handle_balance_io_new(
|
||||
api: *const MainloopApi,
|
||||
event: *mut IoEventInternal,
|
||||
reader_fd: RawFd,
|
||||
_flags: libpulse_binding::mainloop::events::io::FlagSet,
|
||||
data: *mut c_void,
|
||||
) {
|
||||
// Take ownership of the data and borrow its contents.
|
||||
let mut data = unsafe { Box::<HandleBalanceData>::from_raw(data as _) };
|
||||
let HandleBalanceData(ctx, volumes, map, rx) = data.as_mut();
|
||||
|
||||
// Return early if the context is not ready, and give the data back.
|
||||
if ctx.get_state() != State::Ready {
|
||||
let _ = Box::leak(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the first byte cannot be read, destroy this event source with its reader and data.
|
||||
let mut buf = [0u8; 1];
|
||||
let mut reader = unsafe { std::fs::File::from_raw_fd(reader_fd) };
|
||||
if reader.read_exact(&mut buf).is_err() {
|
||||
(unsafe { &*api })
|
||||
.io_free
|
||||
.as_ref()
|
||||
.expect("io_free function is missing")(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Give ownership of the reader back.
|
||||
_ = reader.into_raw_fd();
|
||||
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
match req {
|
||||
Request::Volume(index, volume_scale) => {
|
||||
let mut intro = ctx.introspect();
|
||||
|
||||
let new_scale = Volume((volume_scale * Volume::NORMAL.0 as f32).round() as u32);
|
||||
|
||||
if let Some(v) = volumes.scale(new_scale) {
|
||||
_ = intro.set_sink_volume_by_index(
|
||||
index,
|
||||
v,
|
||||
Some(Box::new(|success| {
|
||||
if !success {
|
||||
log::error!("Failed to set sink balance");
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
Request::Balance(index, new_balance) => {
|
||||
if map.can_balance() {
|
||||
if let Some(v) = volumes.set_balance(&map, new_balance) {
|
||||
let mut intro = ctx.introspect();
|
||||
|
||||
_ = intro.set_sink_volume_by_index(
|
||||
index,
|
||||
v,
|
||||
Some(Box::new(|success| {
|
||||
if !success {
|
||||
log::error!("Failed to set sink balance");
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Request::Quit => unsafe { &*api }
|
||||
.quit
|
||||
.as_ref()
|
||||
.expect("quit function missing")(api, 0),
|
||||
}
|
||||
}
|
||||
|
||||
let _ = Box::leak(data);
|
||||
}
|
||||
|
||||
impl PulseChannels {
|
||||
fn new(
|
||||
volumes: ChannelVolumes,
|
||||
map: Map,
|
||||
api: &MainloopApi,
|
||||
index: u32,
|
||||
ctx: Context,
|
||||
) -> PulseChannels {
|
||||
let (reader, writer) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)
|
||||
.expect("failed to crate pipe");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
|
||||
// Create IO event source object for handling speaker balance.
|
||||
let event_source = api.io_new.as_ref().unwrap()(
|
||||
api as *const _,
|
||||
reader.into_raw_fd(),
|
||||
libpulse_binding::mainloop::events::io::FlagSet::INPUT,
|
||||
Some(handle_balance_io_new),
|
||||
Box::into_raw(Box::new(HandleBalanceData(ctx, volumes, map, rx))) as *mut c_void,
|
||||
);
|
||||
|
||||
if let Some(enable) = api.io_enable.as_ref() {
|
||||
enable(
|
||||
event_source,
|
||||
libpulse_binding::mainloop::events::io::FlagSet::INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
tx,
|
||||
pipe_tx: std::fs::File::from(writer),
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the active index.
|
||||
#[inline]
|
||||
pub fn set_index(&mut self, index: u32) {
|
||||
self.index = index;
|
||||
}
|
||||
|
||||
/// Set the speaker balance of the active sink.
|
||||
pub fn set_balance(&mut self, balance: f32) {
|
||||
if let Err(err) = self.tx.send(Request::Balance(self.index, balance)) {
|
||||
log::error!("Failed to send new balance to channel");
|
||||
} else {
|
||||
self.pipe_tx
|
||||
.write_all(&[1])
|
||||
.expect("PulseChannels pipe write failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the volume of the active sink.
|
||||
pub fn set_volume(&mut self, volume: f32) {
|
||||
if let Err(err) = self.tx.send(Request::Volume(self.index, volume)) {
|
||||
log::error!("Failed to send new volume to channel");
|
||||
} else {
|
||||
self.pipe_tx
|
||||
.write_all(&[1])
|
||||
.expect("PulseChannels pipe write failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the pulse thread to quit.
|
||||
pub fn quit(mut self) {
|
||||
_ = self.tx.send(Request::Quit);
|
||||
_ = self.pipe_tx.write_all(&[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct Card {
|
||||
pub object_id: u32,
|
||||
pub name: String,
|
||||
pub product_name: String,
|
||||
pub variant: DeviceVariant,
|
||||
pub ports: Vec<CardPort>,
|
||||
pub profiles: Vec<CardProfile>,
|
||||
pub active_profile: Option<CardProfile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct CardPort {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub direction: Direction,
|
||||
pub port_type: PortType,
|
||||
pub profile_port: u32,
|
||||
pub priority: u32,
|
||||
pub profiles: Vec<CardProfile>,
|
||||
pub availability: Availability,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub enum Availability {
|
||||
Unknown,
|
||||
No,
|
||||
Yes,
|
||||
}
|
||||
|
||||
impl From<PortAvailable> for Availability {
|
||||
fn from(pa: PortAvailable) -> Self {
|
||||
match pa {
|
||||
PortAvailable::Unknown => Availability::Unknown,
|
||||
PortAvailable::No => Availability::No,
|
||||
PortAvailable::Yes => Availability::Yes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub struct CardProfile {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub available: bool,
|
||||
pub n_sinks: u32,
|
||||
pub n_sources: u32,
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub enum DeviceVariant {
|
||||
Alsa { alsa_card: u32 },
|
||||
Bluez5 { address: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub enum Direction {
|
||||
Input,
|
||||
Output,
|
||||
Both,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Hash, Eq, PartialEq)]
|
||||
pub enum PortType {
|
||||
Mic,
|
||||
Speaker,
|
||||
Headphones,
|
||||
Headset,
|
||||
Digital,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FromStr for PortType {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"mic" => Ok(PortType::Mic),
|
||||
"speaker" => Ok(PortType::Speaker),
|
||||
"headphones" => Ok(PortType::Headphones),
|
||||
"headset" => Ok(PortType::Headset),
|
||||
"digital" => Ok(PortType::Digital),
|
||||
_ => Ok(PortType::Unknown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Data {
|
||||
main_loop: RefCell<Mainloop>,
|
||||
default_sink_name: RefCell<Option<String>>,
|
||||
default_source_name: RefCell<Option<String>>,
|
||||
sink_volume: Cell<Option<u32>>,
|
||||
sink_mute: Cell<Option<bool>>,
|
||||
source_volume: Cell<Option<u32>>,
|
||||
source_mute: Cell<Option<bool>>,
|
||||
introspector: Introspector,
|
||||
sender: RefCell<futures::channel::mpsc::Sender<Event>>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
fn card_info_cb(self: &Rc<Self>, card_info: ListResult<&CardInfo>) {
|
||||
if let ListResult::Item(card_info) = card_info {
|
||||
let Some(object_id) = card_info
|
||||
.proplist
|
||||
.get_str("object.id")
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let variant = if let Some(alsa_card) = card_info
|
||||
.proplist
|
||||
.get_str("alsa.card")
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
{
|
||||
DeviceVariant::Alsa { alsa_card }
|
||||
} else if let Some(address) = card_info.proplist.get_str("api.bluez5.address") {
|
||||
DeviceVariant::Bluez5 { address }
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let card = Card {
|
||||
name: card_info
|
||||
.name
|
||||
.as_ref()
|
||||
.map(Cow::to_string)
|
||||
.unwrap_or_default(),
|
||||
product_name: card_info
|
||||
.proplist
|
||||
.get_str("device.product.name")
|
||||
.unwrap_or_default(),
|
||||
object_id,
|
||||
variant,
|
||||
ports: card_info
|
||||
.ports
|
||||
.iter()
|
||||
.map(|port| CardPort {
|
||||
name: port.name.as_ref().map(Cow::to_string).unwrap_or_default(),
|
||||
description: port
|
||||
.description
|
||||
.as_ref()
|
||||
.map(Cow::to_string)
|
||||
.unwrap_or_default(),
|
||||
direction: match port.direction.bits() {
|
||||
x if x == libpulse_binding::direction::FlagSet::INPUT.bits() => {
|
||||
Direction::Input
|
||||
}
|
||||
x if x == libpulse_binding::direction::FlagSet::OUTPUT.bits() => {
|
||||
Direction::Output
|
||||
}
|
||||
_ => Direction::Both,
|
||||
},
|
||||
port_type: port
|
||||
.proplist
|
||||
.get_str("port.type")
|
||||
.as_deref()
|
||||
.map(|s| PortType::from_str(s).unwrap())
|
||||
.unwrap_or_default(),
|
||||
profile_port: port
|
||||
.proplist
|
||||
.get_str("card.profile.port")
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(0),
|
||||
priority: port.priority,
|
||||
profiles: collect_profiles(&port.profiles),
|
||||
availability: port.available.into(),
|
||||
})
|
||||
.collect(),
|
||||
profiles: collect_profiles(&card_info.profiles),
|
||||
active_profile: card_info.active_profile.as_deref().map(CardProfile::from),
|
||||
};
|
||||
|
||||
if block_on(self.sender.borrow_mut().send(Event::CardInfo(card))).is_err() {
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_info_cb(self: &Rc<Self>, server_info: &ServerInfo) {
|
||||
let new_default_sink_name = server_info
|
||||
.default_sink_name
|
||||
.as_ref()
|
||||
.map(|x| x.clone().into_owned());
|
||||
let mut default_sink_name = self.default_sink_name.borrow_mut();
|
||||
if new_default_sink_name != *default_sink_name {
|
||||
if let Some(name) = &new_default_sink_name {
|
||||
_ = block_on(
|
||||
self.sender
|
||||
.borrow_mut()
|
||||
.send(Event::DefaultSink(name.clone())),
|
||||
);
|
||||
self.get_sink_info_by_name(name);
|
||||
}
|
||||
*default_sink_name = new_default_sink_name;
|
||||
}
|
||||
|
||||
let new_default_source_name = server_info
|
||||
.default_source_name
|
||||
.as_ref()
|
||||
.map(|x| x.clone().into_owned());
|
||||
let mut default_source_name = self.default_source_name.borrow_mut();
|
||||
if new_default_source_name != *default_source_name {
|
||||
if let Some(name) = &new_default_source_name {
|
||||
_ = block_on(
|
||||
self.sender
|
||||
.borrow_mut()
|
||||
.send(Event::DefaultSource(name.clone())),
|
||||
);
|
||||
self.get_source_info_by_name(name);
|
||||
}
|
||||
*default_source_name = new_default_source_name;
|
||||
}
|
||||
}
|
||||
|
||||
fn get_server_info(self: &Rc<Self>) {
|
||||
let data = self.clone();
|
||||
self.introspector
|
||||
.get_server_info(move |server_info| data.server_info_cb(server_info));
|
||||
}
|
||||
|
||||
fn sink_info_cb(&self, sink_info_res: ListResult<&SinkInfo>) {
|
||||
if let ListResult::Item(sink_info) = sink_info_res {
|
||||
if sink_info.name.as_deref() != self.default_sink_name.borrow().as_deref() {
|
||||
return;
|
||||
}
|
||||
let balance = (sink_info.channel_map.can_balance()
|
||||
&& sink_info.base_volume.is_normal())
|
||||
.then(|| sink_info.volume.get_balance(&sink_info.channel_map));
|
||||
|
||||
let volume = sink_info.volume.max().0 / (Volume::NORMAL.0 / 100);
|
||||
if self.sink_mute.get() != Some(sink_info.mute) {
|
||||
self.sink_mute.set(Some(sink_info.mute));
|
||||
if block_on(
|
||||
self.sender
|
||||
.borrow_mut()
|
||||
.send(Event::SinkMute(sink_info.mute)),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
if self.sink_volume.get() != Some(volume) {
|
||||
self.sink_volume.set(Some(volume));
|
||||
if block_on(self.sender.borrow_mut().send(Event::SinkVolume(volume))).is_err() {
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
if block_on(self.sender.borrow_mut().send(Event::Balance(balance))).is_err() {
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
let mut main_loop = self.main_loop.borrow_mut();
|
||||
let api = main_loop.get_api();
|
||||
if let Some(mut ctx) = Context::new(&*main_loop, "balance") {
|
||||
let _ = ctx.connect(None, FlagSet::NOFAIL, None);
|
||||
|
||||
let channels = PulseChannels::new(
|
||||
sink_info.volume,
|
||||
sink_info.channel_map,
|
||||
api,
|
||||
sink_info.index,
|
||||
ctx,
|
||||
);
|
||||
|
||||
if block_on(self.sender.borrow_mut().send(Event::Channels(channels))).is_err() {
|
||||
main_loop.quit(Retval(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source_info_cb(&self, source_info_res: ListResult<&SourceInfo>) {
|
||||
if let ListResult::Item(source_info) = source_info_res {
|
||||
if source_info.name.as_deref() != self.default_source_name.borrow().as_deref() {
|
||||
return;
|
||||
}
|
||||
let volume = source_info.volume.max().0 / (Volume::NORMAL.0 / 100);
|
||||
if self.source_mute.get() != Some(source_info.mute) {
|
||||
self.source_mute.set(Some(source_info.mute));
|
||||
if block_on(
|
||||
self.sender
|
||||
.borrow_mut()
|
||||
.send(Event::SourceMute(source_info.mute)),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
if self.source_volume.get() != Some(volume) {
|
||||
self.source_volume.set(Some(volume));
|
||||
if block_on(self.sender.borrow_mut().send(Event::SourceVolume(volume))).is_err() {
|
||||
self.main_loop.borrow_mut().quit(Retval(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_card_info_by_index(self: &Rc<Self>, index: u32) {
|
||||
let data = self.clone();
|
||||
self.introspector
|
||||
.get_card_info_by_index(index, move |card_info_res| {
|
||||
data.card_info_cb(card_info_res);
|
||||
});
|
||||
}
|
||||
|
||||
fn get_sink_info_by_index(self: &Rc<Self>, index: u32) {
|
||||
let data = self.clone();
|
||||
self.introspector.get_sink_info_by_index(
|
||||
index,
|
||||
move |sink_info_res: ListResult<&SinkInfo<'_>>| {
|
||||
if let ListResult::Item(ref info) = sink_info_res {
|
||||
if let Some(card_index) = info.card {
|
||||
let data_clone = data.clone();
|
||||
data.introspector.get_card_info_by_index(
|
||||
card_index,
|
||||
move |card_info_res| {
|
||||
data_clone.card_info_cb(card_info_res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
data.sink_info_cb(sink_info_res);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_sink_info_by_name(self: &Rc<Self>, name: &str) {
|
||||
let data = self.clone();
|
||||
self.introspector
|
||||
.get_sink_info_by_name(name, move |sink_info_res| {
|
||||
if let ListResult::Item(ref info) = sink_info_res {
|
||||
if let Some(card_index) = info.card {
|
||||
let data_clone = data.clone();
|
||||
data.introspector.get_card_info_by_index(
|
||||
card_index,
|
||||
move |card_info_res| {
|
||||
data_clone.card_info_cb(card_info_res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
data.sink_info_cb(sink_info_res);
|
||||
});
|
||||
}
|
||||
|
||||
fn get_source_info_by_index(self: &Rc<Self>, index: u32) {
|
||||
let data = self.clone();
|
||||
self.introspector
|
||||
.get_source_info_by_index(index, move |source_info_res| {
|
||||
if let ListResult::Item(ref info) = source_info_res {
|
||||
if let Some(card_index) = info.card {
|
||||
let data_clone = data.clone();
|
||||
data.introspector.get_card_info_by_index(
|
||||
card_index,
|
||||
move |card_info_res| {
|
||||
data_clone.card_info_cb(card_info_res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
data.source_info_cb(source_info_res);
|
||||
});
|
||||
}
|
||||
|
||||
fn get_source_info_by_name(self: &Rc<Self>, name: &str) {
|
||||
let data = self.clone();
|
||||
self.introspector
|
||||
.get_source_info_by_name(name, move |source_info_res| {
|
||||
if let ListResult::Item(ref info) = source_info_res {
|
||||
if let Some(card_index) = info.card {
|
||||
let data_clone = data.clone();
|
||||
data.introspector.get_card_info_by_index(
|
||||
card_index,
|
||||
move |card_info_res| {
|
||||
data_clone.card_info_cb(card_info_res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
data.source_info_cb(source_info_res);
|
||||
});
|
||||
}
|
||||
|
||||
fn subscribe_cb(
|
||||
self: &Rc<Self>,
|
||||
facility: Facility,
|
||||
_operation: Option<Operation>,
|
||||
index: u32,
|
||||
) {
|
||||
match facility {
|
||||
Facility::Server => {
|
||||
self.get_server_info();
|
||||
}
|
||||
Facility::Sink => {
|
||||
self.get_sink_info_by_index(index);
|
||||
}
|
||||
Facility::Source => {
|
||||
self.get_source_info_by_index(index);
|
||||
}
|
||||
Facility::Card => {
|
||||
self.get_card_info_by_index(index);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_profiles(profiles: &[CardProfileInfo]) -> Vec<CardProfile> {
|
||||
profiles.iter().map(CardProfile::from).collect()
|
||||
}
|
||||
|
||||
impl From<&CardProfileInfo<'_>> for CardProfile {
|
||||
fn from(profile: &CardProfileInfo) -> Self {
|
||||
CardProfile {
|
||||
name: profile
|
||||
.name
|
||||
.as_ref()
|
||||
.map(Cow::to_string)
|
||||
.unwrap_or_default(),
|
||||
description: profile
|
||||
.description
|
||||
.as_ref()
|
||||
.map(Cow::to_string)
|
||||
.unwrap_or_default(),
|
||||
available: profile.available,
|
||||
n_sinks: profile.n_sinks,
|
||||
n_sources: profile.n_sources,
|
||||
priority: profile.priority,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
[package]
|
||||
name = "cosmic-settings-sound-subscription"
|
||||
version = "1.0.7"
|
||||
edition = "2024"
|
||||
rust-version.workspace = true
|
||||
license = "MPL-2.0"
|
||||
publish = true
|
||||
|
||||
[dependencies]
|
||||
cosmic-pipewire = { path = "../../crates/cosmic-pipewire" }
|
||||
futures = "0.3.32"
|
||||
intmap = "3.1.3"
|
||||
libcosmic = { git = "https://github.com/pop-os/libcosmic", default-features = false }
|
||||
numtoa = "1.0.0-alpha1"
|
||||
rustix = "1.1.3"
|
||||
tokio = { version = "1.49.0", features = ["process", "rt", "time"] }
|
||||
tracing = { version = "0.1.44", default-features = false }
|
||||
|
||||
[features]
|
||||
# Set profile on first load
|
||||
auto-profile-init = []
|
||||
|
|
@ -1,359 +0,0 @@
|
|||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
### 1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
### 1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
### 1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
### 1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
### 1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
+ (a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
+ (b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
### 1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
### 1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
### 1.8. "License"
|
||||
means this document.
|
||||
|
||||
### 1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
### 1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
+ (a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
+ (b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
### 1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
### 1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
### 1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
### 1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
## 2. License Grants and Conditions
|
||||
|
||||
### 2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
+ (a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
+ (b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
### 2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
### 2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
+ (a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
+ (b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
+ (c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
### 2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
### 2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
### 2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
### 2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
## 3. Responsibilities
|
||||
|
||||
### 3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
### 3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
+ (a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
+ (b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
### 3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
### 3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
### 3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
## 4. Inability to Comply Due to Statute or Regulation
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
## 5. Termination
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
|
||||
## 6. Disclaimer of Warranty
|
||||
|
||||
**Covered Software is provided under this License on an "as is"
|
||||
basis, without warranty of any kind, either expressed, implied, or
|
||||
statutory, including, without limitation, warranties that the
|
||||
Covered Software is free of defects, merchantable, fit for a
|
||||
particular purpose or non-infringing. The entire risk as to the
|
||||
quality and performance of the Covered Software is with You.
|
||||
Should any Covered Software prove defective in any respect, You
|
||||
(not any Contributor) assume the cost of any necessary servicing,
|
||||
repair, or correction. This disclaimer of warranty constitutes an
|
||||
essential part of this License. No use of any Covered Software is
|
||||
authorized under this License except under this disclaimer.**
|
||||
|
||||
|
||||
#7. Limitation of Liability
|
||||
|
||||
**Under no circumstances and under no legal theory, whether tort
|
||||
(including negligence), contract, or otherwise, shall any
|
||||
Contributor, or anyone who distributes Covered Software as
|
||||
permitted above, be liable to You for any direct, indirect,
|
||||
special, incidental, or consequential damages of any character
|
||||
including, without limitation, damages for lost profits, loss of
|
||||
goodwill, work stoppage, computer failure or malfunction, or any
|
||||
and all other commercial damages or losses, even if such party
|
||||
shall have been informed of the possibility of such damages. This
|
||||
limitation of liability shall not apply to liability for death or
|
||||
personal injury resulting from such party's negligence to the
|
||||
extent applicable law prohibits such limitation. Some
|
||||
jurisdictions do not allow the exclusion or limitation of
|
||||
incidental or consequential damages, so this exclusion and
|
||||
limitation may not apply to You.**
|
||||
|
||||
|
||||
## 8. Litigation
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
## 9. Miscellaneous
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
## 10. Versions of the License
|
||||
|
||||
### 10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
### 10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
### 10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
### 10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
## Exhibit A - Source Code Form License Notice
|
||||
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
## Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
|
||||
|
|
@ -1,956 +0,0 @@
|
|||
// Copyright 2024 System76 <info@system76.com>
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
use cosmic::Task;
|
||||
use cosmic::iced::stream;
|
||||
use cosmic_pipewire as pipewire;
|
||||
use futures::{SinkExt, Stream};
|
||||
use intmap::IntMap;
|
||||
use pipewire::Availability;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::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> + Send + 'static {
|
||||
stream::channel(
|
||||
1,
|
||||
|mut emitter: futures::channel::mpsc::Sender<Message>| 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue