feat(sound): use cosmic-settings-daemon's varlink API
This commit is contained in:
parent
8981b0b48e
commit
95eda40878
6 changed files with 762 additions and 448 deletions
576
Cargo.lock
generated
576
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -60,6 +60,7 @@ tokio = { version = "1.49.0", features = ["full"] }
|
||||||
# cosmic-config = { path = "../libcosmic/cosmic-config" }
|
# cosmic-config = { path = "../libcosmic/cosmic-config" }
|
||||||
cosmic-config = { git = "https://github.com/pop-os/libcosmic" }
|
cosmic-config = { git = "https://github.com/pop-os/libcosmic" }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
zlink = "0.5.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ edition = "2024"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures.workspace = true
|
||||||
i18n-embed-fl.workspace = true
|
i18n-embed-fl.workspace = true
|
||||||
i18n-embed.workspace = true
|
i18n-embed.workspace = true
|
||||||
libcosmic.workspace = true
|
libcosmic.workspace = true
|
||||||
mpris2-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings" }
|
mpris2-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings" }
|
||||||
# mpris2-zbus = { path = "../../dbus-settings-bindings/mpris2" }
|
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
@ -19,7 +19,9 @@ tracing.workspace = true
|
||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
zbus.workspace = true
|
zbus.workspace = true
|
||||||
|
zlink.workspace = true
|
||||||
|
intmap = "3.1.3"
|
||||||
|
|
||||||
[dependencies.cosmic-settings-sound-subscription]
|
[dependencies.cosmic-settings-audio-client]
|
||||||
git = "https://github.com/pop-os/cosmic-settings"
|
git = "https://github.com/pop-os/cosmic-settings-daemon"
|
||||||
# path = "../../cosmic-settings/subscriptions/sound"
|
features = ["codec"]
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
mod localize;
|
mod localize;
|
||||||
|
mod model;
|
||||||
mod mouse_area;
|
mod mouse_area;
|
||||||
|
|
||||||
use crate::localize::localize;
|
use crate::localize::localize;
|
||||||
use config::{AudioAppletConfig, amplification_sink, amplification_source};
|
use config::{AudioAppletConfig, amplification_sink, amplification_source};
|
||||||
use cosmic::{
|
use cosmic::{
|
||||||
Element, Renderer, Task, Theme, app,
|
Apply, Element, Renderer, Task, Theme, app,
|
||||||
applet::{
|
applet::{
|
||||||
column as applet_column,
|
column as applet_column,
|
||||||
cosmic_panel_config::PanelAnchor,
|
cosmic_panel_config::PanelAnchor,
|
||||||
|
|
@ -26,10 +27,12 @@ use cosmic::{
|
||||||
surface, theme,
|
surface, theme,
|
||||||
widget::{Row, button, container, divider, icon, space, text, toggler},
|
widget::{Row, button, container, divider, icon, space, text, toggler},
|
||||||
};
|
};
|
||||||
use cosmic_settings_sound_subscription as css;
|
use cosmic_settings_audio_client::{self as audio_client, CosmicAudioProxy};
|
||||||
|
use futures::SinkExt;
|
||||||
use iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup};
|
use iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup};
|
||||||
use mpris_subscription::{MprisRequest, MprisUpdate};
|
use mpris_subscription::{MprisRequest, MprisUpdate};
|
||||||
use mpris2_zbus::player::PlaybackStatus;
|
use mpris2_zbus::player::PlaybackStatus;
|
||||||
|
use std::{cell::RefCell, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod mpris_subscription;
|
mod mpris_subscription;
|
||||||
|
|
@ -50,8 +53,10 @@ pub struct Audio {
|
||||||
core: cosmic::app::Core,
|
core: cosmic::app::Core,
|
||||||
/// Track the applet's popup window.
|
/// Track the applet's popup window.
|
||||||
popup: Option<window::Id>,
|
popup: Option<window::Id>,
|
||||||
/// The model from cosmic-settings for managing pipewire devices.
|
/// Varlink connection to `com.system76.CosmicSettings.Audio`.
|
||||||
model: css::Model,
|
audio_client: Option<Rc<RefCell<audio_client::Client>>>,
|
||||||
|
/// Known audio device state
|
||||||
|
model: model::Model,
|
||||||
/// Whether to expand the revealer of a source or sink device.
|
/// Whether to expand the revealer of a source or sink device.
|
||||||
is_open: IsOpen,
|
is_open: IsOpen,
|
||||||
/// Max slider volume for the sink device, as determined by the amplification property.
|
/// Max slider volume for the sink device, as determined by the amplification property.
|
||||||
|
|
@ -72,8 +77,8 @@ pub struct Audio {
|
||||||
|
|
||||||
impl Audio {
|
impl Audio {
|
||||||
fn output_icon_name(&self) -> &'static str {
|
fn output_icon_name(&self) -> &'static str {
|
||||||
let volume = self.model.sink_volume;
|
let volume = self.model.active_sink.volume;
|
||||||
let mute = self.model.sink_mute;
|
let mute = self.model.active_sink.mute;
|
||||||
if mute || volume == 0 {
|
if mute || volume == 0 {
|
||||||
"audio-volume-muted-symbolic"
|
"audio-volume-muted-symbolic"
|
||||||
} else if volume < 33 {
|
} else if volume < 33 {
|
||||||
|
|
@ -88,8 +93,8 @@ impl Audio {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input_icon_name(&self) -> &'static str {
|
fn input_icon_name(&self) -> &'static str {
|
||||||
let volume = self.model.source_volume;
|
let volume = self.model.active_source.volume;
|
||||||
let mute = self.model.source_mute;
|
let mute = self.model.active_source.mute;
|
||||||
if mute || volume == 0 {
|
if mute || volume == 0 {
|
||||||
"microphone-sensitivity-muted-symbolic"
|
"microphone-sensitivity-muted-symbolic"
|
||||||
} else if volume < 33 {
|
} else if volume < 33 {
|
||||||
|
|
@ -110,8 +115,10 @@ enum IsOpen {
|
||||||
Input,
|
Input,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
|
/// Connection to `com.system76.CosmicSettings`.
|
||||||
|
Client(Arc<audio_client::Client>),
|
||||||
Ignore,
|
Ignore,
|
||||||
SetSinkVolume(u32),
|
SetSinkVolume(u32),
|
||||||
SetSourceVolume(u32),
|
SetSourceVolume(u32),
|
||||||
|
|
@ -129,7 +136,7 @@ pub enum Message {
|
||||||
MprisRequest(MprisRequest),
|
MprisRequest(MprisRequest),
|
||||||
Token(TokenUpdate),
|
Token(TokenUpdate),
|
||||||
OpenSettings,
|
OpenSettings,
|
||||||
Subscription(css::Message),
|
Subscription(audio_client::Event),
|
||||||
Surface(surface::Action),
|
Surface(surface::Action),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,15 +249,9 @@ impl cosmic::Application for Audio {
|
||||||
const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
|
const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
|
||||||
|
|
||||||
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, app::Task<Message>) {
|
fn init(core: cosmic::app::Core, _flags: ()) -> (Self, app::Task<Message>) {
|
||||||
let mut model = css::Model::default();
|
|
||||||
model.unplugged_text = "Unplugged".into();
|
|
||||||
model.hd_audio_text = "HD Audio".into();
|
|
||||||
model.usb_audio_text = "USB Audio".into();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
core,
|
core,
|
||||||
model,
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Task::none(),
|
Task::none(),
|
||||||
|
|
@ -318,52 +319,74 @@ impl cosmic::Application for Audio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Subscription(message) => {
|
Message::Subscription(message) => {
|
||||||
return self
|
self.model.update(message);
|
||||||
.model
|
|
||||||
.update(message)
|
|
||||||
.map(|message| Message::Subscription(message).into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::SetDefaultSink(pos) => {
|
Message::SetDefaultSink(pos) => {
|
||||||
return self
|
if let Some(&pos) = self.model.sinks.sorted_index.get(pos)
|
||||||
.model
|
&& let Some(&node_id) = self.model.sinks.id.get(pos as usize)
|
||||||
.set_default_sink(pos)
|
&& let Some(client) = self.audio_client.as_mut()
|
||||||
.map(|message| Message::Subscription(message).into());
|
{
|
||||||
|
futures::executor::block_on(async {
|
||||||
|
_ = client.borrow_mut().conn.set_default(node_id, true).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::SetDefaultSource(pos) => {
|
Message::SetDefaultSource(pos) => {
|
||||||
return self
|
if let Some(&pos) = self.model.sources.sorted_index.get(pos)
|
||||||
.model
|
&& let Some(&node_id) = self.model.sources.id.get(pos as usize)
|
||||||
.set_default_source(pos)
|
&& let Some(client) = self.audio_client.as_mut()
|
||||||
.map(|message| Message::Subscription(message).into());
|
{
|
||||||
|
futures::executor::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.audio_client {
|
||||||
|
futures::executor::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.audio_client {
|
||||||
|
futures::executor::block_on(async {
|
||||||
|
_ = client.borrow_mut().conn.source_mute_toggle().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Message::SetSinkVolume(volume) => {
|
Message::SetSinkVolume(volume) => {
|
||||||
return self
|
if let Some(ref mut client) = self.audio_client {
|
||||||
.model
|
self.model.active_sink.volume = volume;
|
||||||
.set_sink_volume(volume)
|
self.model.active_sink.volume_text = volume.to_string();
|
||||||
.map(|message| Message::Subscription(message).into());
|
futures::executor::block_on(async {
|
||||||
|
_ = client.borrow_mut().conn.set_sink_volume(volume).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::SetSourceVolume(volume) => {
|
Message::SetSourceVolume(volume) => {
|
||||||
return self
|
if let Some(ref mut client) = self.audio_client {
|
||||||
.model
|
self.model.active_source.volume = volume;
|
||||||
.set_source_volume(volume)
|
self.model.active_source.volume_text = volume.to_string();
|
||||||
.map(|message| Message::Subscription(message).into());
|
futures::executor::block_on(async {
|
||||||
|
_ = client.borrow_mut().conn.set_source_volume(volume).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::ToggleMediaControlsInTopPanel(enabled) => {
|
Message::ToggleMediaControlsInTopPanel(enabled) => {
|
||||||
self.config.show_media_controls_in_top_panel = enabled;
|
self.config.show_media_controls_in_top_panel = enabled;
|
||||||
if let Ok(helper) =
|
if let Ok(helper) =
|
||||||
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION)
|
cosmic::cosmic_config::Config::new(Self::APP_ID, AudioAppletConfig::VERSION)
|
||||||
|
&& let Err(err) = self.config.write_entry(&helper)
|
||||||
{
|
{
|
||||||
if let Err(err) = self.config.write_entry(&helper) {
|
tracing::error!(?err, "Error writing config");
|
||||||
tracing::error!(?err, "Error writing config");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::CloseRequested(id) => {
|
Message::CloseRequested(id) => {
|
||||||
|
|
@ -461,6 +484,12 @@ impl cosmic::Application for Audio {
|
||||||
cosmic::app::Action::Surface(a),
|
cosmic::app::Action::Surface(a),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
Message::Client(client) => {
|
||||||
|
if let Some(client) = Arc::into_inner(client) {
|
||||||
|
self.audio_client = Some(Rc::new(RefCell::new(client)));
|
||||||
|
self.model = model::Model::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Task::none()
|
Task::none()
|
||||||
|
|
@ -476,7 +505,49 @@ impl cosmic::Application for Audio {
|
||||||
}),
|
}),
|
||||||
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
|
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
|
||||||
activation_token_subscription(0).map(Message::Token),
|
activation_token_subscription(0).map(Message::Token),
|
||||||
Subscription::run(|| css::watch().map(Message::Subscription)),
|
Subscription::run(|| {
|
||||||
|
iced::stream::channel(
|
||||||
|
1,
|
||||||
|
move |mut emitter: futures::channel::mpsc::Sender<_>| async move {
|
||||||
|
loop {
|
||||||
|
let mut client = match audio_client::connect().await {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(why) => {
|
||||||
|
if let 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(Message::Client(Arc::new(client))).await;
|
||||||
|
while let Some(message) = stream.next().await {
|
||||||
|
match message {
|
||||||
|
Ok(event) => {
|
||||||
|
_ = emitter.send(Message::Subscription(event)).await;
|
||||||
|
}
|
||||||
|
Err(why) => {
|
||||||
|
tracing::error!(?why, "event error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,7 +568,7 @@ impl cosmic::Application for Audio {
|
||||||
return Message::Ignore;
|
return Message::Ignore;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_volume = (self.model.sink_volume as f64 + (scroll_vector as f64))
|
let new_volume = (self.model.active_sink.volume as f64 + (scroll_vector as f64))
|
||||||
.clamp(0.0, self.max_sink_volume as f64);
|
.clamp(0.0, self.max_sink_volume as f64);
|
||||||
Message::SetSinkVolume(new_volume as u32)
|
Message::SetSinkVolume(new_volume as u32)
|
||||||
});
|
});
|
||||||
|
|
@ -551,19 +622,21 @@ impl cosmic::Application for Audio {
|
||||||
space_xxs, space_s, ..
|
space_xxs, space_s, ..
|
||||||
} = theme::active().cosmic().spacing;
|
} = theme::active().cosmic().spacing;
|
||||||
|
|
||||||
let sink = self
|
let sink: Option<&str> = self
|
||||||
.model
|
.model
|
||||||
.active_sink()
|
.sinks
|
||||||
.and_then(|pos| self.model.sinks().get(pos));
|
.active()
|
||||||
let source = self
|
.map(|pos| self.model.sinks.sorted_display[pos].as_ref());
|
||||||
|
let source: Option<&str> = self
|
||||||
.model
|
.model
|
||||||
.active_source()
|
.sources
|
||||||
.and_then(|pos| self.model.sources().get(pos));
|
.active()
|
||||||
|
.map(|pos| self.model.sources.sorted_display[pos].as_ref());
|
||||||
|
|
||||||
let mut audio_content = {
|
let mut audio_content = {
|
||||||
let output_slider = slider(
|
let output_slider = slider(
|
||||||
0..=self.max_sink_volume,
|
0..=self.max_sink_volume,
|
||||||
self.model.sink_volume,
|
self.model.active_sink.volume,
|
||||||
Message::SetSinkVolume,
|
Message::SetSinkVolume,
|
||||||
)
|
)
|
||||||
.width(Length::FillPortion(5))
|
.width(Length::FillPortion(5))
|
||||||
|
|
@ -571,7 +644,7 @@ impl cosmic::Application for Audio {
|
||||||
|
|
||||||
let input_slider = slider(
|
let input_slider = slider(
|
||||||
0..=self.max_source_volume,
|
0..=self.max_source_volume,
|
||||||
self.model.source_volume,
|
self.model.active_source.volume,
|
||||||
Message::SetSourceVolume,
|
Message::SetSourceVolume,
|
||||||
)
|
)
|
||||||
.width(Length::FillPortion(5))
|
.width(Length::FillPortion(5))
|
||||||
|
|
@ -590,7 +663,7 @@ impl cosmic::Application for Audio {
|
||||||
.line_height(24)
|
.line_height(24)
|
||||||
.on_press(Message::ToggleSinkMute),
|
.on_press(Message::ToggleSinkMute),
|
||||||
output_slider,
|
output_slider,
|
||||||
container(text(&self.model.sink_volume_text).size(16))
|
container(text(&self.model.active_sink.volume_text).size(16))
|
||||||
.width(Length::FillPortion(1))
|
.width(Length::FillPortion(1))
|
||||||
.align_x(Alignment::End)
|
.align_x(Alignment::End)
|
||||||
]
|
]
|
||||||
|
|
@ -609,7 +682,7 @@ impl cosmic::Application for Audio {
|
||||||
.line_height(24)
|
.line_height(24)
|
||||||
.on_press(Message::ToggleSourceMute),
|
.on_press(Message::ToggleSourceMute),
|
||||||
input_slider,
|
input_slider,
|
||||||
container(text(&self.model.source_volume_text).size(16))
|
container(text(&self.model.active_source.volume_text).size(16))
|
||||||
.width(Length::FillPortion(1))
|
.width(Length::FillPortion(1))
|
||||||
.align_x(Alignment::End)
|
.align_x(Alignment::End)
|
||||||
]
|
]
|
||||||
|
|
@ -624,7 +697,7 @@ impl cosmic::Application for Audio {
|
||||||
Some(sink) => sink.to_owned(),
|
Some(sink) => sink.to_owned(),
|
||||||
None => fl!("no-device"),
|
None => fl!("no-device"),
|
||||||
},
|
},
|
||||||
self.model.sinks(),
|
&self.model.sinks.sorted_display,
|
||||||
Message::OutputToggle,
|
Message::OutputToggle,
|
||||||
Message::SetDefaultSink,
|
Message::SetDefaultSink,
|
||||||
),
|
),
|
||||||
|
|
@ -635,7 +708,7 @@ impl cosmic::Application for Audio {
|
||||||
Some(source) => source.to_owned(),
|
Some(source) => source.to_owned(),
|
||||||
None => fl!("no-device"),
|
None => fl!("no-device"),
|
||||||
},
|
},
|
||||||
self.model.sources(),
|
&self.model.sources.sorted_display,
|
||||||
Message::InputToggle,
|
Message::InputToggle,
|
||||||
Message::SetDefaultSource,
|
Message::SetDefaultSource,
|
||||||
)
|
)
|
||||||
|
|
@ -691,19 +764,15 @@ impl cosmic::Application for Audio {
|
||||||
}
|
}
|
||||||
if let Some(play) = self.is_play() {
|
if let Some(play) = self.is_play() {
|
||||||
control_elements.push(
|
control_elements.push(
|
||||||
button::icon(
|
button::icon(icon::from_name(if play { PLAY } else { PAUSE }).symbolic(true))
|
||||||
icon::from_name(if play { PLAY } else { PAUSE })
|
.extra_small()
|
||||||
.size(32)
|
.class(cosmic::theme::Button::AppletIcon)
|
||||||
.symbolic(true),
|
.on_press(if play {
|
||||||
)
|
Message::MprisRequest(MprisRequest::Play)
|
||||||
.extra_small()
|
} else {
|
||||||
.class(cosmic::theme::Button::AppletIcon)
|
Message::MprisRequest(MprisRequest::Pause)
|
||||||
.on_press(if play {
|
})
|
||||||
Message::MprisRequest(MprisRequest::Play)
|
.into(),
|
||||||
} else {
|
|
||||||
Message::MprisRequest(MprisRequest::Pause)
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(go_next) = self.go_next(32) {
|
if let Some(go_next) = self.go_next(32) {
|
||||||
|
|
@ -754,20 +823,21 @@ impl cosmic::Application for Audio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn revealer(
|
fn revealer<'a>(
|
||||||
open: bool,
|
open: bool,
|
||||||
title: String,
|
title: String,
|
||||||
selected: String,
|
selected: String,
|
||||||
devices: &[String],
|
devices: &'a [Arc<str>],
|
||||||
toggle: Message,
|
toggle: Message,
|
||||||
mut change: impl FnMut(usize) -> Message + 'static,
|
mut change: impl FnMut(usize) -> Message + 'static,
|
||||||
) -> widget::Column<'static, Message, crate::Theme, Renderer> {
|
) -> widget::Column<'a, Message, crate::Theme, Renderer> {
|
||||||
if open {
|
if open {
|
||||||
devices.iter().cloned().enumerate().fold(
|
devices.iter().enumerate().fold(
|
||||||
column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|
column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|
||||||
|col, (id, name)| {
|
move |col, (id, name)| {
|
||||||
col.push(
|
col.push(
|
||||||
menu_button(text::body(name))
|
text::body(name.as_ref())
|
||||||
|
.apply(menu_button)
|
||||||
.on_press(change(id))
|
.on_press(change(id))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding([8, 48]),
|
.padding([8, 48]),
|
||||||
|
|
@ -785,9 +855,9 @@ fn revealer_head(
|
||||||
selected: String,
|
selected: String,
|
||||||
toggle: Message,
|
toggle: Message,
|
||||||
) -> cosmic::widget::Button<'static, Message> {
|
) -> cosmic::widget::Button<'static, Message> {
|
||||||
menu_button(column![
|
cosmic::widget::column::with_capacity(2)
|
||||||
text::body(title).width(Length::Fill),
|
.push(text::body(title).width(Length::Fill))
|
||||||
text::caption(selected),
|
.push(text::caption(selected))
|
||||||
])
|
.apply(menu_button)
|
||||||
.on_press(toggle)
|
.on_press(toggle)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
393
cosmic-applet-audio/src/model.rs
Normal file
393
cosmic-applet-audio/src/model.rs
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
// 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, RouteInfo};
|
||||||
|
use intmap::IntMap;
|
||||||
|
|
||||||
|
pub type DeviceId = u32;
|
||||||
|
pub type NodeId = u32;
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Model {
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Nodes {
|
||||||
|
active: Option<usize>,
|
||||||
|
pub sorted_display: Box<[Arc<str>]>,
|
||||||
|
pub sorted_index: Box<[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.into_boxed_slice();
|
||||||
|
self.sorted_index = indexes.into_boxed_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mute: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn update(&mut self, event: audio_client::Event) {
|
||||||
|
tracing::debug!(?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.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::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(|(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
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
Some(node_name(
|
||||||
|
&node.device_profile_description,
|
||||||
|
&self.sources.description[pos],
|
||||||
|
))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
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 index == 0 {
|
||||||
|
*routes = vec![route];
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_routes.remove(device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
&self.translate(&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(
|
||||||
|
&self.translate(&route.description),
|
||||||
|
&self.sources.description[pos],
|
||||||
|
);
|
||||||
|
self.sources.dropdown_sort();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn translate(&self, description: &str) -> String {
|
||||||
|
description
|
||||||
|
.replace("High Definition", "HD")
|
||||||
|
.replace("DisplayPort", "DP")
|
||||||
|
.replace("Controller", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_name(route: &str, node: &str) -> Arc<str> {
|
||||||
|
if route.is_empty() {
|
||||||
|
node.to_owned()
|
||||||
|
} else {
|
||||||
|
[route, " - ", node].concat()
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ use cosmic::iced::core::Point;
|
||||||
|
|
||||||
use cosmic::iced::core::{
|
use cosmic::iced::core::{
|
||||||
Clipboard, Element, Layout, Length, Rectangle, Shell, Size, Widget,
|
Clipboard, Element, Layout, Length, Rectangle, Shell, Size, Widget,
|
||||||
event::{self, Event},
|
event::Event,
|
||||||
layout, mouse, overlay, renderer, touch,
|
layout, mouse, overlay, renderer, touch,
|
||||||
widget::{Operation, Tree, tree},
|
widget::{Operation, Tree, tree},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue