feat(audio): share cosmic-settings' new sound library

This commit is contained in:
Michael Aaron Murphy 2025-11-11 16:56:53 +01:00 committed by Ashley Wulber
parent abf5eedb5e
commit a3fb55a2b8
10 changed files with 574 additions and 1478 deletions

634
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -30,8 +30,6 @@ cosmic-applets-config = { path = "cosmic-applets-config" }
cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols", default-features = false, features = [
"client",
], rev = "d0e95be" }
cosmic-settings-subscriptions = { git = "https://github.com/pop-os/cosmic-settings-subscriptions" }
cosmic-time = { git = "https://github.com/pop-os/cosmic-time", default-features = false }
# cosmic-time = { path = "../cosmic-time", default-features = false ] }

View file

@ -4,11 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
# cosmic-dbus-a11y = { git = "https://github.com/pop-os/dbus-settings-bindings" }
cosmic-settings-subscriptions = { workspace = true, features = [
"accessibility",
"cosmic_a11y_manager",
] }
anyhow.workspace = true
cctk.workspace = true
cosmic-protocols.workspace = true
@ -21,3 +16,9 @@ tokio.workspace = true
tracing-log.workspace = true
tracing-subscriber.workspace = true
tracing.workspace = true
[dependencies.cosmic-settings-a11y-manager-subscription]
git = "https://github.com/pop-os/cosmic-settings"
[dependencies.cosmic-settings-accessibility-subscription]
git = "https://github.com/pop-os/cosmic-settings"

View file

