@@ -1,15 +1,10 @@ |
| 1 | -//! Volume control module using PulseAudio | 1 | +//! Volume control module using pactl |
| 2 | //! | 2 | //! |
| 3 | //! Provides volume slider and mute toggle for audio output. | 3 | //! Provides volume slider and mute toggle for audio output. |
| | 4 | +//! Uses pactl commands - works with both PulseAudio and PipeWire. |
| 4 | | 5 | |
| 5 | use anyhow::{Context, Result}; | 6 | use anyhow::{Context, Result}; |
| 6 | -use libpulse_binding as pulse; | 7 | +use std::process::Command; |
| 7 | -use libpulse_binding::context::Context as PulseContext; | | |
| 8 | -use libpulse_binding::mainloop::standard::Mainloop; | | |
| 9 | -use libpulse_binding::context::subscribe::InterestMaskSet; | | |
| 10 | -use std::cell::RefCell; | | |
| 11 | -use std::rc::Rc; | | |
| 12 | -use std::sync::{Arc, Mutex}; | | |
| 13 | use tracing::{debug, info, warn}; | 8 | use tracing::{debug, info, warn}; |
| 14 | | 9 | |
| 15 | /// Volume state | 10 | /// Volume state |
@@ -21,62 +16,37 @@ pub struct VolumeState { |
| 21 | pub muted: bool, | 16 | pub muted: bool, |
| 22 | /// Sink name | 17 | /// Sink name |
| 23 | pub sink_name: String, | 18 | pub sink_name: String, |
| 24 | - /// Sink description | | |
| 25 | - pub sink_description: String, | | |
| 26 | } | 19 | } |
| 27 | | 20 | |
| 28 | -/// Volume module for controlling PulseAudio | 21 | +/// Volume module for controlling audio via pactl |
| 29 | pub struct VolumeModule { | 22 | pub struct VolumeModule { |
| 30 | - mainloop: Option<Rc<RefCell<Mainloop>>>, | 23 | + state: VolumeState, |
| 31 | - context: Option<Rc<RefCell<PulseContext>>>, | 24 | + available: bool, |
| 32 | - state: Arc<Mutex<VolumeState>>, | | |
| 33 | - connected: bool, | | |
| 34 | } | 25 | } |
| 35 | | 26 | |
| 36 | impl VolumeModule { | 27 | impl VolumeModule { |
| 37 | /// Create a new volume module | 28 | /// Create a new volume module |
| 38 | pub fn new() -> Self { | 29 | pub fn new() -> Self { |
| 39 | Self { | 30 | Self { |
| 40 | - mainloop: None, | 31 | + state: VolumeState::default(), |
| 41 | - context: None, | 32 | + available: false, |
| 42 | - state: Arc::new(Mutex::new(VolumeState::default())), | | |
| 43 | - connected: false, | | |
| 44 | } | 33 | } |
| 45 | } | 34 | } |
| 46 | | 35 | |
| 47 | - /// Connect to PulseAudio | 36 | + /// Connect and check if pactl is available |
| 48 | pub fn connect(&mut self) -> Result<()> { | 37 | pub fn connect(&mut self) -> Result<()> { |
| 49 | - let mainloop = Rc::new(RefCell::new( | 38 | + // Check if pactl is available |
| 50 | - Mainloop::new().context("Failed to create PulseAudio mainloop")? | 39 | + let output = Command::new("pactl") |
| 51 | - )); | 40 | + .arg("--version") |
| 52 | - | 41 | + .output() |
| 53 | - let context = Rc::new(RefCell::new( | 42 | + .context("pactl not found")?; |
| 54 | - PulseContext::new(&*mainloop.borrow(), "gartray") | 43 | + |
| 55 | - .context("Failed to create PulseAudio context")? | 44 | + if !output.status.success() { |
| 56 | - )); | 45 | + anyhow::bail!("pactl not working"); |
| 57 | - | | |
| 58 | - // Connect | | |
| 59 | - context.borrow_mut() | | |
| 60 | - .connect(None, pulse::context::FlagSet::NOFLAGS, None) | | |
| 61 | - .map_err(|_| anyhow::anyhow!("Failed to connect to PulseAudio"))?; | | |
| 62 | - | | |
| 63 | - // Wait for connection | | |
| 64 | - loop { | | |
| 65 | - mainloop.borrow_mut().iterate(true); | | |
| 66 | - match context.borrow().get_state() { | | |
| 67 | - pulse::context::State::Ready => break, | | |
| 68 | - pulse::context::State::Failed | | | |
| 69 | - pulse::context::State::Terminated => { | | |
| 70 | - return Err(anyhow::anyhow!("PulseAudio connection failed")); | | |
| 71 | - } | | |
| 72 | - _ => {} | | |
| 73 | - } | | |
| 74 | } | 46 | } |
| 75 | | 47 | |
| | 48 | + self.available = true; |
| 76 | info!("Connected to PulseAudio"); | 49 | info!("Connected to PulseAudio"); |
| 77 | - self.mainloop = Some(mainloop); | | |
| 78 | - self.context = Some(context); | | |
| 79 | - self.connected = true; | | |
| 80 | | 50 | |
| 81 | // Get initial state | 51 | // Get initial state |
| 82 | self.refresh()?; | 52 | self.refresh()?; |
@@ -84,158 +54,171 @@ impl VolumeModule { |
| 84 | Ok(()) | 54 | Ok(()) |
| 85 | } | 55 | } |
| 86 | | 56 | |
| 87 | - /// Refresh volume state from PulseAudio | 57 | + /// Refresh volume state from pactl |
| 88 | pub fn refresh(&mut self) -> Result<()> { | 58 | pub fn refresh(&mut self) -> Result<()> { |
| 89 | - if !self.connected { | 59 | + if !self.available { |
| 90 | return Ok(()); | 60 | return Ok(()); |
| 91 | } | 61 | } |
| 92 | | 62 | |
| 93 | - let context = self.context.as_ref() | 63 | + // Get default sink |
| 94 | - .ok_or_else(|| anyhow::anyhow!("Not connected"))?; | 64 | + if let Ok(sink) = self.get_default_sink() { |
| 95 | - let mainloop = self.mainloop.as_ref() | 65 | + self.state.sink_name = sink; |
| 96 | - .ok_or_else(|| anyhow::anyhow!("No mainloop"))?; | 66 | + } |
| | 67 | + |
| | 68 | + // Get volume and mute state |
| | 69 | + if let Ok((volume, muted)) = self.get_sink_volume(&self.state.sink_name) { |
| | 70 | + self.state.volume = volume; |
| | 71 | + self.state.muted = muted; |
| | 72 | + } |
| 97 | | 73 | |
| 98 | - let state = self.state.clone(); | 74 | + debug!("Volume: {:.0}%, muted: {}", self.state.volume * 100.0, self.state.muted); |
| | 75 | + Ok(()) |
| | 76 | + } |
| 99 | | 77 | |
| 100 | - // Get default sink info | 78 | + /// Get the default sink name |
| 101 | - let introspector = context.borrow().introspect(); | 79 | + fn get_default_sink(&self) -> Result<String> { |
| 102 | - let op = introspector.get_server_info(move |info| { | 80 | + let output = Command::new("pactl") |
| 103 | - if let Some(default_sink) = &info.default_sink_name { | 81 | + .args(["get-default-sink"]) |
| 104 | - let mut s = state.lock().unwrap(); | 82 | + .output() |
| 105 | - s.sink_name = default_sink.to_string(); | 83 | + .context("Failed to get default sink")?; |
| 106 | - } | 84 | + |
| 107 | - }); | 85 | + if output.status.success() { |
| 108 | - | 86 | + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) |
| 109 | - // Wait for operation | 87 | + } else { |
| 110 | - while op.get_state() == pulse::operation::State::Running { | 88 | + anyhow::bail!("pactl get-default-sink failed") |
| 111 | - mainloop.borrow_mut().iterate(true); | 89 | + } |
| 112 | - } | 90 | + } |
| 113 | - | 91 | + |
| 114 | - // Now get sink details | 92 | + /// Get volume and mute state for a sink |
| 115 | - let state = self.state.clone(); | 93 | + fn get_sink_volume(&self, sink: &str) -> Result<(f64, bool)> { |
| 116 | - let sink_name = { | 94 | + // Get volume |
| 117 | - let s = state.lock().unwrap(); | 95 | + let output = Command::new("pactl") |
| 118 | - s.sink_name.clone() | 96 | + .args(["get-sink-volume", sink]) |
| 119 | - }; | 97 | + .output() |
| 120 | - | 98 | + .context("Failed to get sink volume")?; |
| 121 | - if !sink_name.is_empty() { | 99 | + |
| 122 | - let introspector = context.borrow().introspect(); | 100 | + let mut volume = 0.5; |
| 123 | - let op = introspector.get_sink_info_by_name(&sink_name, move |result| { | 101 | + if output.status.success() { |
| 124 | - if let pulse::callbacks::ListResult::Item(sink) = result { | 102 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| 125 | - let mut s = state.lock().unwrap(); | 103 | + // Parse output like "Volume: front-left: 65536 / 100% / 0.00 dB, ..." |
| 126 | - s.muted = sink.mute; | 104 | + if let Some(pct) = stdout.split('/').nth(1) { |
| 127 | - if let Some(desc) = &sink.description { | 105 | + if let Some(num) = pct.trim().strip_suffix('%') { |
| 128 | - s.sink_description = desc.to_string(); | 106 | + if let Ok(v) = num.trim().parse::<f64>() { |
| | 107 | + volume = v / 100.0; |
| 129 | } | 108 | } |
| 130 | - // Calculate average volume | | |
| 131 | - let vol = sink.volume.avg().0 as f64 / pulse::volume::Volume::NORMAL.0 as f64; | | |
| 132 | - s.volume = vol.min(1.5); // Cap at 150% | | |
| 133 | - debug!("Volume: {:.0}%, muted: {}", s.volume * 100.0, s.muted); | | |
| 134 | } | 109 | } |
| 135 | - }); | | |
| 136 | - | | |
| 137 | - while op.get_state() == pulse::operation::State::Running { | | |
| 138 | - mainloop.borrow_mut().iterate(true); | | |
| 139 | } | 110 | } |
| 140 | } | 111 | } |
| 141 | | 112 | |
| 142 | - Ok(()) | 113 | + // Get mute state |
| | 114 | + let output = Command::new("pactl") |
| | 115 | + .args(["get-sink-mute", sink]) |
| | 116 | + .output() |
| | 117 | + .context("Failed to get sink mute")?; |
| | 118 | + |
| | 119 | + let mut muted = false; |
| | 120 | + if output.status.success() { |
| | 121 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| | 122 | + muted = stdout.contains("yes"); |
| | 123 | + } |
| | 124 | + |
| | 125 | + Ok((volume, muted)) |
| 143 | } | 126 | } |
| 144 | | 127 | |
| 145 | /// Set volume (0.0 - 1.0) | 128 | /// Set volume (0.0 - 1.0) |
| 146 | pub fn set_volume(&mut self, volume: f64) -> Result<()> { | 129 | pub fn set_volume(&mut self, volume: f64) -> Result<()> { |
| 147 | - if !self.connected { | 130 | + if !self.available { |
| 148 | return Ok(()); | 131 | return Ok(()); |
| 149 | } | 132 | } |
| 150 | | 133 | |
| 151 | - let context = self.context.as_ref() | 134 | + let sink = &self.state.sink_name; |
| 152 | - .ok_or_else(|| anyhow::anyhow!("Not connected"))?; | 135 | + if sink.is_empty() { |
| 153 | - let mainloop = self.mainloop.as_ref() | | |
| 154 | - .ok_or_else(|| anyhow::anyhow!("No mainloop"))?; | | |
| 155 | - | | |
| 156 | - let sink_name = { | | |
| 157 | - let s = self.state.lock().unwrap(); | | |
| 158 | - s.sink_name.clone() | | |
| 159 | - }; | | |
| 160 | - | | |
| 161 | - if sink_name.is_empty() { | | |
| 162 | return Ok(()); | 136 | return Ok(()); |
| 163 | } | 137 | } |
| 164 | | 138 | |
| 165 | - // Convert to PulseAudio volume | 139 | + // Convert to percentage |
| 166 | - let pa_vol = (volume.clamp(0.0, 1.5) * pulse::volume::Volume::NORMAL.0 as f64) as u32; | 140 | + let pct = (volume.clamp(0.0, 1.5) * 100.0) as u32; |
| 167 | - let mut channel_vol = pulse::volume::ChannelVolumes::default(); | 141 | + let volume_str = format!("{}%", pct); |
| 168 | - channel_vol.set(2, pulse::volume::Volume(pa_vol)); | | |
| 169 | | 142 | |
| 170 | - let mut introspector = context.borrow().introspect(); | 143 | + let status = Command::new("pactl") |
| 171 | - let op = introspector.set_sink_volume_by_name(&sink_name, &channel_vol, None); | 144 | + .args(["set-sink-volume", sink, &volume_str]) |
| 172 | - | 145 | + .status() |
| 173 | - while op.get_state() == pulse::operation::State::Running { | 146 | + .context("Failed to set volume")?; |
| 174 | - mainloop.borrow_mut().iterate(true); | | |
| 175 | - } | | |
| 176 | | 147 | |
| 177 | - // Update local state | 148 | + if status.success() { |
| 178 | - { | 149 | + self.state.volume = volume; |
| 179 | - let mut s = self.state.lock().unwrap(); | 150 | + debug!("Set volume to {:.0}%", volume * 100.0); |
| 180 | - s.volume = volume; | 151 | + } else { |
| | 152 | + warn!("pactl set-sink-volume failed"); |
| 181 | } | 153 | } |
| 182 | | 154 | |
| 183 | - debug!("Set volume to {:.0}%", volume * 100.0); | | |
| 184 | Ok(()) | 155 | Ok(()) |
| 185 | } | 156 | } |
| 186 | | 157 | |
| 187 | /// Toggle mute | 158 | /// Toggle mute |
| 188 | pub fn toggle_mute(&mut self) -> Result<()> { | 159 | pub fn toggle_mute(&mut self) -> Result<()> { |
| 189 | - if !self.connected { | 160 | + if !self.available { |
| 190 | return Ok(()); | 161 | return Ok(()); |
| 191 | } | 162 | } |
| 192 | | 163 | |
| 193 | - let context = self.context.as_ref() | 164 | + let sink = &self.state.sink_name; |
| 194 | - .ok_or_else(|| anyhow::anyhow!("Not connected"))?; | 165 | + if sink.is_empty() { |
| 195 | - let mainloop = self.mainloop.as_ref() | 166 | + return Ok(()); |
| 196 | - .ok_or_else(|| anyhow::anyhow!("No mainloop"))?; | 167 | + } |
| 197 | | 168 | |
| 198 | - let (sink_name, muted) = { | 169 | + let status = Command::new("pactl") |
| 199 | - let s = self.state.lock().unwrap(); | 170 | + .args(["set-sink-mute", sink, "toggle"]) |
| 200 | - (s.sink_name.clone(), s.muted) | 171 | + .status() |
| 201 | - }; | 172 | + .context("Failed to toggle mute")?; |
| 202 | | 173 | |
| 203 | - if sink_name.is_empty() { | 174 | + if status.success() { |
| 204 | - return Ok(()); | 175 | + self.state.muted = !self.state.muted; |
| | 176 | + debug!("Mute toggled to {}", self.state.muted); |
| | 177 | + } else { |
| | 178 | + warn!("pactl set-sink-mute failed"); |
| 205 | } | 179 | } |
| 206 | | 180 | |
| 207 | - let mut introspector = context.borrow().introspect(); | 181 | + Ok(()) |
| 208 | - let op = introspector.set_sink_mute_by_name(&sink_name, !muted, None); | 182 | + } |
| | 183 | + |
| | 184 | + /// Set mute state |
| | 185 | + pub fn set_mute(&mut self, muted: bool) -> Result<()> { |
| | 186 | + if !self.available { |
| | 187 | + return Ok(()); |
| | 188 | + } |
| 209 | | 189 | |
| 210 | - while op.get_state() == pulse::operation::State::Running { | 190 | + let sink = &self.state.sink_name; |
| 211 | - mainloop.borrow_mut().iterate(true); | 191 | + if sink.is_empty() { |
| | 192 | + return Ok(()); |
| 212 | } | 193 | } |
| 213 | | 194 | |
| 214 | - // Update local state | 195 | + let mute_str = if muted { "1" } else { "0" }; |
| 215 | - { | 196 | + |
| 216 | - let mut s = self.state.lock().unwrap(); | 197 | + let status = Command::new("pactl") |
| 217 | - s.muted = !muted; | 198 | + .args(["set-sink-mute", sink, mute_str]) |
| | 199 | + .status() |
| | 200 | + .context("Failed to set mute")?; |
| | 201 | + |
| | 202 | + if status.success() { |
| | 203 | + self.state.muted = muted; |
| 218 | } | 204 | } |
| 219 | | 205 | |
| 220 | - debug!("Mute toggled to {}", !muted); | | |
| 221 | Ok(()) | 206 | Ok(()) |
| 222 | } | 207 | } |
| 223 | | 208 | |
| 224 | /// Get current state | 209 | /// Get current state |
| 225 | pub fn state(&self) -> VolumeState { | 210 | pub fn state(&self) -> VolumeState { |
| 226 | - self.state.lock().unwrap().clone() | 211 | + self.state.clone() |
| 227 | } | 212 | } |
| 228 | | 213 | |
| 229 | - /// Check if connected | 214 | + /// Check if available |
| 230 | - pub fn is_connected(&self) -> bool { | 215 | + pub fn is_available(&self) -> bool { |
| 231 | - self.connected | 216 | + self.available |
| 232 | } | 217 | } |
| 233 | } | 218 | } |
| 234 | | 219 | |
| 235 | -impl Drop for VolumeModule { | 220 | +impl Default for VolumeModule { |
| 236 | - fn drop(&mut self) { | 221 | + fn default() -> Self { |
| 237 | - if let Some(context) = &self.context { | 222 | + Self::new() |
| 238 | - context.borrow_mut().disconnect(); | | |
| 239 | - } | | |
| 240 | } | 223 | } |
| 241 | } | 224 | } |