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