diff --git a/Cargo.lock b/Cargo.lock index 872dd8c..ff9fd24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1650,6 +1650,7 @@ dependencies = [ "gtk-layer-shell", "hyprland", "indexmap 2.2.5", + "libpulse-binding", "mpd-utils", "mpris", "nix 0.27.1", @@ -1756,6 +1757,33 @@ dependencies = [ "winapi", ] +[[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", + "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", + "num-traits", + "pkg-config", + "winapi", +] + [[package]] name = "link-cplusplus" version = "1.0.8" @@ -2009,6 +2037,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote 1.0.35", + "syn 1.0.109", +] + [[package]] name = "num-traits" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index fbd403d..ad93883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ default = [ "sys_info", "tray", "upower", + "volume", "workspaces+all" ] @@ -62,6 +63,8 @@ tray = ["system-tray"] upower = ["upower_dbus", "zbus", "futures-lite"] +volume = ["libpulse-binding"] + workspaces = ["futures-util"] "workspaces+all" = ["workspaces", "workspaces+sway", "workspaces+hyprland"] "workspaces+sway" = ["workspaces", "swayipc-async"] @@ -132,6 +135,10 @@ upower_dbus = { version = "0.3.2", optional = true } futures-lite = { version = "2.2.0", optional = true } zbus = { version = "3.15.2", optional = true } +# volume +libpulse-binding = { version = "2.28.1", optional = true } +# libpulse-glib-binding = { version = "2.27.1", optional = true } + # workspaces swayipc-async = { version = "2.0.1", optional = true } hyprland = { version = "0.3.13", features = ["silent"], optional = true } @@ -140,4 +147,4 @@ futures-util = { version = "0.3.30", optional = true } # shared regex = { version = "1.10.3", default-features = false, features = [ "std", -], optional = true } # music, sys_info \ No newline at end of file +], optional = true } # music, sys_info diff --git a/docs/Compiling.md b/docs/Compiling.md index 236b295..30c69df 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -20,6 +20,8 @@ You also need rust; only the latest stable version is supported. pacman -S gtk3 gtk-layer-shell # for http support pacman -S openssl +# for volume support +pacman -S libpulse ``` ### Ubuntu/Debian @@ -28,6 +30,8 @@ pacman -S openssl apt install build-essential libgtk-3-dev libgtk-layer-shell-dev # for http support apt install libssl-dev +# for volume support +apt install libpulse-dev ``` ### Fedora @@ -36,6 +40,8 @@ apt install libssl-dev dnf install gtk3-devel gtk-layer-shell-devel # for http support dnf install openssl-devel +# for volume support +dnf install libpulseaudio-devel ``` ## Features @@ -81,6 +87,7 @@ cargo build --release --no-default-features \ | sys_info | Enables the `sys_info` module. | | tray | Enables the `tray` module. | | upower | Enables the `upower` module. | +| volume | Enables the `volume` module. | | workspaces+all | Enables the `workspaces` module with support for all compositors. | | workspaces+sway | Enables the `workspaces` module with support for Sway. | | workspaces+hyprland | Enables the `workspaces` module with support for Hyprland. | diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 2553fe8..cbe5e0a 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -34,4 +34,5 @@ - [Sys_Info](sys-info) - [Tray](tray) - [Upower](upower) +- [Volume](volume) - [Workspaces](workspaces) diff --git a/docs/modules/Volume.md b/docs/modules/Volume.md new file mode 100644 index 0000000..4385646 --- /dev/null +++ b/docs/modules/Volume.md @@ -0,0 +1,128 @@ +Displays the current volume level. +Clicking on the widget opens a volume mixer, which allows you to change the device output level, +the default playback device, and control application volume levels individually. + +This requires PulseAudio to function (`pipewire-pulse` is supported). + +TODO: Screenshot + +## Configuration + +> Type: `volume` + +| Name | Type | Default | Description | +|-----------------------|----------|------------------------|----------------------------------------------------------------------------------------------------------------| +| `format` | `string` | `{icon} {percentage}%` | Format string to use for the widget button label. | +| `max_volume` | `float` | `100` | Maximum value to allow volume sliders to reach. Pulse supports values > 100 but this may result in distortion. | +| `icons.volume_high` | `string` | `󰕾` | Icon to show for high volume levels. | +| `icons.volume_medium` | `string` | `󰖀` | Icon to show for medium volume levels. | +| `icons.volume_low` | `string` | `󰕿` | Icon to show for low volume levels. | +| `icons.muted` | `string` | `󰝟` | Icon to show for muted outputs. | + +
+JSON + +```json +{ + "end": [ + { + "type": "volume", + "format": "{icon} {percentage}%", + "max_volume": 100, + "icons": { + "volume_high": "󰕾", + "volume_medium": "󰖀", + "volume_low": "󰕿", + "muted": "󰝟" + } + } + ] +} + +``` + +
+ +
+TOML + +```toml +[[end]] +type = "volume" +format = "{icon} {percentage}%" +max_volume = 100 + +[[end.icons]] +volume_high = "󰕾" +volume_medium = "󰖀" +volume_low = "󰕿" +muted = "󰝟" +``` + +
+ +
+YAML + +```yaml +end: + - type: "volume" + format: "{icon} {percentage}%" + max_volume: 100 + icons: + volume_high: "󰕾" + volume_medium: "󰖀" + volume_low: "󰕿" + muted: "󰝟" +``` + +
+ +
+Corn + +```corn +{ + end = [ + { + type = "volume" + format = "{icon} {percentage}%" + max_volume = 100 + icons.volume_high = "󰕾" + icons.volume_medium = "󰖀" + icons.volume_low = "󰕿" + icons.muted = "󰝟" + } + ] +} +``` + +
+ +### Formatting Tokens + +The following tokens can be used in the `format` config option: + +| Token | Description | +|----------------|-------------------------------------------| +| `{percentage}` | The active device volume percentage. | +| `{icon}` | The icon representing the current volume. | +| `{name}` | The active device name. | + +## Styling + +| Selector | Description | +|----------------------------------------------|----------------------------------------------------| +| `.volume` | Volume widget button. | +| `.popup-volume` | Volume popup box. | +| `.popup-volume .device-box` | Box for the device volume controls. | +| `.popup-volume .device-box .device-selector` | Default device dropdown selector. | +| `.popup-volume .device-box .slider` | Device volume slider. | +| `.popup-volume .device-box .btn-mute` | Device volume mute toggle button. | +| `.popup-volume .apps-box` | Parent box for the application volume controls. | +| `.popup-volume .apps-box .app-box` | Box for an individual application volume controls. | +| `.popup-volume .apps-box .app-box .title` | Name of the application playback stream. | +| `.popup-volume .apps-box .app-box .slider` | Application volume slider. | +| `.popup-volume .apps-box .app-box .btn-mute` | Application volume mute toggle button. | + +For more information on styling, please see the [styling guide](styling-guide). \ No newline at end of file diff --git a/examples/config.corn b/examples/config.corn index 4cc47da..718e830 100644 --- a/examples/config.corn +++ b/examples/config.corn @@ -67,6 +67,16 @@ let { $clipboard = { type = "clipboard" max_items = 3 truncate.mode = "end" truncate.length = 50 } + $volume = { + type = "volume" + format = "{icon} {volume}%" + max_volume = 100 + icons.volume_high = "󰕾" + icons.volume_medium = "󰖀" + icons.volume_low = "󰕿" + icons.muted = "󰝟" + } + $label = { type = "label" label = "random num: {{500:echo FIXME}}" } // -- begin custom -- @@ -100,7 +110,7 @@ let { // -- end custom -- $left = [ $workspaces $launcher $label ] - $right = [ $mpd_local $mpd_server $phone_battery $sys_info $clipboard $power_menu $clock ] + $right = [ $mpd_local $mpd_server $phone_battery $sys_info $volume $clipboard $power_menu $clock ] } in { anchor_to_edges = true diff --git a/examples/style.css b/examples/style.css index c766da8..8887538 100644 --- a/examples/style.css +++ b/examples/style.css @@ -174,6 +174,11 @@ scale trough { margin-left: 10px; } +/* -- volume -- */ + +.popup-volume .device-box { + border-right: 1px solid @color_border; +} /* -- workspaces -- */ diff --git a/src/bar.rs b/src/bar.rs index 49f820a..0263d39 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -392,6 +392,8 @@ fn add_modules( ModuleConfig::Tray(mut module) => add_module!(module, id), #[cfg(feature = "upower")] ModuleConfig::Upower(mut module) => add_module!(module, id), + #[cfg(feature = "volume")] + ModuleConfig::Volume(mut module) => add_module!(module, id), #[cfg(feature = "workspaces")] ModuleConfig::Workspaces(mut module) => add_module!(module, id), } diff --git a/src/clients/mod.rs b/src/clients/mod.rs index f1433bc..8edec61 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -10,6 +10,8 @@ pub mod music; pub mod system_tray; #[cfg(feature = "upower")] pub mod upower; +#[cfg(feature = "volume")] +pub mod volume; pub mod wayland; /// Singleton wrapper consisting of @@ -27,6 +29,8 @@ pub struct Clients { tray: Option>, #[cfg(feature = "upower")] upower: Option>>, + #[cfg(feature = "volume")] + volume: Option>, } impl Clients { @@ -86,6 +90,13 @@ impl Clients { }) .clone() } + + #[cfg(feature = "volume")] + pub fn volume(&mut self) -> Arc { + self.volume + .get_or_insert_with(volume::create_client) + .clone() + } } /// Types implementing this trait diff --git a/src/clients/volume/mod.rs b/src/clients/volume/mod.rs new file mode 100644 index 0000000..7abf28d --- /dev/null +++ b/src/clients/volume/mod.rs @@ -0,0 +1,312 @@ +mod sink; +mod sink_input; + +use crate::{arc_mut, lock, register_client, send, spawn_blocking}; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::introspect::{Introspector, ServerInfo}; +use libpulse_binding::context::subscribe::{Facility, InterestMaskSet, Operation}; +use libpulse_binding::context::{Context, FlagSet, State}; +use libpulse_binding::mainloop::standard::{IterateResult, Mainloop}; +use libpulse_binding::proplist::Proplist; +use libpulse_binding::volume::{ChannelVolumes, Volume}; +use std::fmt::{Debug, Formatter}; +use std::sync::{Arc, Mutex}; +use tokio::sync::broadcast; +use tracing::{debug, error, info, warn}; + +pub use sink::Sink; +pub use sink_input::SinkInput; + +type ArcMutVec = Arc>>; + +#[derive(Debug, Clone)] +pub enum Event { + AddSink(Sink), + UpdateSink(Sink), + RemoveSink(String), + + AddInput(SinkInput), + UpdateInput(SinkInput), + RemoveInput(u32), +} + +#[derive(Debug)] +pub struct Client { + connection: Arc>, + + data: Data, + + tx: broadcast::Sender, + _rx: broadcast::Receiver, +} + +#[derive(Debug, Default, Clone)] +struct Data { + sinks: ArcMutVec, + sink_inputs: ArcMutVec, + + default_sink_name: Arc>>, +} + +pub enum ConnectionState { + Disconnected, + Connected { + context: Arc>, + introspector: Introspector, + }, +} + +impl Debug for ConnectionState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Disconnected => "Disconnected", + Self::Connected { .. } => "Connected", + } + ) + } +} + +impl Client { + pub fn new() -> Self { + let (tx, rx) = broadcast::channel(32); + + Self { + connection: arc_mut!(ConnectionState::Disconnected), + data: Data::default(), + tx, + _rx: rx, + } + } + + /// Starts the client. + fn run(&self) { + let Some(mut proplist) = Proplist::new() else { + error!("Failed to create PA proplist"); + return; + }; + + if proplist + .set_str("APPLICATION_NAME", "dev.jstanger.ironbar") + .is_err() + { + error!("Failed to update PA proplist"); + } + + let Some(mut mainloop) = Mainloop::new() else { + error!("Failed to create PA mainloop"); + return; + }; + + let Some(context) = Context::new_with_proplist(&mainloop, "Ironbar Context", &proplist) + else { + error!("Failed to create PA context"); + return; + }; + + let context = arc_mut!(context); + + let state_callback = Box::new({ + let context = context.clone(); + let data = self.data.clone(); + let tx = self.tx.clone(); + + move || on_state_change(&context, &data, &tx) + }); + + lock!(context).set_state_callback(Some(state_callback)); + + if let Err(err) = lock!(context).connect(None, FlagSet::NOAUTOSPAWN, None) { + error!("{err:?}"); + } + + let introspector = lock!(context).introspect(); + + { + let mut inner = lock!(self.connection); + *inner = ConnectionState::Connected { + context, + introspector, + }; + } + + loop { + match mainloop.iterate(true) { + IterateResult::Success(_) => {} + IterateResult::Err(err) => error!("{err:?}"), + IterateResult::Quit(_) => break, + } + } + } + + /// Gets an event receiver. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +/// Creates a new Pulse volume client. +pub fn create_client() -> Arc { + let client = Arc::new(Client::new()); + + { + let client = client.clone(); + spawn_blocking(move || { + client.run(); + }); + } + + client +} + +fn on_state_change(context: &Arc>, data: &Data, tx: &broadcast::Sender) { + let Ok(state) = context.try_lock().map(|lock| lock.get_state()) else { + return; + }; + + match state { + State::Ready => { + info!("connected to server"); + + let introspect = lock!(context).introspect(); + let introspect2 = lock!(context).introspect(); + + introspect.get_sink_info_list({ + let sinks = data.sinks.clone(); + let default_sink = data.default_sink_name.clone(); + + let tx = tx.clone(); + + move |info| match info { + ListResult::Item(_) => sink::add(info, &sinks, &tx), + ListResult::End => { + introspect2.get_server_info({ + let sinks = sinks.clone(); + let default_sink = default_sink.clone(); + let tx = tx.clone(); + + move |info| set_default_sink(info, &sinks, &default_sink, &tx) + }); + } + ListResult::Error => error!("Error while receiving sinks"), + } + }); + + introspect.get_sink_input_info_list({ + let inputs = data.sink_inputs.clone(); + let tx = tx.clone(); + + move |info| sink_input::add(info, &inputs, &tx) + }); + + let subscribe_callback = Box::new({ + let context = context.clone(); + let data = data.clone(); + let tx = tx.clone(); + + move |facility, op, i| on_event(&context, &data, &tx, facility, op, i) + }); + + lock!(context).set_subscribe_callback(Some(subscribe_callback)); + lock!(context).subscribe( + InterestMaskSet::SERVER | InterestMaskSet::SINK_INPUT | InterestMaskSet::SINK, + |_| (), + ); + } + State::Failed => error!("Failed to connect to audio server"), + State::Terminated => error!("Connection to audio server terminated"), + _ => {} + } +} + +fn on_event( + context: &Arc>, + data: &Data, + tx: &broadcast::Sender, + facility: Option, + op: Option, + i: u32, +) { + let (Some(facility), Some(op)) = (facility, op) else { + return; + }; + + match facility { + Facility::Server => on_server_event(context, &data.sinks, &data.default_sink_name, tx), + Facility::Sink => sink::on_event(context, &data.sinks, &data.default_sink_name, tx, op, i), + Facility::SinkInput => sink_input::on_event(context, &data.sink_inputs, tx, op, i), + _ => error!("Received unhandled facility: {facility:?}"), + } +} + +fn on_server_event( + context: &Arc>, + sinks: &ArcMutVec, + default_sink: &Arc>>, + tx: &broadcast::Sender, +) { + lock!(context).introspect().get_server_info({ + let sinks = sinks.clone(); + let default_sink = default_sink.clone(); + let tx = tx.clone(); + + move |info| set_default_sink(info, &sinks, &default_sink, &tx) + }); +} + +fn set_default_sink( + info: &ServerInfo, + sinks: &ArcMutVec, + default_sink: &Arc>>, + tx: &broadcast::Sender, +) { + let default_sink_name = info.default_sink_name.as_ref().map(ToString::to_string); + + if default_sink_name != *lock!(default_sink) { + if let Some(ref default_sink_name) = default_sink_name { + if let Some(sink) = lock!(sinks) + .iter_mut() + .find(|s| s.name.as_str() == default_sink_name.as_str()) + { + sink.active = true; + debug!("Set sink active: {}", sink.name); + send!(tx, Event::UpdateSink(sink.clone())); + } else { + warn!("Couldn't find sink: {}", default_sink_name); + } + } + } + + *lock!(default_sink) = default_sink_name; +} + +/// Converts a Pulse `ChannelVolumes` struct into a single percentage value, +/// representing the average value across all channels. +fn volume_to_percent(volume: ChannelVolumes) -> f64 { + let avg = volume.avg().0; + let base_delta = (Volume::NORMAL.0 - Volume::MUTED.0) as f64 / 100.0; + + ((avg - Volume::MUTED.0) as f64 / base_delta).round() +} + +/// Converts a percentage volume into a Pulse volume value, +/// which can be used for setting channel volumes. +pub fn percent_to_volume(target_percent: f64) -> u32 { + let base_delta = (Volume::NORMAL.0 as f32 - Volume::MUTED.0 as f32) / 100.0; + + if target_percent < 0.0 { + Volume::MUTED.0 + } else if target_percent == 100.0 { + Volume::NORMAL.0 + } else if target_percent >= 150.0 { + (Volume::NORMAL.0 as f32 * 1.5) as u32 + } else if target_percent < 100.0 { + Volume::MUTED.0 + target_percent as u32 * base_delta as u32 + } else { + Volume::NORMAL.0 + (target_percent - 100.0) as u32 * base_delta as u32 + } +} + +register_client!(Client, volume); diff --git a/src/clients/volume/sink.rs b/src/clients/volume/sink.rs new file mode 100644 index 0000000..d08b99e --- /dev/null +++ b/src/clients/volume/sink.rs @@ -0,0 +1,175 @@ +use super::{percent_to_volume, volume_to_percent, ArcMutVec, Client, ConnectionState, Event}; +use crate::{lock, send}; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::introspect::SinkInfo; +use libpulse_binding::context::subscribe::Operation; +use libpulse_binding::context::Context; +use libpulse_binding::def::SinkState; +use std::sync::{mpsc, Arc, Mutex}; +use tokio::sync::broadcast; +use tracing::{debug, error}; + +#[derive(Debug, Clone)] +pub struct Sink { + index: u32, + pub name: String, + pub description: String, + pub volume: f64, + pub muted: bool, + pub active: bool, +} + +impl From<&SinkInfo<'_>> for Sink { + fn from(value: &SinkInfo) -> Self { + Self { + index: value.index, + name: value + .name + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + description: value + .description + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + muted: value.mute, + volume: volume_to_percent(value.volume), + active: value.state == SinkState::Running, + } + } +} + +impl Client { + pub fn sinks(&self) -> Arc>> { + self.data.sinks.clone() + } + + pub fn set_default_sink(&self, name: &str) { + if let ConnectionState::Connected { context, .. } = &*lock!(self.connection) { + lock!(context).set_default_sink(name, |_| {}); + } + } + + pub fn set_sink_volume(&self, name: &str, volume_percent: f64) { + if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) { + let (tx, rx) = mpsc::channel(); + + introspector.get_sink_info_by_name(name, move |info| { + let ListResult::Item(info) = info else { + return; + }; + send!(tx, info.volume); + }); + + let new_volume = percent_to_volume(volume_percent); + + let mut volume = rx.recv().expect("to receive info"); + for v in volume.get_mut() { + v.0 = new_volume; + } + + introspector.set_sink_volume_by_name(name, &volume, None); + } + } + + pub fn set_sink_muted(&self, name: &str, muted: bool) { + if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) { + introspector.set_sink_mute_by_name(name, muted, None); + } + } +} + +pub fn on_event( + context: &Arc>, + sinks: &ArcMutVec, + default_sink: &Arc>>, + tx: &broadcast::Sender, + op: Operation, + i: u32, +) { + let introspect = lock!(context).introspect(); + + match op { + Operation::New => { + debug!("new sink"); + introspect.get_sink_info_by_index(i, { + let sinks = sinks.clone(); + let tx = tx.clone(); + + move |info| add(info, &sinks, &tx) + }); + } + Operation::Changed => { + debug!("sink changed"); + introspect.get_sink_info_by_index(i, { + let sinks = sinks.clone(); + let default_sink = default_sink.clone(); + let tx = tx.clone(); + + move |info| update(info, &sinks, &default_sink, &tx) + }); + } + Operation::Removed => { + debug!("sink removed"); + remove(i, sinks, tx); + } + } +} + +pub fn add(info: ListResult<&SinkInfo>, sinks: &ArcMutVec, tx: &broadcast::Sender) { + let ListResult::Item(info) = info else { + return; + }; + + lock!(sinks).push(info.into()); + send!(tx, Event::AddSink(info.into())); +} + +fn update( + info: ListResult<&SinkInfo>, + sinks: &ArcMutVec, + default_sink: &Arc>>, + tx: &broadcast::Sender, +) { + let ListResult::Item(info) = info else { + return; + }; + + { + let mut sinks = lock!(sinks); + let Some(pos) = sinks.iter().position(|sink| sink.index == info.index) else { + error!("received update to untracked sink input"); + return; + }; + + sinks[pos] = info.into(); + + // update in local copy + if !sinks[pos].active { + if let Some(default_sink) = &*lock!(default_sink) { + sinks[pos].active = &sinks[pos].name == default_sink; + } + } + } + + let mut sink: Sink = info.into(); + + // update in broadcast copy + if !sink.active { + if let Some(default_sink) = &*lock!(default_sink) { + sink.active = &sink.name == default_sink; + } + } + + send!(tx, Event::UpdateSink(sink)); +} + +fn remove(index: u32, sinks: &ArcMutVec, tx: &broadcast::Sender) { + let mut sinks = lock!(sinks); + + if let Some(pos) = sinks.iter().position(|s| s.index == index) { + let info = sinks.remove(pos); + send!(tx, Event::RemoveSink(info.name)); + } +} diff --git a/src/clients/volume/sink_input.rs b/src/clients/volume/sink_input.rs new file mode 100644 index 0000000..102aed2 --- /dev/null +++ b/src/clients/volume/sink_input.rs @@ -0,0 +1,148 @@ +use super::{percent_to_volume, volume_to_percent, ArcMutVec, Client, ConnectionState, Event}; +use crate::{lock, send}; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::introspect::SinkInputInfo; +use libpulse_binding::context::subscribe::Operation; +use libpulse_binding::context::Context; +use std::sync::{mpsc, Arc, Mutex}; +use tokio::sync::broadcast; +use tracing::{debug, error}; + +#[derive(Debug, Clone)] +pub struct SinkInput { + pub index: u32, + pub name: String, + pub volume: f64, + pub muted: bool, + + pub can_set_volume: bool, +} + +impl From<&SinkInputInfo<'_>> for SinkInput { + fn from(value: &SinkInputInfo) -> Self { + Self { + index: value.index, + name: value + .name + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + muted: value.mute, + volume: volume_to_percent(value.volume), + can_set_volume: value.has_volume && value.volume_writable, + } + } +} + +impl Client { + pub fn sink_inputs(&self) -> Arc>> { + self.data.sink_inputs.clone() + } + + pub fn set_input_volume(&self, index: u32, volume_percent: f64) { + if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) { + let (tx, rx) = mpsc::channel(); + + introspector.get_sink_input_info(index, move |info| { + let ListResult::Item(info) = info else { + return; + }; + send!(tx, info.volume); + }); + + let new_volume = percent_to_volume(volume_percent); + + let mut volume = rx.recv().expect("to receive info"); + for v in volume.get_mut() { + v.0 = new_volume; + } + + introspector.set_sink_input_volume(index, &volume, None); + } + } + + pub fn set_input_muted(&self, index: u32, muted: bool) { + if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) { + introspector.set_sink_input_mute(index, muted, None); + } + } +} + +pub fn on_event( + context: &Arc>, + inputs: &ArcMutVec, + tx: &broadcast::Sender, + op: Operation, + i: u32, +) { + let introspect = lock!(context).introspect(); + + match op { + Operation::New => { + debug!("new sink input"); + introspect.get_sink_input_info(i, { + let inputs = inputs.clone(); + let tx = tx.clone(); + + move |info| add(info, &inputs, &tx) + }); + } + Operation::Changed => { + debug!("sink input changed"); + introspect.get_sink_input_info(i, { + let inputs = inputs.clone(); + let tx = tx.clone(); + + move |info| update(info, &inputs, &tx) + }); + } + Operation::Removed => { + debug!("sink input removed"); + remove(i, inputs, tx); + } + } +} + +pub fn add( + info: ListResult<&SinkInputInfo>, + inputs: &ArcMutVec, + tx: &broadcast::Sender, +) { + let ListResult::Item(info) = info else { + return; + }; + + lock!(inputs).push(info.into()); + send!(tx, Event::AddInput(info.into())); +} + +fn update( + info: ListResult<&SinkInputInfo>, + inputs: &ArcMutVec, + tx: &broadcast::Sender, +) { + let ListResult::Item(info) = info else { + return; + }; + + { + let mut inputs = lock!(inputs); + let Some(pos) = inputs.iter().position(|input| input.index == info.index) else { + error!("received update to untracked sink input"); + return; + }; + + inputs[pos] = info.into(); + } + + send!(tx, Event::UpdateInput(info.into())); +} + +fn remove(index: u32, inputs: &ArcMutVec, tx: &broadcast::Sender) { + let mut inputs = lock!(inputs); + + if let Some(pos) = inputs.iter().position(|s| s.index == index) { + let info = inputs.remove(pos); + send!(tx, Event::RemoveInput(info.index)); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index bb0d61f..bfd301b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -21,6 +21,8 @@ use crate::modules::sysinfo::SysInfoModule; use crate::modules::tray::TrayModule; #[cfg(feature = "upower")] use crate::modules::upower::UpowerModule; +#[cfg(feature = "volume")] +use crate::modules::volume::VolumeModule; #[cfg(feature = "workspaces")] use crate::modules::workspaces::WorkspacesModule; use cfg_if::cfg_if; @@ -52,6 +54,8 @@ pub enum ModuleConfig { Tray(Box), #[cfg(feature = "upower")] Upower(Box), + #[cfg(feature = "volume")] + Volume(Box), #[cfg(feature = "workspaces")] Workspaces(Box), } diff --git a/src/macros.rs b/src/macros.rs index 028fd19..4cc986c 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -180,3 +180,10 @@ macro_rules! arc_rw { std::sync::Arc::new(std::sync::RwLock::new($val)) }; } + +#[macro_export] +macro_rules! rc_mut { + ($val:expr) => { + std::rc::Rc::new(std::cell::RefCell::new($val)) + }; +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 102b4bc..d8d671b 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -41,6 +41,8 @@ pub mod sysinfo; pub mod tray; #[cfg(feature = "upower")] pub mod upower; +#[cfg(feature = "volume")] +pub mod volume; #[cfg(feature = "workspaces")] pub mod workspaces; diff --git a/src/modules/volume.rs b/src/modules/volume.rs new file mode 100644 index 0000000..a7dda8d --- /dev/null +++ b/src/modules/volume.rs @@ -0,0 +1,427 @@ +use crate::clients::volume; +use crate::clients::volume::Event; +use crate::config::CommonConfig; +use crate::gtk_helpers::IronbarGtkExt; +use crate::modules::{ + Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext, +}; +use crate::{glib_recv, lock, send_async, spawn, try_send}; +use glib::Propagation; +use gtk::pango::EllipsizeMode; +use gtk::prelude::*; +use gtk::{Button, CellRendererText, ComboBoxText, Label, Orientation, Scale, ToggleButton}; +use serde::Deserialize; +use std::collections::HashMap; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, Deserialize)] +pub struct VolumeModule { + #[serde(default = "default_format")] + format: String, + + #[serde(default = "default_max_volume")] + max_volume: f64, + + #[serde(default)] + icons: Icons, + + #[serde(flatten)] + pub common: Option, +} + +fn default_format() -> String { + String::from("{icon} {percentage}%") +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Icons { + #[serde(default = "default_icon_volume_high")] + volume_high: String, + #[serde(default = "default_icon_volume_medium")] + volume_medium: String, + #[serde(default = "default_icon_volume_low")] + volume_low: String, + #[serde(default = "default_icon_muted")] + muted: String, +} + +impl Icons { + fn volume_icon(&self, volume_percent: f64) -> &str { + match volume_percent as u32 { + 0..=33 => &self.volume_low, + 34..=66 => &self.volume_medium, + 67.. => &self.volume_high, + } + } +} + +impl Default for Icons { + fn default() -> Self { + Self { + volume_high: default_icon_volume_high(), + volume_medium: default_icon_volume_medium(), + volume_low: default_icon_volume_low(), + muted: default_icon_muted(), + } + } +} + +const fn default_max_volume() -> f64 { + 100.0 +} + +fn default_icon_volume_high() -> String { + String::from("󰕾") +} + +fn default_icon_volume_medium() -> String { + String::from("󰖀") +} + +fn default_icon_volume_low() -> String { + String::from("󰕿") +} + +fn default_icon_muted() -> String { + String::from("󰝟") +} + +#[derive(Debug, Clone)] +pub enum Update { + SinkChange(String), + SinkVolume(String, f64), + SinkMute(String, bool), + + InputVolume(u32, f64), + InputMute(u32, bool), +} + +impl Module