diff --git a/Cargo.lock b/Cargo.lock index c9bb037..07c14c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +dependencies = [ + "unicode-width", + "yansi-term", +] + [[package]] name = "anstream" version = "0.6.15" @@ -661,6 +671,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "annotate-snippets", + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.72", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -966,6 +997,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -1020,6 +1060,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.5", +] + [[package]] name = "clap" version = "4.5.11" @@ -1282,6 +1333,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1472,6 +1541,7 @@ dependencies = [ "cosmic-randr-shell", "cosmic-settings-config", "cosmic-settings-page", + "cosmic-settings-subscriptions", "cosmic-settings-system", "cosmic-settings-wallpaper", "derivative", @@ -1479,7 +1549,7 @@ dependencies = [ "dirs", "downcast-rs", "freedesktop-desktop-entry", - "futures-lite 2.3.0", + "futures", "generator 0.8.1", "hostname-validator", "hostname1-zbus", @@ -1548,6 +1618,23 @@ dependencies = [ "url", ] +[[package]] +name = "cosmic-settings-subscriptions" +version = "0.1.0" +source = "git+https://github.com/pop-os/cosmic-settings-subscriptions?branch=sound-settings#177d4781f1e976ac62af6d6f3f193f96c1544d76" +dependencies = [ + "futures", + "iced_futures", + "libpulse-binding", + "log", + "pipewire", + "rustix 0.38.34", + "tokio", + "tokio-stream", + "upower_dbus", + "zbus 4.4.0", +] + [[package]] name = "cosmic-settings-system" version = "0.1.0" @@ -2598,6 +2685,12 @@ version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "glow" version = "0.13.1" @@ -3892,6 +3985,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lebe" version = "0.5.2" @@ -3982,6 +4081,33 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libpulse-binding" +version = "2.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3557a2dfc380c8f061189a01c6ae7348354e0c9886038dc6c171219c08eaff" +dependencies = [ + "bitflags 1.3.2", + "libc", + "libpulse-sys", + "num-derive 0.3.3", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc19e110fbf42c17260d30f6d3dc545f58491c7830d38ecb9aaca96e26067a9b" +dependencies = [ + "libc", + "num-derive 0.3.3", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "libredox" version = "0.0.2" @@ -4003,6 +4129,34 @@ dependencies = [ "libc", ] +[[package]] +name = "libspa" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" +dependencies = [ + "bitflags 2.6.0", + "cc", + "convert_case", + "cookie-factory", + "libc", + "libspa-sys", + "nix 0.27.1", + "nom", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -4378,6 +4532,17 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -4484,6 +4649,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -4885,6 +5061,34 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" +dependencies = [ + "anyhow", + "bitflags 2.6.0", + "libc", + "libspa", + "libspa-sys", + "nix 0.27.1", + "once_cell", + "pipewire-sys", + "thiserror", +] + +[[package]] +name = "pipewire-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -5156,7 +5360,7 @@ dependencies = [ "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", - "num-derive", + "num-derive 0.4.2", "num-traits", "once_cell", "paste", @@ -5768,6 +5972,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -6740,6 +6950,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "upower_dbus" +version = "0.3.2" +source = "git+https://github.com/pop-os/dbus-settings-bindings#cd21ddcb1b5cbfc80ab84b34d3c8b1ff3d81179a" +dependencies = [ + "serde", + "serde_repr", + "zbus 4.4.0", +] + [[package]] name = "url" version = "2.5.2" @@ -7786,6 +8006,15 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" +[[package]] +name = "yansi-term" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1" +dependencies = [ + "winapi", +] + [[package]] name = "yazi" version = "0.1.6" diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 6bc3a81..ba5a267 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -26,7 +26,7 @@ derive_setters = "0.1.6" dirs = "5.0.1" downcast-rs = "1.2.1" freedesktop-desktop-entry = "0.7.0" -futures = { package = "futures-lite", version = "2.3.0" } +futures = "0.3.30" generator = "=0.8.1" hostname-validator = "1.1.1" hostname1-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings" } @@ -55,6 +55,12 @@ zbus = { version = "4.4.0", features = ["tokio"] } tachyonix = "0.3.0" slab = "0.4.9" +[dependencies.cosmic-settings-subscriptions] +git = "https://github.com/pop-os/cosmic-settings-subscriptions" +branch = "sound-settings" +# path = "../../cosmic-settings-subscriptions" +features = ["pipewire", "pulse"] + [dependencies.icu] version = "1.4.0" features = ["experimental", "compiled_data", "icu_datetime_experimental"] diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index a228765..3413b5f 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -140,7 +140,7 @@ impl cosmic::Application for SettingsApp { let desktop_id = app.insert_page::().id(); app.insert_page::(); - //app.insert_page::(); + app.insert_page::(); app.insert_page::(); app.insert_page::(); app.insert_page::(); @@ -381,6 +381,12 @@ impl cosmic::Application for SettingsApp { } } + crate::pages::Message::Sound(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + crate::pages::Message::SystemShortcuts(message) => { if let Some(page) = self .pages diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 22ca262..5629adc 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -35,6 +35,7 @@ pub enum Message { Panel(desktop::panel::Message), PanelApplet(desktop::panel::applets_inner::Message), Power(power::Message), + Sound(sound::Message), SystemShortcuts(input::keyboard::shortcuts::ShortcutMessage), TilingShortcuts(input::keyboard::shortcuts::ShortcutMessage), WindowManagement(desktop::window_management::Message), diff --git a/cosmic-settings/src/pages/sound.rs b/cosmic-settings/src/pages/sound.rs index 12fc03c..3e0a3da 100644 --- a/cosmic-settings/src/pages/sound.rs +++ b/cosmic-settings/src/pages/sound.rs @@ -1,13 +1,82 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only -use cosmic::widget::{settings, text}; +use std::{collections::HashMap, time::Duration}; + +use cosmic::{ + widget::{self, settings}, + Apply, Command, Element, +}; use cosmic_settings_page::{self as page, section, Section}; +use cosmic_settings_subscriptions::{pipewire, pulse}; +use futures::StreamExt; use slab::Slab; use slotmap::SlotMap; +#[derive(Clone, Debug)] +pub enum Message { + // Get default sinks/sources and their volumes/mute status. + Pulse(pulse::Event), + // Get ALSA cards and their profiles. + Pipewire(pipewire::DeviceEvent), + // Change the default microphone input. + SinkChanged(usize), + // Request to change the default microphone volume. + SinkVolumeChanged(u32), + // Change the microphone volume. + SinkVolumeApply(NodeId), + // Toggle the mute status of the microphone. + SinkMuteToggle, + // Change the default speaker output. + SourceChanged(usize), + // Request to change the speaker volume. + SourceVolumeChanged(u32), + // Change the speaker volume. + SourceVolumeApply(NodeId), + // Toggle the mute status of the speaker output. + SourceMuteToggle, +} + +pub type CardId = u32; +pub type NodeId = u32; +pub type ProfileId = u32; + +struct Card { + class: pipewire::MediaClass, + // name: String, + profiles: HashMap, +} + +struct Profile { + // device: ProfileId, + identifier: String, +} + #[derive(Default)] -pub struct Page; +pub struct Page { + pipewire_thread: Option<(tokio::sync::oneshot::Sender<()>, pipewire::Sender<()>)>, + pulse_thread: Option>, + alsa_cards: HashMap, + default_sink: String, + default_source: String, + + sink_volume: u32, + sink_volume_text: String, + sink_mute: bool, + sink_volume_debounce: bool, + + source_volume: u32, + source_volume_text: String, + source_mute: bool, + source_volume_debounce: bool, + + sinks: Vec, + sink_ids: Vec, + sources: Vec, + source_ids: Vec, + active_sink: Option, + active_source: Option, +} impl page::Page for Page { fn content( @@ -17,8 +86,8 @@ impl page::Page for Page { Some(vec![ sections.insert(output()), sections.insert(input()), - sections.insert(alerts()), - sections.insert(applications()), + // sections.insert(alerts()), + // sections.insert(applications()), ]) } @@ -27,42 +96,299 @@ impl page::Page for Page { .title(fl!("sound")) .description(fl!("sound", "desc")) } + + fn on_enter( + &mut self, + _page: cosmic_settings_page::Entity, + sender: tokio::sync::mpsc::Sender, + ) -> Command { + if self.pulse_thread.is_none() { + let sender = sender.clone(); + + let (tx, mut rx) = futures::channel::mpsc::channel(1); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + + // Listen to events from the pulse thread until the tx channel is closed. + _ = std::thread::spawn(move || { + pulse::thread(tx); + }); + + // Forward events from the pulse thread to the application until + // the application requests to stop listening to the pulse thread. + tokio::task::spawn(async move { + let forwarder = std::pin::pin!(async move { + while let Some(event) = rx.next().await { + let event = crate::pages::Message::Sound(Message::Pulse(event)); + if sender.send(event).await.is_err() { + break; + } + } + }); + + futures::future::select(std::pin::pin!(cancel_rx), forwarder).await; + }); + + self.pulse_thread = Some(cancel_tx); + } + + if self.pipewire_thread.is_none() { + let (tx, mut rx) = futures::channel::mpsc::channel(1); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + + // Listen to events from the pipewire thread until the tx channel is closed. + let (_handle, terminate) = pipewire::thread(tx); + + // Forward events from the pipewire thread to the application until + // the application requests to stop listening to the pulse thread. + tokio::task::spawn(async move { + let forwarder = std::pin::pin!(async move { + while let Some(event) = rx.next().await { + let event = crate::pages::Message::Sound(Message::Pipewire(event)); + if sender.send(event).await.is_err() { + break; + } + } + }); + + futures::future::select(std::pin::pin!(cancel_rx), forwarder).await; + }); + + self.pipewire_thread = Some((cancel_tx, terminate)); + } + + Command::none() + } + + fn on_leave(&mut self) -> Command { + if let Some(cancellation) = self.pulse_thread.take() { + _ = cancellation.send(()); + } + + if let Some((cancellation, terminate)) = self.pipewire_thread.take() { + _ = cancellation.send(()); + _ = terminate.send(()); + } + + Command::none() + } } impl page::AutoBind for Page {} -fn alerts() -> Section { - let mut descriptions = Slab::new(); - let volume = descriptions.insert(fl!("sound-alerts", "volume")); - let sound = descriptions.insert(fl!("sound-alerts", "sound")); +impl Page { + fn set_default_sink(&mut self) { + for card in self.alsa_cards.values() { + if let pipewire::MediaClass::Sink = card.class { + for (&node_id, profile) in &card.profiles { + if profile.identifier == self.default_sink { + self.active_sink = self.sink_ids.iter().position(|&id| id == node_id); + return; + } + } + } + } + } - Section::default() - .title(fl!("sound-alerts")) - .descriptions(descriptions) - .view::(move |_binder, _page, section| { - settings::view_section(§ion.title) - .add(settings::item(§ion.descriptions[volume], text("TODO"))) - .add(settings::item(§ion.descriptions[sound], text("TODO"))) - .into() - }) -} + fn set_default_source(&mut self) { + for card in self.alsa_cards.values() { + if let pipewire::MediaClass::Source = card.class { + for (&node_id, profile) in &card.profiles { + if profile.identifier == self.default_source { + self.active_source = self.source_ids.iter().position(|&id| id == node_id); + return; + } + } + } + } + } -fn applications() -> Section { - let mut descriptions = Slab::new(); + pub fn update(&mut self, message: Message) -> Command { + match message { + Message::SourceVolumeChanged(volume) => { + self.source_volume = volume; + self.source_volume_text = volume.to_string(); + if self.source_volume_debounce { + return Command::none(); + } - let applications = descriptions.insert(fl!("sound-applications", "desc")); + let mut command = None; + if let Some(&node_id) = self.source_ids.get(self.active_source.unwrap_or(0)) { + command = Some(cosmic::command::future(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + crate::pages::Message::Sound(Message::SourceVolumeApply(node_id)) + })); + } - Section::default() - .title(fl!("sound-applications")) - .descriptions(descriptions) - .view::(move |_binder, _page, section| { - settings::view_section(§ion.title) - .add(settings::item( - &*section.descriptions[applications], - text("TODO"), - )) - .into() - }) + if let Some(command) = command { + self.source_volume_debounce = true; + return command; + } + } + + Message::Pulse(pulse::Event::SourceVolume(volume)) => { + if self.sink_volume_debounce { + return Command::none(); + } + + self.source_volume = volume; + self.source_volume_text = volume.to_string(); + } + + Message::SinkVolumeChanged(volume) => { + self.sink_volume = volume; + self.sink_volume_text = volume.to_string(); + if self.sink_volume_debounce { + return Command::none(); + } + + let mut command = None; + if let Some(&node_id) = self.sink_ids.get(self.active_sink.unwrap_or(0)) { + command = Some(cosmic::command::future(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + crate::pages::Message::Sound(Message::SinkVolumeApply(node_id)) + })); + } + + if let Some(command) = command { + self.source_volume_debounce = true; + return command; + } + } + + Message::Pulse(pulse::Event::SinkVolume(volume)) => { + if self.sink_volume_debounce { + return Command::none(); + } + + self.sink_volume = volume; + self.sink_volume_text = volume.to_string(); + } + + Message::Pulse(pulse::Event::DefaultSink(sink)) => { + self.default_sink = sink; + self.set_default_sink(); + } + + Message::Pulse(pulse::Event::DefaultSource(source)) => { + self.default_source = source; + self.set_default_source(); + } + + Message::Pulse(pulse::Event::SinkMute(mute)) => { + self.sink_mute = mute; + } + + Message::Pulse(pulse::Event::SourceMute(mute)) => { + self.source_mute = mute; + } + + Message::Pipewire(pipewire::DeviceEvent::Add(device)) => { + 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); + } + } + + pipewire::MediaClass::Source => { + self.sources.push(device.node_description.clone()); + self.source_ids.push(device.object_id); + if self.default_source == device.node_name { + self.active_source = Some(self.sources.len() - 1); + } + } + } + + let card = self + .alsa_cards + .entry(device.alsa_card) + .or_insert_with(|| Card { + class: device.media_class, + // name: device.alsa_card_name, + profiles: HashMap::new(), + }); + + card.profiles.insert( + device.object_id, + Profile { + // device: device.card_profile_device, + identifier: device.node_name, + }, + ); + } + + Message::Pipewire(pipewire::DeviceEvent::Remove(device_id)) => { + let mut remove = None; + for (card_id, card) in &mut self.alsa_cards { + if card.profiles.remove(&device_id).is_some() { + if card.profiles.is_empty() { + remove = Some(*card_id); + } + break; + } + } + + if let Some(card_id) = remove { + _ = self.alsa_cards.remove(&card_id); + } + + if let Some(pos) = self.sink_ids.iter().position(|&id| id == device_id) { + _ = self.sink_ids.remove(pos); + _ = self.sinks.remove(pos); + if self.active_sink == Some(pos) { + self.active_sink = None; + } + } else if let Some(pos) = self.source_ids.iter().position(|&id| id == device_id) { + _ = self.source_ids.remove(pos); + _ = self.sources.remove(pos); + if self.active_source == Some(pos) { + self.active_source = None; + } + } + } + + Message::SinkChanged(pos) => { + if let Some(&node_id) = self.sink_ids.get(pos) { + self.active_sink = Some(pos); + wpctl_set_default(node_id); + } + } + + Message::SourceChanged(pos) => { + if let Some(&node_id) = self.sink_ids.get(pos) { + self.active_source = Some(pos); + wpctl_set_default(node_id); + } + } + + Message::SinkVolumeApply(node_id) => { + self.sink_volume_debounce = false; + wpctl_set_volume(node_id, self.sink_volume); + } + + Message::SourceVolumeApply(node_id) => { + self.source_volume_debounce = false; + wpctl_set_volume(node_id, self.source_volume); + } + + Message::SinkMuteToggle => { + self.sink_mute = !self.sink_mute; + if let Some(&node_id) = self.sink_ids.get(self.active_sink.unwrap_or(0)) { + wpctl_set_mute(node_id, self.sink_mute); + } + } + + Message::SourceMuteToggle => { + self.source_mute = !self.source_mute; + if let Some(&node_id) = self.source_ids.get(self.active_source.unwrap_or(0)) { + wpctl_set_mute(node_id, self.source_mute); + } + } + } + Command::none() + } } fn input() -> Section { @@ -70,17 +396,44 @@ 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 _level = descriptions.insert(fl!("sound-input", "level")); Section::default() .title(fl!("sound-input")) .descriptions(descriptions) - .view::(move |_binder, _page, section| { + .view::(move |_binder, page, section| { + let volume_control = widget::row::with_capacity(3) + .align_items(cosmic::iced::Alignment::Center) + .spacing(4) + .push( + widget::button::icon(if page.source_mute { + widget::icon::from_name("microphone-sensitivity-muted-symbolic") + } else { + widget::icon::from_name("audio-input-microphone-symbolic") + }) + .on_press(Message::SourceMuteToggle), + ) + .push(widget::text::body(&page.source_volume_text)) + .push( + widget::slider(0..=150, page.source_volume, Message::SourceVolumeChanged) + .width(250) + .breakpoints(&[100]), + ); + + let devices = widget::dropdown( + &page.sources, + Some(page.active_source.unwrap_or(0)), + Message::SourceChanged, + ); + settings::view_section(§ion.title) - .add(settings::item(&*section.descriptions[volume], text("TODO"))) - .add(settings::item(&*section.descriptions[device], text("TODO"))) - .add(settings::item(&*section.descriptions[level], text("TODO"))) - .into() + .add(settings::item( + &*section.descriptions[volume], + volume_control, + )) + .add(settings::item(&*section.descriptions[device], devices)) + .apply(Element::from) + .map(crate::pages::Message::Sound) }) } @@ -89,19 +442,110 @@ 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 _level = descriptions.insert(fl!("sound-output", "level")); + // let config = descriptions.insert(fl!("sound-output", "config")); // let balance = descriptions.insert(fl!("sound-output", "balance")); Section::default() .title(fl!("sound-output")) .descriptions(descriptions) - .view::(move |_binder, _page, section| { + .view::(move |_binder, page, section| { + let volume_control = widget::row::with_capacity(3) + .align_items(cosmic::iced::Alignment::Center) + .spacing(4) + .push( + widget::button::icon(if page.sink_mute { + widget::icon::from_name("audio-volume-muted-symbolic") + } else { + widget::icon::from_name("audio-volume-high-symbolic") + }) + .on_press(Message::SinkMuteToggle), + ) + .push(widget::text::body(&page.sink_volume_text)) + .push( + widget::slider(0..=150, page.sink_volume, Message::SinkVolumeChanged) + .width(250) + .breakpoints(&[100]), + ); + + let devices = widget::dropdown( + &page.sinks, + Some(page.active_sink.unwrap_or(0)), + Message::SinkChanged, + ); + settings::view_section(§ion.title) - .add(settings::item(&*section.descriptions[volume], text("TODO"))) - .add(settings::item(&*section.descriptions[device], text("TODO"))) - .add(settings::item(&*section.descriptions[level], text("TODO"))) - .add(settings::item(&*section.descriptions[config], text("TODO"))) - .into() + .add(settings::item( + &*section.descriptions[volume], + volume_control, + )) + .add(settings::item(&*section.descriptions[device], devices)) + .apply(Element::from) + .map(crate::pages::Message::Sound) }) } + +// fn alerts() -> Section { +// let mut descriptions = Slab::new(); +// let volume = descriptions.insert(fl!("sound-alerts", "volume")); +// let sound = descriptions.insert(fl!("sound-alerts", "sound")); + +// Section::default() +// .title(fl!("sound-alerts")) +// .descriptions(descriptions) +// .view::(move |_binder, _page, section| { +// settings::view_section(§ion.title) +// .add(settings::item(§ion.descriptions[volume], text("TODO"))) +// .add(settings::item(§ion.descriptions[sound], text("TODO"))) +// .into() +// }) +// } + +// fn applications() -> Section { +// let mut descriptions = Slab::new(); + +// let applications = descriptions.insert(fl!("sound-applications", "desc")); + +// Section::default() +// .title(fl!("sound-applications")) +// .descriptions(descriptions) +// .view::(move |_binder, _page, section| { +// settings::view_section(§ion.title) +// .add(settings::item( +// &*section.descriptions[applications], +// text("TODO"), +// )) +// .into() +// }) +// } + +fn wpctl_set_default(id: u32) { + tokio::task::spawn(async move { + let default = id.to_string(); + _ = tokio::process::Command::new("wpctl") + .args(&["set-default", default.as_str()]) + .status() + .await; + }); +} + +fn wpctl_set_volume(id: u32, volume: u32) { + tokio::task::spawn(async move { + let id = id.to_string(); + let volume = format!("{}.{:02}", volume / 100, volume % 100); + _ = tokio::process::Command::new("wpctl") + .args(&["set-volume", id.as_str(), volume.as_str()]) + .status() + .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/debian/control b/debian/control index 7987ecf..401b9c6 100644 --- a/debian/control +++ b/debian/control @@ -5,11 +5,12 @@ Maintainer: Michael Murphy Build-Depends: debhelper-compat (=13), cmake, - just (>= 1.13.0), + just, libexpat1-dev, libfontconfig-dev, libfreetype-dev, libinput-dev, + libpulse-dev, libudev-dev, libwayland-dev, libxkbcommon-dev,