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