From c63ae640294dc4995150752668f14d219191668b Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Thu, 15 Aug 2024 09:40:12 +0200 Subject: [PATCH] feat(sound): sound profiles, bluetooth codecs, and fixes --- Cargo.lock | 2 +- cosmic-settings/Cargo.toml | 1 + .../src/pages/desktop/workspaces.rs | 6 +- cosmic-settings/src/pages/input/mod.rs | 2 +- cosmic-settings/src/pages/sound.rs | 273 +++++++++++++----- cosmic-settings/src/pages/time/date.rs | 6 +- i18n/en/cosmic_settings.ftl | 2 + 7 files changed, 211 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 161cdfa..5256780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,7 +1621,7 @@ dependencies = [ [[package]] name = "cosmic-settings-subscriptions" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-settings-subscriptions#9634f708d079f4b6336e02ed3e22ede6ee7f7cdb" +source = "git+https://github.com/pop-os/cosmic-settings-subscriptions#f6fa655e4b74a5bd2dbfc2f6fdd94bc78f5e4fcc" dependencies = [ "futures", "iced_futures", diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 2d5e276..6adbe25 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -57,6 +57,7 @@ slab = "0.4.9" [dependencies.cosmic-settings-subscriptions] git = "https://github.com/pop-os/cosmic-settings-subscriptions" +# path = "../../cosmic-settings-subscriptions" features = ["pipewire", "pulse"] [dependencies.icu] diff --git a/cosmic-settings/src/pages/desktop/workspaces.rs b/cosmic-settings/src/pages/desktop/workspaces.rs index 5b6a70d..744aa24 100644 --- a/cosmic-settings/src/pages/desktop/workspaces.rs +++ b/cosmic-settings/src/pages/desktop/workspaces.rs @@ -36,7 +36,7 @@ impl Default for Page { fn default() -> Self { let comp_config = cosmic_config::Config::new("com.system76.CosmicComp", 1).unwrap(); let comp_workspace_config = comp_config.get("workspaces").unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'workspaces'"); } @@ -44,14 +44,14 @@ impl Default for Page { }); let config = cosmic_config::Config::new("com.system76.CosmicWorkspaces", 1).unwrap(); let show_workspace_name = config.get("show_workspace_name").unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'show_workspace_name'"); } false }); let show_workspace_number = config.get("show_workspace_number").unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'show_workspace_number'"); } diff --git a/cosmic-settings/src/pages/input/mod.rs b/cosmic-settings/src/pages/input/mod.rs index b34239c..b211745 100644 --- a/cosmic-settings/src/pages/input/mod.rs +++ b/cosmic-settings/src/pages/input/mod.rs @@ -46,7 +46,7 @@ fn get_config( key: &str, ) -> T { config.get(key).unwrap_or_else(|why| { - if !matches!(cosmic_config::Error::NoConfigDirectory, why) { + if !matches!(why, cosmic_config::Error::NoConfigDirectory) { error!(?why, "Failed to read config '{}'", key); } diff --git a/cosmic-settings/src/pages/sound.rs b/cosmic-settings/src/pages/sound.rs index ff84ae3..1684e69 100644 --- a/cosmic-settings/src/pages/sound.rs +++ b/cosmic-settings/src/pages/sound.rs @@ -1,11 +1,11 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only -use std::{collections::HashMap, time::Duration}; +use std::{collections::BTreeMap, time::Duration}; use cosmic::{ widget::{self, settings}, - Apply, Command, Element, + Command, Element, }; use cosmic_settings_page::{self as page, section, Section}; use cosmic_settings_subscriptions::{pipewire, pulse}; @@ -19,39 +19,43 @@ pub enum Message { Pulse(pulse::Event), /// Get ALSA cards and their profiles. Pipewire(pipewire::DeviceEvent), - /// Change the default microphone input. + /// Change the default output. SinkChanged(usize), - /// Request to change the default microphone volume. + /// Change the active profile for an output. + SinkProfileChanged(usize), + /// Request to change the default output volume. SinkVolumeChanged(u32), - /// Change the microphone volume. + /// Change the output volume. SinkVolumeApply(NodeId), - /// Toggle the mute status of the microphone. + /// Toggle the mute status of the output. SinkMuteToggle, - /// Change the default speaker output. + /// Change the default input output. SourceChanged(usize), - /// Request to change the speaker volume. + /// Change the active profile for an output. + SourceProfileChanged(usize), + /// Request to change the input volume. SourceVolumeChanged(u32), - /// Change the speaker volume. + /// Change the input volume. SourceVolumeApply(NodeId), - /// Toggle the mute status of the speaker output. + /// Toggle the mute status of the input output. SourceMuteToggle, } pub type NodeId = u32; pub type ProfileId = u32; +#[derive(Debug)] struct Card { - class: pipewire::MediaClass, - // name: String, - profiles: HashMap, + devices: BTreeMap, } -struct Profile { - // device: ProfileId, +#[derive(Debug)] +struct Port { + class: pipewire::MediaClass, identifier: String, } -#[derive(Clone, Eq, Hash, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] enum DeviceId { Alsa(u32), Bluez5(String), @@ -61,7 +65,12 @@ enum DeviceId { pub struct Page { pipewire_thread: Option<(tokio::sync::oneshot::Sender<()>, pipewire::Sender<()>)>, pulse_thread: Option>, - devices: HashMap, + devices: BTreeMap, + card_names: BTreeMap, + card_ports: BTreeMap>, + card_profiles: BTreeMap>, + active_profiles: BTreeMap>, + default_sink: String, default_source: String, @@ -77,10 +86,19 @@ pub struct Page { sinks: Vec, sink_ids: Vec, + sink_profiles: Vec, + sink_profile_names: Vec, sources: Vec, source_ids: Vec, + source_profiles: Vec, + source_profile_names: Vec, + active_sink: Option, + active_sink_device: Option, + active_sink_profile: Option, active_source: Option, + active_source_device: Option, + active_source_profile: Option, } impl page::Page for Page { @@ -88,12 +106,7 @@ impl page::Page for Page { &self, sections: &mut SlotMap>, ) -> Option { - Some(vec![ - sections.insert(output()), - sections.insert(input()), - // sections.insert(alerts()), - // sections.insert(applications()), - ]) + Some(vec![sections.insert(output()), sections.insert(input())]) } fn info(&self) -> page::Info { @@ -183,12 +196,53 @@ impl page::Page for Page { impl page::AutoBind for Page {} impl Page { + fn device_profiles(&self, device_id: &DeviceId) -> (Vec, Vec, Option) { + let (profiles, profile_descriptions): (Vec, Vec) = self + .card_profiles + .get(device_id) + .map_or((Vec::new(), Vec::new()), |profiles| { + profiles + .iter() + // TODO: Allow disabling + .filter(|p| p.available && p.description != "Off") + .map(|p| (p.name.clone(), p.description.clone())) + .collect() + }); + + let active_profile = self.active_profiles.get(device_id).and_then(|profile| { + profile + .as_ref() + .and_then(|profile| profiles.iter().position(|p| p == profile)) + }); + + (profiles, profile_descriptions, active_profile) + } + + fn set_sink_profiles(&mut self, device_id: &DeviceId) { + ( + self.sink_profile_names, + self.sink_profiles, + self.active_sink_profile, + ) = self.device_profiles(device_id); + } + + fn set_source_profiles(&mut self, device_id: &DeviceId) { + ( + self.source_profile_names, + self.source_profiles, + self.active_source_profile, + ) = self.device_profiles(device_id); + } + fn set_default_sink(&mut self) { - for card in self.devices.values() { - if let pipewire::MediaClass::Sink = card.class { - for (&node_id, profile) in &card.profiles { - if profile.identifier == self.default_sink { + for (device_id, card) in &self.devices { + for (&node_id, device) in &card.devices { + if let pipewire::MediaClass::Sink = device.class { + if device.identifier == self.default_sink { self.active_sink = self.sink_ids.iter().position(|&id| id == node_id); + let device_id = device_id.clone(); + self.set_sink_profiles(&device_id); + self.active_sink_device = Some(device_id); return; } } @@ -197,11 +251,14 @@ impl Page { } fn set_default_source(&mut self) { - for card in self.devices.values() { - if let pipewire::MediaClass::Source = card.class { - for (&node_id, profile) in &card.profiles { - if profile.identifier == self.default_source { + for (device_id, card) in &self.devices { + for (&node_id, device) in &card.devices { + if let pipewire::MediaClass::Source = device.class { + if device.identifier == self.default_source { self.active_source = self.source_ids.iter().position(|&id| id == node_id); + let device_id = device_id.clone(); + self.set_source_profiles(&device_id); + self.active_source_device = Some(device_id); return; } } @@ -289,13 +346,33 @@ impl Page { self.source_mute = mute; } + Message::Pulse(pulse::Event::CardInfo(card)) => { + let device_id = match card.variant { + pulse::DeviceVariant::Alsa { alsa_card, .. } => DeviceId::Alsa(alsa_card), + pulse::DeviceVariant::Bluez5 { address, .. } => DeviceId::Bluez5(address), + }; + + self.card_names.insert(device_id.clone(), card.name); + self.card_ports.insert(device_id.clone(), card.ports); + self.card_profiles.insert(device_id.clone(), card.profiles); + self.active_profiles + .insert(device_id, card.active_profile.map(|p| p.name)); + } + Message::Pipewire(pipewire::DeviceEvent::Add(device)) => { + let device_id = match device.variant { + pipewire::DeviceVariant::Alsa { alsa_card, .. } => DeviceId::Alsa(alsa_card), + pipewire::DeviceVariant::Bluez5 { address, .. } => DeviceId::Bluez5(address), + }; + match device.media_class { pipewire::MediaClass::Sink => { self.sinks.push(device.node_description.clone()); self.sink_ids.push(device.object_id); if self.default_sink == device.node_name { self.active_sink = Some(self.sinks.len() - 1); + self.active_sink_device = Some(device_id.clone()); + self.set_sink_profiles(&device_id); } } @@ -304,40 +381,30 @@ impl Page { self.source_ids.push(device.object_id); if self.default_source == device.node_name { self.active_source = Some(self.sources.len() - 1); + self.active_source_device = Some(device_id.clone()); + self.set_source_profiles(&device_id); } } } - let card = self - .devices - .entry(match device.variant { - pipewire::DeviceVariant::Alsa { alsa_card, .. } => { - DeviceId::Alsa(alsa_card) - } - pipewire::DeviceVariant::Bluez5 { address, .. } => { - DeviceId::Bluez5(address) - } - }) - .or_insert_with(|| Card { - class: device.media_class, - // name: device.alsa_card_name, - profiles: HashMap::new(), - }); + let card = self.devices.entry(device_id).or_insert_with(|| Card { + devices: BTreeMap::new(), + }); - card.profiles.insert( + card.devices.insert( device.object_id, - Profile { - // device: device.card_profile_device, + Port { + class: device.media_class, identifier: device.node_name, }, ); } - Message::Pipewire(pipewire::DeviceEvent::Remove(device_id)) => { + Message::Pipewire(pipewire::DeviceEvent::Remove(node_id)) => { let mut remove = None; for (card_id, card) in &mut self.devices { - if card.profiles.remove(&device_id).is_some() { - if card.profiles.is_empty() { + if card.devices.remove(&node_id).is_some() { + if card.devices.is_empty() { remove = Some(card_id.clone()); } break; @@ -348,17 +415,21 @@ impl Page { _ = self.devices.remove(&card_id); } - if let Some(pos) = self.sink_ids.iter().position(|&id| id == device_id) { + if let Some(pos) = self.sink_ids.iter().position(|&id| id == node_id) { _ = self.sink_ids.remove(pos); _ = self.sinks.remove(pos); if self.active_sink == Some(pos) { self.active_sink = None; + self.active_sink_device = None; + self.active_sink_profile = None; } - } else if let Some(pos) = self.source_ids.iter().position(|&id| id == device_id) { + } else if let Some(pos) = self.source_ids.iter().position(|&id| id == node_id) { _ = self.source_ids.remove(pos); _ = self.sources.remove(pos); if self.active_source == Some(pos) { self.active_source = None; + self.active_source_device = None; + self.active_source_profile = None; } } } @@ -400,6 +471,32 @@ impl Page { wpctl_set_mute(node_id, self.source_mute); } } + + Message::SinkProfileChanged(profile) => { + self.active_sink_profile = Some(profile); + if let Some(profile) = self.sink_profile_names.get(profile) { + if let Some(ref device_id) = self.active_sink_device { + if let Some(name) = self.card_names.get(device_id) { + pactl_set_card_profile(name.clone(), profile.clone()); + self.active_profiles + .insert(device_id.clone(), Some(profile.clone())); + } + } + } + } + + Message::SourceProfileChanged(profile) => { + self.active_source_profile = Some(profile); + if let Some(profile) = self.source_profile_names.get(profile) { + if let Some(ref device_id) = self.active_source_device { + if let Some(name) = self.card_names.get(device_id) { + pactl_set_card_profile(name.clone(), profile.clone()); + self.active_profiles + .insert(device_id.clone(), Some(profile.clone())); + } + } + } + } } Command::none() } @@ -411,6 +508,7 @@ fn input() -> Section { let volume = descriptions.insert(fl!("sound-input", "volume")); let device = descriptions.insert(fl!("sound-input", "device")); let _level = descriptions.insert(fl!("sound-input", "level")); + let profile = descriptions.insert(fl!("profile")); Section::default() .title(fl!("sound-input")) @@ -440,14 +538,24 @@ fn input() -> Section { Message::SourceChanged, ); - settings::view_section(§ion.title) + let mut controls = settings::view_section(§ion.title) .add(settings::item( &*section.descriptions[volume], volume_control, )) - .add(settings::item(&*section.descriptions[device], devices)) - .apply(Element::from) - .map(crate::pages::Message::Sound) + .add(settings::item(&*section.descriptions[device], devices)); + + if !page.source_profiles.is_empty() { + let dropdown = widget::dropdown( + &page.source_profiles, + page.active_source_profile, + Message::SourceProfileChanged, + ); + + controls = controls.add(settings::item(&*section.descriptions[profile], dropdown)); + } + + Element::from(controls).map(crate::pages::Message::Sound) }) } @@ -457,7 +565,7 @@ fn output() -> Section { let volume = descriptions.insert(fl!("sound-output", "volume")); let device = descriptions.insert(fl!("sound-output", "device")); let _level = descriptions.insert(fl!("sound-output", "level")); - // let config = descriptions.insert(fl!("sound-output", "config")); + let profile = descriptions.insert(fl!("profile")); // let balance = descriptions.insert(fl!("sound-output", "balance")); Section::default() @@ -488,14 +596,24 @@ fn output() -> Section { Message::SinkChanged, ); - settings::view_section(§ion.title) + let mut controls = settings::view_section(§ion.title) .add(settings::item( &*section.descriptions[volume], volume_control, )) - .add(settings::item(&*section.descriptions[device], devices)) - .apply(Element::from) - .map(crate::pages::Message::Sound) + .add(settings::item(&*section.descriptions[device], devices)); + + if !page.sink_profiles.is_empty() { + let dropdown = widget::dropdown( + &page.sink_profiles, + page.active_sink_profile, + Message::SinkProfileChanged, + ); + + controls = controls.add(settings::item(&*section.descriptions[profile], dropdown)); + } + + Element::from(controls).map(crate::pages::Message::Sound) }) } @@ -533,6 +651,15 @@ fn output() -> Section { // }) // } +fn pactl_set_card_profile(id: String, profile: String) { + tokio::task::spawn(async move { + _ = tokio::process::Command::new("pactl") + .args(&["set-card-profile", id.as_str(), profile.as_str()]) + .status() + .await + }); +} + fn wpctl_set_default(id: u32) { tokio::task::spawn(async move { let default = id.to_string(); @@ -543,6 +670,16 @@ fn wpctl_set_default(id: u32) { }); } +fn wpctl_set_mute(id: u32, mute: bool) { + tokio::task::spawn(async move { + let default = id.to_string(); + _ = tokio::process::Command::new("wpctl") + .args(&["set-mute", default.as_str(), if mute { "1" } else { "0" }]) + .status() + .await; + }); +} + fn wpctl_set_volume(id: u32, volume: u32) { tokio::task::spawn(async move { let id = id.to_string(); @@ -553,13 +690,3 @@ fn wpctl_set_volume(id: u32, volume: u32) { .await; }); } - -fn wpctl_set_mute(id: u32, mute: bool) { - tokio::task::spawn(async move { - let default = id.to_string(); - _ = tokio::process::Command::new("wpctl") - .args(&["set-mute", default.as_str(), if mute { "1" } else { "0" }]) - .status() - .await; - }); -} diff --git a/cosmic-settings/src/pages/time/date.rs b/cosmic-settings/src/pages/time/date.rs index 3976ca4..5192fd6 100644 --- a/cosmic-settings/src/pages/time/date.rs +++ b/cosmic-settings/src/pages/time/date.rs @@ -56,7 +56,7 @@ impl Default for Page { let military_time = cosmic_applet_config .get("military_time") .unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'military_time'"); } @@ -66,7 +66,7 @@ impl Default for Page { let first_day_of_week = cosmic_applet_config .get("first_day_of_week") .unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'first_day_of_week'"); } @@ -76,7 +76,7 @@ impl Default for Page { let show_date_in_top_panel = cosmic_applet_config .get("show_date_in_top_panel") .unwrap_or_else(|err| { - if !matches!(cosmic_config::Error::NoConfigDirectory, err) { + if !matches!(err, cosmic_config::Error::NoConfigDirectory) { error!(?err, "Failed to read config 'show_date_in_top_panel'"); } diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 523f92f..54ec1ea 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -286,6 +286,8 @@ sound-alerts = Alerts sound-applications = Applications .desc = Application volumes and settings +profile = Profile + ## Power power = Power & Battery