@ -24,10 +24,11 @@ use cosmic::{
theme::{self, CosmicTheme},
widget::{Column, divider, text},
};
use cosmic_settings_subscriptions::{
accessibility::{self, DBusRequest, DBusUpdate},
cosmic_a11y_manager::{AccessibilityEvent, AccessibilityRequest, ColorFilter},
use cosmic_settings_a11y_manager_subscription::{
self as cosmic_a11y_manager, AccessibilityEvent, AccessibilityRequest, ColorFilter,
};
use cosmic_settings_accessibility_subscription::{self as accessibility, DBusRequest, DBusUpdate};
use cosmic_time::{Instant, Timeline, anim, chain, id};
use std::sync::LazyLock;
use tokio::sync::mpsc::UnboundedSender;

View file

@ -9,7 +9,7 @@ use cosmic::iced::{
stream,
};
use cosmic_protocols::a11y::v1::client::cosmic_a11y_manager_v1::Filter;
use cosmic_settings_subscriptions::cosmic_a11y_manager::{
use cosmic_settings_a11y_manager_subscription::{
self as thread, AccessibilityEvent, AccessibilityRequest,
};
use std::sync::LazyLock;

View file

@ -5,7 +5,6 @@ edition = "2024"
license = "GPL-3.0-only"
[dependencies]
cosmic-settings-subscriptions.workspace = true
cosmic-time.workspace = true
i18n-embed-fl.workspace = true
i18n-embed.workspace = true
@ -22,3 +21,6 @@ tracing.workspace = true
url = "2"
urlencoding = "2.1.3"
zbus.workspace = true
[dependencies.cosmic-settings-sound-subscription]
git = "https://github.com/pop-os/cosmic-settings"

View file

@ -4,10 +4,7 @@
mod localize;
mod mouse_area;
use std::sync::LazyLock;
use std::time::Duration;
use crate::{localize::localize, pulse::DeviceInfo};
use crate::localize::localize;
use config::{AudioAppletConfig, amplification_sink, amplification_source};
use cosmic::{
Element, Renderer, Task, Theme, app,
@ -22,28 +19,22 @@ use cosmic::{
cosmic_theme::Spacing,
iced::{
self, Alignment, Length, Subscription,
futures::StreamExt,
widget::{self, column, row, slider},
window,
},
surface, theme,
widget::{Column, Row, button, container, divider, horizontal_space, icon, text},
widget::{Row, button, container, divider, horizontal_space, icon, text},
};
use cosmic_settings_subscriptions::pulse as sub_pulse;
use cosmic_settings_sound_subscription as css;
use cosmic_time::{Instant, Timeline, anim, chain, id};
use iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup};
use libpulse_binding::volume::Volume;
use mpris_subscription::{MprisRequest, MprisUpdate};
use mpris2_zbus::player::PlaybackStatus;
use std::sync::LazyLock;
mod config;
mod mpris_subscription;
mod pulse;
// Full, in this case, means 100%.
static FULL_VOLUME: f64 = Volume::NORMAL.0 as f64;
// Max volume is 150% volume.
static MAX_VOLUME: f64 = FULL_VOLUME + (FULL_VOLUME * 0.5);
static SHOW_MEDIA_CONTROLS: LazyLock<id::Toggler> = LazyLock::new(id::Toggler::unique);
@ -59,72 +50,57 @@ pub fn run() -> cosmic::iced::Result {
#[derive(Default)]
pub struct Audio {
/// For interfacing with libcosmic.
core: cosmic::app::Core,
is_open: IsOpen,
output_volume: f64,
output_volume_debounce: bool,
output_volume_text: String,
output_amplification: bool,
input_volume: f64,
input_volume_debounce: bool,
input_volume_text: String,
input_amplification: bool,
current_output: Option<DeviceInfo>,
current_input: Option<DeviceInfo>,
outputs: Vec<DeviceInfo>,
inputs: Vec<DeviceInfo>,
pulse_state: PulseState,
/// Track the applet's popup window.
popup: Option<window::Id>,
/// The model from cosmic-settings for managing pipewire devices.
model: css::Model,
/// Whether to expand the revealer of a source or sink device.
is_open: IsOpen,
/// Max slider volume for the sink device, as determined by the amplification property.
max_sink_volume: u32,
/// Max slider volume for the source device, as determined by the amplification property.
max_source_volume: u32,
/// Breakpoints for the sink volume slider.
sink_breakpoints: &'static [u32],
/// Breakpoitns for the source volume slider.
source_breakpoints: &'static [u32],
/// Track animations used by the revealers.
timeline: Timeline,
/// Config file specific to this applet.
config: AudioAppletConfig,
/// mpris player status
player_status: Option<mpris_subscription::PlayerStatus>,
/// Used to request an activation token for opening cosmic-settings.
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
channels: Option<sub_pulse::PulseChannels>,
}
impl Audio {
fn update_output(&mut self, output: Option<DeviceInfo>) {
self.current_output = output;
if let Some(device) = self.current_output.as_ref() {
self.output_volume = volume_to_percent(device.volume.avg());
self.output_volume_text = format!("{}%", self.output_volume.round());
}
}
fn output_icon_name(&self) -> &'static str {
let volume = self.output_volume;
let mute = self.current_output_mute();
if mute || volume == 0. {
let volume = self.model.sink_volume;
let mute = self.model.sink_mute;
if mute || volume == 0 {
"audio-volume-muted-symbolic"
} else if volume < 33. {
} else if volume < 33 {
"audio-volume-low-symbolic"
} else if volume < 66. {
} else if volume < 66 {
"audio-volume-medium-symbolic"
} else if volume <= 100. {
} else if volume <= 100 {
"audio-volume-high-symbolic"
} else {
"audio-volume-overamplified-symbolic"
}
}
fn update_input(&mut self, input: Option<DeviceInfo>) {
self.current_input = input;
if let Some(device) = self.current_input.as_ref() {
self.input_volume = volume_to_percent(device.volume.avg());
self.input_volume_text = format!("{}%", self.input_volume.round());
}
}
fn input_icon_name(&self) -> &'static str {
let volume = self.input_volume;
let mute = self.current_input_mute();
if mute || volume == 0. {
let volume = self.model.source_volume;
let mute = self.model.source_mute;
if mute || volume == 0 {
"microphone-sensitivity-muted-symbolic"
} else if volume < 33. {
} else if volume < 33 {
"microphone-sensitivity-low-symbolic"
} else if volume < 66. {
} else if volume < 66 {
"microphone-sensitivity-medium-symbolic"
} else {
"microphone-sensitivity-high-symbolic"
@ -143,17 +119,14 @@ enum IsOpen {
#[derive(Debug, Clone)]
pub enum Message {
Ignore,
ApplyOutputVolume,
ApplyInputVolume,
SetOutputVolume(f64),
SetInputVolume(f64),
SetOutputMute(bool),
SetInputMute(bool),
SetSinkVolume(u32),
SetSourceVolume(u32),
ToggleSinkMute,
ToggleSourceMute,
SetDefaultSink(usize),
SetDefaultSource(usize),
OutputToggle,
InputToggle,
OutputChanged(String),
InputChanged(String),
Pulse(pulse::Event),
TogglePopup,
CloseRequested(window::Id),
ToggleMediaControlsInTopPanel(chain::Toggler, bool),
@ -163,7 +136,7 @@ pub enum Message {
MprisRequest(MprisRequest),
Token(TokenUpdate),
OpenSettings,
PulseSub(sub_pulse::Event),
Subscription(css::Message),
Surface(surface::Action),
}
@ -267,14 +240,6 @@ impl Audio {
}
})
}
fn current_output_mute(&self) -> bool {
self.current_output.as_ref().is_some_and(|o| o.mute)
}
fn current_input_mute(&self) -> bool {
self.current_input.as_ref().is_some_and(|o| o.mute)
}
}
impl cosmic::Application for Audio {
@ -284,9 +249,15 @@ impl cosmic::Application for Audio {
const APP_ID: &'static str = "com.system76.CosmicAppletAudio";
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 {
core,
model,
..Default::default()
},
Task::none(),
@ -313,15 +284,21 @@ impl cosmic::Application for Audio {
if let Some(p) = self.popup.take() {
return destroy_popup(p);
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::UpdateConnection);
}
let new_id = window::Id::unique();
self.popup.replace(new_id);
self.timeline = Timeline::new();
self.output_amplification = amplification_sink();
self.input_amplification = amplification_source();
(self.max_sink_volume, self.sink_breakpoints) = if amplification_sink() {
(150, &[100][..])
} else {
(100, &[][..])
};
(self.max_source_volume, self.source_breakpoints) = if amplification_source() {
(150, &[100][..])
} else {
(100, &[][..])
};
let popup_settings = self.core.applet.get_popup_settings(
self.core.main_window_id().unwrap(),
@ -331,130 +308,14 @@ impl cosmic::Application for Audio {
None,
);
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
}
return get_popup(popup_settings);
}
}
Message::SetOutputVolume(vol) => {
if self.output_volume == vol {
return Task::none();
}
self.output_volume = vol;
self.output_volume_text = format!("{}%", self.output_volume.round());
if self.output_volume_debounce {
return Task::none();
}
self.output_volume_debounce = true;
return cosmic::task::future(async move {
tokio::time::sleep(Duration::from_millis(64)).await;
Message::ApplyOutputVolume
});
}
Message::SetInputVolume(vol) => {
if self.input_volume == vol {
return Task::none();
}
self.input_volume = vol;
self.input_volume_text = format!("{}%", self.input_volume.round());
if self.input_volume_debounce {
return Task::none();
}
self.input_volume_debounce = true;
return cosmic::task::future(async move {
tokio::time::sleep(Duration::from_millis(64)).await;
Message::ApplyInputVolume
});
}
Message::ApplyOutputVolume => {
self.output_volume_debounce = false;
if let Some(channel) = self.channels.as_mut() {
channel.set_volume(self.output_volume as f32 / 100.);
}
}
Message::ApplyInputVolume => {
self.input_volume_debounce = false;
self.current_input.as_mut().map(|i| {
i.volume
.set(i.volume.len(), percent_to_volume(self.input_volume))
});
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_input {
if let Some(name) = &device.name {
tracing::info!("increasing volume of {}", name);
connection.send(pulse::Message::SetSourceVolumeByName(
name.clone(),
device.volume,
));
}
}
}
}
Message::SetOutputMute(mute) => {
if let Some(output) = self.current_output.as_mut() {
output.mute = mute;
}
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_output {
if let Some(name) = &device.name {
connection
.send(pulse::Message::SetSinkMuteByName(name.clone(), device.mute));
}
}
}
}
Message::SetInputMute(mute) => {
if let Some(input) = self.current_input.as_mut() {
input.mute = mute;
}
if let PulseState::Connected(connection) = &mut self.pulse_state {
if let Some(device) = &self.current_input {
if let Some(name) = &device.name {
connection.send(pulse::Message::SetSourceMuteByName(
name.clone(),
device.mute,
))
}
}
}
}
Message::OutputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.outputs.iter().find(|o| o.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSink(val.clone()));
}
}
}
Message::InputChanged(val) => {
if let Some(conn) = self.pulse_state.connection() {
if let Some(val) = self.inputs.iter().find(|i| i.name.as_ref() == Some(&val)) {
conn.send(pulse::Message::SetDefaultSource(val.clone()));
}
}
}
Message::OutputToggle => {
self.is_open = if self.is_open == IsOpen::Output {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
}
IsOpen::Output
}
}
@ -462,61 +323,48 @@ impl cosmic::Application for Audio {
self.is_open = if self.is_open == IsOpen::Input {
IsOpen::None
} else {
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSources);
}
IsOpen::Input
}
}
Message::Pulse(event) => match event {
pulse::Event::Init(mut conn) => {
conn.send(pulse::Message::UpdateConnection);
self.pulse_state = PulseState::Disconnected(conn);
Message::Subscription(message) => {
return self
.model
.update(message)
.map(|message| Message::Subscription(message).into());
}
pulse::Event::Connected => {
self.pulse_state.connected();
if let Some(conn) = self.pulse_state.connection() {
conn.send(pulse::Message::GetSinks);
conn.send(pulse::Message::GetSources);
conn.send(pulse::Message::GetDefaultSink);
conn.send(pulse::Message::GetDefaultSource);
Message::SetDefaultSink(pos) => {
return self
.model
.set_default_sink(pos)
.map(|message| Message::Subscription(message).into());
}
Message::SetDefaultSource(pos) => {
return self
.model
.set_default_source(pos)
.map(|message| Message::Subscription(message).into());
}
pulse::Event::MessageReceived(msg) => {
match msg {
// This is where we match messages from the subscription to app state
pulse::Message::SetSinks(sinks) => self.outputs = sinks,
pulse::Message::SetSources(mut sources) => {
sources.retain(|source| {
!source.name.as_ref().is_some_and(|n| n.contains("monitor"))
});
self.inputs = sources;
Message::ToggleSinkMute => self.model.toggle_sink_mute(),
Message::ToggleSourceMute => self.model.toggle_source_mute(),
Message::SetSinkVolume(volume) => {
return self
.model
.set_sink_volume(volume)
.map(|message| Message::Subscription(message).into());
}
pulse::Message::SetDefaultSink(sink) => {
self.update_output(Some(sink));
Message::SetSourceVolume(volume) => {
return self
.model
.set_source_volume(volume)
.map(|message| Message::Subscription(message).into());
}
pulse::Message::SetDefaultSource(source) => {
self.update_input(Some(source));
}
pulse::Message::Disconnected => {
panic!("Subscription error handling is bad. This should never happen.")
}
_ => {
tracing::trace!("Received misc message")
}
}
}
pulse::Event::Disconnected => {
self.pulse_state.disconnected();
if let Some(mut conn) = self.pulse_state.connection().cloned() {
_ = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
conn.send(pulse::Message::UpdateConnection);
});
}
}
},
Message::ToggleMediaControlsInTopPanel(chain, enabled) => {
self.timeline.set_chain(chain).start();
self.config.show_media_controls_in_top_panel = enabled;
@ -618,45 +466,6 @@ impl cosmic::Application for Audio {
tokio::spawn(cosmic::process::spawn(cmd));
}
},
Message::PulseSub(event) => match event {
sub_pulse::Event::SinkVolume(value) => {
self.current_output.as_mut().map(|output| {
output
.volume
.set(output.volume.len(), percent_to_volume(value as f64))
});
self.output_volume = value as f64;
self.output_volume_text = format!("{}%", self.output_volume.round());
}
sub_pulse::Event::SinkMute(value) => {
if let Some(output) = self.current_output.as_mut() {
output.mute = value;
}
}
sub_pulse::Event::SourceVolume(value) => {
self.current_input.as_mut().map(|input| {
input
.volume
.set(input.volume.len(), percent_to_volume(value as f64))
});
self.input_volume = value as f64;
self.input_volume_text = format!("{}%", self.input_volume.round());
}
sub_pulse::Event::SourceMute(value) => {
if let Some(input) = self.current_input.as_mut() {
input.mute = value;
}
}
sub_pulse::Event::Channels(c) => {
self.channels = Some(c);
}
sub_pulse::Event::DefaultSink(_) => {}
sub_pulse::Event::DefaultSource(_) => {}
sub_pulse::Event::CardInfo(_) => {}
sub_pulse::Event::Balance(_) => {}
},
Message::Surface(a) => {
return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(a),
@ -669,7 +478,6 @@ impl cosmic::Application for Audio {
fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
pulse::connect().map(Message::Pulse),
self.timeline
.as_subscription()
.map(|(_, now)| Message::Frame(now)),
@ -681,7 +489,7 @@ impl cosmic::Application for Audio {
}),
mpris_subscription::mpris_subscription(0).map(Message::Mpris),
activation_token_subscription(0).map(Message::Token),
sub_pulse::subscription().map(Message::PulseSub),
Subscription::run(|| css::watch().map(Message::Subscription)),
])
}
@ -702,15 +510,9 @@ impl cosmic::Application for Audio {
return Message::Ignore;
}
let new_volume = (self.output_volume + (scroll_vector as f64)).clamp(
0.0,
if self.output_amplification {
150.0
} else {
100.0
},
);
Message::SetOutputVolume(new_volume)
let new_volume = (self.model.sink_volume as f64 + (scroll_vector as f64))
.clamp(0.0, self.max_sink_volume as f64);
Message::SetSinkVolume(new_volume as u32)
});
let playback_buttons = (!self.core.applet.suggested_bounds.as_ref().is_some_and(|c| {
@ -760,33 +562,31 @@ impl cosmic::Application for Audio {
space_xxs, space_s, ..
} = theme::active().cosmic().spacing;
let audio_disabled = matches!(self.pulse_state, PulseState::Disconnected(_));
let out_mute = self.current_output_mute();
let in_mute = self.current_input_mute();
let sink = self
.model
.active_sink()
.and_then(|pos| self.model.sinks().get(pos));
let source = self
.model
.active_source()
.and_then(|pos| self.model.sources().get(pos));
let mut audio_content = if audio_disabled {
column![padded_control(
text::title3(fl!("disconnected"))
.width(Length::Fill)
.align_x(Alignment::Center)
)]
} else {
let output_slider = if self.output_amplification {
slider(0.0..=150.0, self.output_volume, Message::SetOutputVolume)
let mut audio_content = {
let output_slider = slider(
0..=self.max_sink_volume,
self.model.sink_volume,
Message::SetSinkVolume,
)
.width(Length::FillPortion(5))
.breakpoints(&[100.])
} else {
slider(0.0..=100.0, self.output_volume, Message::SetOutputVolume)
.breakpoints(self.sink_breakpoints);
let input_slider = slider(
0..=self.max_source_volume,
self.model.source_volume,
Message::SetSourceVolume,
)
.width(Length::FillPortion(5))
};
let input_slider = if self.input_amplification {
slider(0.0..=150.0, self.input_volume, Message::SetInputVolume)
.width(Length::FillPortion(5))
.breakpoints(&[100.])
} else {
slider(0.0..=100.0, self.input_volume, Message::SetInputVolume)
.width(Length::FillPortion(5))
};
.breakpoints(self.source_breakpoints);
column![
padded_control(
@ -799,9 +599,9 @@ impl cosmic::Application for Audio {
.class(cosmic::theme::Button::Icon)
.icon_size(24)
.line_height(24)
.on_press(Message::SetOutputMute(!out_mute)),
.on_press(Message::ToggleSinkMute),
output_slider,
container(text(&self.output_volume_text).size(16))
container(text(&self.model.sink_volume_text).size(16))
.width(Length::FillPortion(1))
.align_x(Alignment::End)
]
@ -818,9 +618,9 @@ impl cosmic::Application for Audio {
.class(cosmic::theme::Button::Icon)
.icon_size(24)
.line_height(24)
.on_press(Message::SetInputMute(!in_mute)),
.on_press(Message::ToggleSourceMute),
input_slider,
container(text(&self.input_volume_text).size(16))
container(text(&self.model.source_volume_text).size(16))
.width(Length::FillPortion(1))
.align_x(Alignment::End)
]
@ -831,24 +631,24 @@ impl cosmic::Application for Audio {
revealer(
self.is_open == IsOpen::Output,
fl!("output"),
match &self.current_output {
Some(output) => pretty_name(output.description.clone()),
match sink {
Some(sink) => sink.to_owned(),
None => String::from("No device selected"),
},
self.outputs.as_slice(),
self.model.sinks(),
Message::OutputToggle,
Message::OutputChanged,
Message::SetDefaultSink,
),
revealer(
self.is_open == IsOpen::Input,
fl!("input"),
match &self.current_input {
Some(input) => pretty_name(input.description.clone()),
match source {
Some(source) => source.to_owned(),
None => fl!("no-device"),
},
self.inputs.as_slice(),
self.model.sources(),
Message::InputToggle,
Message::InputChanged,
Message::SetDefaultSource,
)
]
.align_x(Alignment::Start)
@ -974,18 +774,12 @@ fn revealer(
open: bool,
title: String,
selected: String,
device_info: &[DeviceInfo],
devices: &[String],
toggle: Message,
mut change: impl FnMut(String) -> Message + 'static,
mut change: impl FnMut(usize) -> Message + 'static,
) -> widget::Column<'static, Message, crate::Theme, Renderer> {
if open {
let options = device_info.iter().map(|device| {
(
device.name.clone().unwrap_or_default(),
pretty_name(device.description.clone()),
)
});
options.fold(
devices.iter().cloned().enumerate().fold(
column![revealer_head(open, title, selected, toggle)].width(Length::Fill),
|col, (id, name)| {
col.push(
@ -1013,48 +807,3 @@ fn revealer_head(
])
.on_press(toggle)
}
fn pretty_name(name: Option<String>) -> String {
match name {
Some(n) => n,
None => String::from("Generic"),
}
}
#[derive(Default)]
enum PulseState {
#[default]
Init,
Disconnected(pulse::Connection),
Connected(pulse::Connection),
}
impl PulseState {
fn connection(&mut self) -> Option<&mut pulse::Connection> {
match self {
Self::Disconnected(c) => Some(c),
Self::Connected(c) => Some(c),
Self::Init => None,
}
}
fn connected(&mut self) {
if let Self::Disconnected(c) = self {
*self = Self::Connected(c.clone());
}
}
fn disconnected(&mut self) {
if let Self::Connected(c) = self {
*self = Self::Disconnected(c.clone());
}
}
}
fn volume_to_percent(volume: Volume) -> f64 {
volume.0 as f64 * 100. / FULL_VOLUME
}
fn percent_to_volume(percent: f64) -> Volume {
Volume((percent / 100. * FULL_VOLUME).clamp(0., MAX_VOLUME).round() as u32)
}

View file

@ -1,840 +0,0 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::{cell::RefCell, mem, rc::Rc, sync::LazyLock, thread, time::Duration};
extern crate libpulse_binding as pulse;
use cosmic::{
iced::{self, Subscription, stream},
iced_futures::futures::{self, SinkExt},
};
use libpulse_binding::{
callbacks::ListResult,
context::{
Context,
introspect::{Introspector, SinkInfo, SourceInfo},
},
error::PAErr,
mainloop::standard::{IterateResult, Mainloop},
proplist::Proplist,
volume::ChannelVolumes,
};
use tokio::sync::{Mutex, mpsc};
pub static FROM_PULSE: LazyLock<Mutex<Option<(mpsc::Receiver<Message>, mpsc::Sender<Message>)>>> =
LazyLock::new(|| Mutex::new(None));
pub fn connect() -> iced::Subscription<Event> {
struct SomeWorker;
Subscription::run_with_id(
std::any::TypeId::of::<SomeWorker>(),
stream::channel(50, move |mut output| async move {
let mut state = State::Connecting(0);
loop {
state = start_listening(state, &mut output).await;
}
}),
)
}
async fn start_listening(
state: State,
output: &mut futures::channel::mpsc::Sender<Event>,
) -> State {
match state {
// Waiting for Connection to succeed
State::Connecting(mut disconnect_count) => {
let mut guard = FROM_PULSE.lock().await;
let (from_pulse, to_pulse) = {
if guard.is_none() {
let PulseHandle {
to_pulse,
from_pulse,
} = PulseHandle::new();
_ = output.send(Event::Init(Connection(to_pulse.clone()))).await;
*guard = Some((from_pulse, to_pulse));
}
guard.as_mut().unwrap()
};
to_pulse
.send(Message::UpdateConnection)
.await
.expect("Failed to request connection update");
match from_pulse.recv().await {
Some(Message::Connected) => {
disconnect_count = 0;
_ = output.send(Event::Connected).await;
State::Connected
}
Some(Message::Disconnected) => {
disconnect_count += 1;
_ = output.send(Event::Disconnected).await;
tokio::time::sleep(Duration::from_millis(
2_usize
.saturating_pow(disconnect_count.try_into().unwrap_or(u32::MAX))
.try_into()
.unwrap_or(u64::MAX),
))
.await;
State::Connecting(1)
}
Some(m) => {
tracing::error!("Unexpected message: {:?}", m);
State::Connecting(1)
}
None => {
panic!("Pulse Sender dropped, something has gone wrong!");
}
}
}
State::Connected => {
let mut guard = FROM_PULSE.lock().await;
let Some((from_pulse, _)) = guard.as_mut() else {
return State::Connecting(1);
};
// This is where we match messages from the pulse server to pass to the gui
match from_pulse.recv().await {
Some(Message::SetSinks(sinks)) => {
_ = output
.send(Event::MessageReceived(Message::SetSinks(sinks)))
.await;
State::Connected
}
Some(Message::SetSources(sources)) => {
_ = output
.send(Event::MessageReceived(Message::SetSources(sources)))
.await;
State::Connected
}
Some(Message::SetDefaultSink(sink)) => {
_ = output
.send(Event::MessageReceived(Message::SetDefaultSink(sink)))
.await;
State::Connected
}
Some(Message::SetDefaultSource(source)) => {
_ = output
.send(Event::MessageReceived(Message::SetDefaultSource(source)))
.await;
State::Connected
}
Some(Message::Disconnected) => {
_ = output.send(Event::Disconnected).await;
State::Connecting(1)
}
None => {
_ = output.send(Event::Disconnected).await;
State::Connecting(1)
}
_ => State::Connected,
}
}
}
}
// #[derive(Debug)]
enum State {
Connecting(usize),
Connected,
}
#[derive(Debug, Clone)]
pub enum Event {
Init(Connection),
Connected,
Disconnected,
MessageReceived(Message),
}
#[derive(Debug, Clone)]
pub struct Connection(mpsc::Sender<Message>);
impl Connection {
pub fn send(&mut self, message: Message) {
if let Err(e) = self.0.try_send(message) {
match e {
mpsc::error::TrySendError::Closed(_) => {
tracing::error!(
"Failed to send message: PulseAudio server communication closed"
);
panic!();
}
mpsc::error::TrySendError::Full(_) => {
tracing::warn!("Failed to send message to PulseAudio server: channel is full");
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
Connected,
Disconnected,
GetSinks,
GetSources,
UpdateConnection,
SetSinks(Vec<DeviceInfo>),
SetSources(Vec<DeviceInfo>),
GetDefaultSink,
GetDefaultSource,
SetDefaultSink(DeviceInfo),
SetDefaultSource(DeviceInfo),
SetSinkVolumeByName(String, ChannelVolumes),
SetSourceVolumeByName(String, ChannelVolumes),
SetSinkMuteByName(String, bool),
SetSourceMuteByName(String, bool),
}
struct PulseHandle {
to_pulse: tokio::sync::mpsc::Sender<Message>,
from_pulse: tokio::sync::mpsc::Receiver<Message>,
}
impl PulseHandle {
// Create pulse server thread, and bidirectional comms
pub fn new() -> Self {
let (to_pulse, mut to_pulse_recv) = tokio::sync::mpsc::channel(50);
let (from_pulse_send, from_pulse) = tokio::sync::mpsc::channel(50);
// this thread should complete by pushing a completed message,
// or fail message. This should never complete/fail without pushing
// a message. This lets the iced subscription go to sleep while init
// finishes. TLDR: be very careful with error handling
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
// take `PulseServer` and handle reciver into async context
// to listen for messages that need to be passed to the pulseserver
// this lets us put the thread to sleep, but keep hold a single
// thread, because pulse audio's API is not multithreaded... at all
rt.block_on(async {
let mut server: Option<PulseServer> = None;
let mut msgs = Vec::new();
loop {
if let Some(msg) = to_pulse_recv.recv().await {
msgs.push(msg);
}
// Consume any additional messages in the channel.
while let Ok(msg) = to_pulse_recv.try_recv() {
// Deduplicate volume change messages.
if matches!(
msg,
Message::SetSinkVolumeByName(..) | Message::SetSourceVolumeByName(..)
) {
let last_msg = msgs.last_mut().unwrap(); //
if mem::discriminant(last_msg) == mem::discriminant(&msg) {
*last_msg = msg;
continue;
}
}
msgs.push(msg);
}
for msg in msgs.drain(..) {
match msg {
Message::GetDefaultSink => {
let Some(server) = server.as_mut() else {
continue;
};
match server.get_default_sink() {
Ok(sink) => {
if let Err(err) = from_pulse_send
.send(Message::SetDefaultSink(sink))
.await
{
tracing::error!("ERROR! {}", err);
}
}
Err(_) => Self::send_disconnected(&from_pulse_send).await,
}
}
Message::GetDefaultSource => {
let Some(server) = server.as_mut() else {
continue;
};
match server.get_default_source() {
Ok(source) => {
if let Err(err) = from_pulse_send
.send(Message::SetDefaultSource(source))
.await
{
tracing::error!("ERROR! {}", err);
}
}
Err(e) => {
tracing::error!("ERROR! {:?}", e);
Self::send_disconnected(&from_pulse_send).await;
}
}
}
Message::GetSinks => {
let Some(server) = server.as_mut() else {
continue;
};
match server.get_sinks() {
Ok(sinks) => {
if let Err(err) =
from_pulse_send.send(Message::SetSinks(sinks)).await
{
tracing::error!("ERROR! {}", err);
}
}
Err(_) => Self::send_disconnected(&from_pulse_send).await,
}
}
Message::GetSources => {
let Some(server) = server.as_mut() else {
continue;
};
match server.get_sources() {
Ok(sinks) => {
if let Err(err) =
from_pulse_send.send(Message::SetSources(sinks)).await
{
tracing::error!("ERROR! {}", err);
}
}
Err(_) => Self::send_disconnected(&from_pulse_send).await,
}
}
Message::SetSinkVolumeByName(name, channel_volumes) => {
let Some(server) = server.as_mut() else {
continue;
};
server.set_sink_volume_by_name(&name, &channel_volumes);
}
Message::SetSourceVolumeByName(name, channel_volumes) => {
let Some(server) = server.as_mut() else {
continue;
};
server.set_source_volume_by_name(&name, &channel_volumes);
}
Message::SetSinkMuteByName(name, mute) => {
let Some(server) = server.as_mut() else {
continue;
};
let op =
server.introspector.set_sink_mute_by_name(&name, mute, None);
server.wait_for_result(op).ok();
}
Message::SetSourceMuteByName(name, mute) => {
let Some(server) = server.as_mut() else {
continue;
};
let op = server
.introspector
.set_source_mute_by_name(&name, mute, None);
server.wait_for_result(op).ok();
}
Message::UpdateConnection => {
tracing::info!(
"Updating Connection, server exists: {:?}",
server.is_some()
);
if let Some(mut cur_server) = server.take() {
if cur_server.get_server_info().is_err() {
tracing::warn!("got error, server must be disconnected...");
Self::send_disconnected(&from_pulse_send).await;
} else {
tracing::info!("got server info, still connected...");
server = Some(cur_server);
Self::send_connected(&from_pulse_send).await;
}
} else {
match PulseServer::connect().and_then(PulseServer::init) {
Ok(new_server) => {
tracing::info!("Connected to server");
Self::send_connected(&from_pulse_send).await;
server = Some(new_server);
}
Err(err) => {
tracing::error!(
"Failed to connect to server: {:?}",
err
);
Self::send_disconnected(&from_pulse_send).await;
}
}
}
}
Message::SetDefaultSink(device) => {
let Some(server) = server.as_mut() else {
continue;
};
let Ok(default_sink) = server.get_default_sink() else {
continue;
};
let to_move = server.get_sink_inputs(default_sink.index);
if let Some(name) = device.name.as_ref() {
if server.set_default_sink(name, to_move) {
if let Err(err) = from_pulse_send
.send(Message::SetDefaultSink(device))
.await
{
tracing::error!("ERROR! {:?}", err);
};
}
}
}
Message::SetDefaultSource(device) => {
let Some(server) = server.as_mut() else {
continue;
};
let Ok(default_source) = server.get_default_source() else {
continue;
};
let to_move = server.get_source_outputs(default_source.index);
if let Some(name) = device.name.as_ref() {
if server.set_default_source(name, to_move) {
if let Err(err) = from_pulse_send
.send(Message::SetDefaultSource(device))
.await
{
tracing::error!("ERROR! {:?}", err);
}
}
}
}
_ => {
tracing::warn!("message doesn't match");
}
}
}
}
});
});
Self {
to_pulse,
from_pulse,
}
}
async fn send_disconnected(sender: &tokio::sync::mpsc::Sender<Message>) {
sender.send(Message::Disconnected).await.unwrap();
}
#[allow(dead_code)]
async fn send_connected(sender: &tokio::sync::mpsc::Sender<Message>) {
sender.send(Message::Connected).await.unwrap();
}
}
struct PulseServer {
mainloop: Rc<RefCell<Mainloop>>,
context: Rc<RefCell<Context>>,
introspector: Introspector,
}
#[derive(Clone, Debug)]
enum PulseServerError<'a> {
IterateErr(IterateResult),
ContextErr(pulse::context::State),
OperationErr(pulse::operation::State),
PAErr(PAErr),
Connect,
Misc(&'a str),
}
// `PulseServer` code is heavily inspired by Dave Patrick Caberto's pulsectl-rs (SeaDve)
// https://crates.io/crates/pulsectl-rs
impl PulseServer {
// connect() requires init() to be run after
pub fn connect() -> Result<Self, PulseServerError<'static>> {
// TODO: fix app name, should be variable
let mut proplist = Proplist::new().unwrap();
proplist
.set_str(
pulse::proplist::properties::APPLICATION_NAME,
"com.system76",
)
.or(Err(PulseServerError::Connect))?;
let mainloop = Rc::new(RefCell::new(
pulse::mainloop::standard::Mainloop::new().ok_or(PulseServerError::Connect)?,
));
let context = Rc::new(RefCell::new(
Context::new_with_proplist(&*mainloop.borrow(), "MainConn", &proplist)
.ok_or(PulseServerError::Connect)?,
));
let introspector = context.borrow_mut().introspect();
context
.borrow_mut()
.connect(None, pulse::context::FlagSet::NOFLAGS, None)
.map_err(PulseServerError::PAErr)?;
Ok(Self {
mainloop,
context,
introspector,
})
}
// Wait for pulse audio connection to complete
pub fn init(self) -> Result<Self, PulseServerError<'static>> {
loop {
match self.mainloop.borrow_mut().iterate(false) {
IterateResult::Success(_) => {}
IterateResult::Err(e) => {
return Err(PulseServerError::IterateErr(IterateResult::Err(e)));
}
IterateResult::Quit(e) => {
return Err(PulseServerError::IterateErr(IterateResult::Quit(e)));
}
}
match self.context.borrow().get_state() {
pulse::context::State::Ready => break,
pulse::context::State::Failed => {
return Err(PulseServerError::ContextErr(pulse::context::State::Failed));
}
pulse::context::State::Terminated => {
return Err(PulseServerError::ContextErr(
pulse::context::State::Terminated,
));
}
_ => {}
}
}
Ok(self)
}
// Get a list of output devices
pub fn get_sinks(&self) -> Result<Vec<DeviceInfo>, PulseServerError<'_>> {
let list: Rc<RefCell<Option<Vec<DeviceInfo>>>> = Rc::new(RefCell::new(Some(Vec::new())));
let list_ref = list.clone();
let operation = self.introspector.get_sink_info_list(
move |sink_list: ListResult<&pulse::context::introspect::SinkInfo>| {
if let ListResult::Item(item) = sink_list {
list_ref.borrow_mut().as_mut().unwrap().push(item.into());
}
},
);
self.wait_for_result(operation).and_then(|()| {
list.borrow_mut().take().ok_or(PulseServerError::Misc(
"get_sinks(): failed to wait for operation",
))
})
}
// Get a list of input devices
pub fn get_sources(&self) -> Result<Vec<DeviceInfo>, PulseServerError<'_>> {
let list: Rc<RefCell<Option<Vec<DeviceInfo>>>> = Rc::new(RefCell::new(Some(Vec::new())));
let list_ref = list.clone();
let operation = self.introspector.get_source_info_list(
move |sink_list: ListResult<&pulse::context::introspect::SourceInfo>| {
if let ListResult::Item(item) = sink_list {
list_ref.borrow_mut().as_mut().unwrap().push(item.into());
}
},
);
self.wait_for_result(operation).and_then(|()| {
list.borrow_mut().take().ok_or(PulseServerError::Misc(
"get_sources(): Failed to wait for operation",
))
})
}
pub fn get_server_info(&mut self) -> Result<ServerInfo, PulseServerError<'_>> {
let info = Rc::new(RefCell::new(Some(None)));
let info_ref = info.clone();
let op = self.introspector.get_server_info(move |res| {
info_ref.borrow_mut().as_mut().unwrap().replace(res.into());
});
self.wait_for_result(op)?;
info.take()
.flatten()
.ok_or(PulseServerError::Misc("get_server_info(): failed"))
}
fn set_default_sink(&mut self, sink: &str, to_move: Vec<u32>) -> bool {
let set_default_success = Rc::new(RefCell::new(false));
let set_default_success_ref = set_default_success.clone();
let op = self
.context
.borrow_mut()
.set_default_sink(sink, move |ret| {
*set_default_success.borrow_mut() = ret;
});
self.wait_for_result(op).ok();
if !set_default_success_ref.replace(true) {
return false;
}
for index in to_move {
let move_success = Rc::new(RefCell::new(false));
let op = self.introspector.move_sink_input_by_name(
index,
sink,
Some(Box::new(move |ret| {
*move_success.borrow_mut() = ret;
})),
);
self.wait_for_result(op).ok();
}
// TODO handle errors
true
}
fn set_default_source(&mut self, sink: &str, to_move: Vec<u32>) -> bool {
let set_default_success = Rc::new(RefCell::new(false));
let set_default_success_ref = set_default_success.clone();
let op = self
.context
.borrow_mut()
.set_default_source(sink, move |ret| {
*set_default_success.borrow_mut() = ret;
});
self.wait_for_result(op).ok();
if !set_default_success_ref.replace(true) {
return false;
}
for index in to_move {
let move_success = Rc::new(RefCell::new(false));
let op = self.introspector.move_source_output_by_name(
index,
sink,
Some(Box::new(move |ret| {
*move_success.borrow_mut() = ret;
})),
);
self.wait_for_result(op).ok();
}
true
}
fn get_default_sink(&mut self) -> Result<DeviceInfo, PulseServerError<'_>> {
let server_info = self.get_server_info();
match server_info {
Ok(info) => {
let name = &info.default_sink_name.unwrap_or_default();
let device = Rc::new(RefCell::new(Some(None)));
let dev_ref = device.clone();
let op = self.introspector.get_sink_info_by_name(
name,
move |sink_list: ListResult<&SinkInfo>| {
if let ListResult::Item(item) = sink_list {
dev_ref.borrow_mut().as_mut().unwrap().replace(item.into());
}
},
);
self.wait_for_result(op)?;
let mut result = device.borrow_mut();
result.take().unwrap().ok_or({
PulseServerError::Misc("get_default_sink(): Error getting requested device")
})
}
Err(_) => Err(PulseServerError::Misc("get_default_sink() failed")),
}
}
fn get_default_source(&mut self) -> Result<DeviceInfo, PulseServerError<'_>> {
let server_info = self.get_server_info();
match server_info {
Ok(info) => {
let name = &info.default_source_name.unwrap_or_default();
let device = Rc::new(RefCell::new(Some(None)));
let dev_ref = device.clone();
let op = self.introspector.get_source_info_by_name(
name,
move |sink_list: ListResult<&SourceInfo>| {
if let ListResult::Item(item) = sink_list {
dev_ref.borrow_mut().as_mut().unwrap().replace(item.into());
}
},
);
self.wait_for_result(op)?;
let mut result = device.borrow_mut();
result.take().unwrap().ok_or({
PulseServerError::Misc("get_default_source(): Error getting requested device")
})
}
Err(_) => Err(PulseServerError::Misc("get_default_source() failed")),
}
}
fn set_sink_volume_by_name(&mut self, name: &str, volume: &ChannelVolumes) {
let op = self
.introspector
.set_sink_mute_by_name(name, volume.is_muted(), None);
self.wait_for_result(op).ok();
let op = self
.introspector
.set_sink_volume_by_name(name, volume, None);
self.wait_for_result(op).ok();
}
fn set_source_volume_by_name(&mut self, name: &str, volume: &ChannelVolumes) {
let op = self
.introspector
.set_source_mute_by_name(name, volume.is_muted(), None);
let _ = self.wait_for_result(op);
let op = self
.introspector
.set_source_volume_by_name(name, volume, None);
let _ = self.wait_for_result(op);
}
fn get_source_outputs(&mut self, source: u32) -> Vec<u32> {
let result = Rc::new(RefCell::new(Vec::new()));
let result_ref = Rc::new(RefCell::new(Vec::new()));
let op = self.introspector.get_source_output_info_list(move |list| {
if let ListResult::Item(item) = list {
if source == item.source {
result.borrow_mut().push(item.index);
}
}
});
let _ = self.wait_for_result(op);
result_ref.replace(Vec::new())
}
fn get_sink_inputs(&mut self, sink: u32) -> Vec<u32> {
let result = Rc::new(RefCell::new(Vec::new()));
let result_ref = Rc::new(RefCell::new(Vec::new()));
let op = self.introspector.get_sink_input_info_list(move |list| {
if let ListResult::Item(item) = list {
if sink == item.sink {
result.borrow_mut().push(item.index);
}
}
});
let _ = self.wait_for_result(op);
result_ref.replace(Vec::new())
}
// after building an operation such as get_devices() we need to keep polling
// the pulse audio server to "wait" for the operation to complete
fn wait_for_result<G: ?Sized>(
&self,
operation: pulse::operation::Operation<G>,
) -> Result<(), PulseServerError<'_>> {
// TODO: make this loop async. It is already in an async context, so
// we could make this thread sleep while waiting for the pulse server's
// response.
loop {
match self.mainloop.borrow_mut().iterate(false) {
IterateResult::Err(e) => {
return Err(PulseServerError::IterateErr(IterateResult::Err(e)));
}
IterateResult::Quit(e) => {
return Err(PulseServerError::IterateErr(IterateResult::Quit(e)));
}
IterateResult::Success(_) => {}
}
match operation.get_state() {
pulse::operation::State::Done => return Ok(()),
pulse::operation::State::Running => {}
pulse::operation::State::Cancelled => {
return Err(PulseServerError::OperationErr(
pulse::operation::State::Cancelled,
));
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeviceInfo {
pub name: Option<String>,
pub description: Option<String>,
pub volume: ChannelVolumes,
pub mute: bool,
pub index: u32,
}
impl<'a> From<&SinkInfo<'a>> for DeviceInfo {
fn from(info: &SinkInfo<'a>) -> Self {
Self {
name: info.name.as_deref().map(str::to_string),
description: info.description.as_deref().map(str::to_string),
volume: info.volume,
mute: info.mute,
index: info.index,
}
}
}
impl<'a> From<&SourceInfo<'a>> for DeviceInfo {
fn from(info: &SourceInfo<'a>) -> Self {
Self {
name: info.name.as_deref().map(str::to_string),
description: info.description.as_deref().map(str::to_string),
volume: info.volume,
mute: info.mute,
index: info.index,
}
}
}
impl Eq for DeviceInfo {}
#[derive(Debug)]
pub struct ServerInfo {
/// User name of the daemon process.
pub user_name: Option<String>,
/// Host name the daemon is running on.
pub host_name: Option<String>,
/// Version string of the daemon.
pub server_version: Option<String>,
/// Server package name (usually “pulseaudio”).
pub server_name: Option<String>,
// Default sample specification.
//pub sample_spec: sample::Spec,
/// Name of default sink.
pub default_sink_name: Option<String>,
/// Name of default source.
pub default_source_name: Option<String>,
/// A random cookie for identifying this instance of PulseAudio.
pub cookie: u32,
// Default channel map.
//pub channel_map: channelmap::Map,
}
impl<'a> From<&'a pulse::context::introspect::ServerInfo<'a>> for ServerInfo {
fn from(info: &'a pulse::context::introspect::ServerInfo<'a>) -> Self {
use std::borrow::Cow;
Self {
user_name: info.user_name.as_ref().map(Cow::to_string),
host_name: info.host_name.as_ref().map(Cow::to_string),
server_version: info.server_version.as_ref().map(Cow::to_string),
server_name: info.server_name.as_ref().map(Cow::to_string),
//sample_spec: info.sample_spec,
default_sink_name: info.default_sink_name.as_ref().map(Cow::to_string),
default_source_name: info.default_source_name.as_ref().map(Cow::to_string),
cookie: info.cookie,
//channel_map: info.channel_map,
}
}
}

View file

@ -6,10 +6,6 @@ license = "GPL-3.0-only"
[dependencies]
anyhow.workspace = true
cosmic-settings-subscriptions = { workspace = true, features = [
"upower",
"settings_daemon",
] }
cosmic-time.workspace = true
drm = "0.14.1"
futures.workspace = true
@ -24,3 +20,9 @@ tracing-subscriber.workspace = true
tracing.workspace = true
udev = "0.9"
zbus.workspace = true
[dependencies.cosmic-settings-upower-subscription]
git = "https://github.com/pop-os/cosmic-settings"
[dependencies.cosmic-settings-daemon-subscription]
git = "https://github.com/pop-os/cosmic-settings"

View file

@ -29,15 +29,12 @@ use cosmic::{
surface, theme,
widget::{divider, horizontal_space, icon, scrollable, slider, text, vertical_space},
};
use cosmic_settings_subscriptions::{
settings_daemon,
upower::{
use cosmic_settings_daemon_subscription as settings_daemon;
use cosmic_settings_upower_subscription::{
device::{DeviceDbusEvent, device_subscription},
kbdbacklight::{
KeyboardBacklightRequest, KeyboardBacklightUpdate, kbd_backlight_subscription,
},
},
kbdbacklight::{KeyboardBacklightRequest, KeyboardBacklightUpdate, kbd_backlight_subscription},
};
use cosmic_time::{Instant, Timeline, anim, chain, id};
use rustc_hash::FxHashMap;