diff --git a/crates/shepherd-hud/src/app.rs b/crates/shepherd-hud/src/app.rs index eb1522b..04809c8 100644 --- a/crates/shepherd-hud/src/app.rs +++ b/crates/shepherd-hud/src/app.rs @@ -11,9 +11,8 @@ use gtk4::prelude::*; use gtk4_layer_shell::{Edge, Layer, LayerShell}; use shepherd_api::Command; use shepherd_ipc::IpcClient; -use std::cell::RefCell; use std::path::PathBuf; -use std::rc::Rc; +use std::sync::mpsc; use std::time::Duration; use tokio::runtime::Runtime; @@ -216,7 +215,44 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { volume_slider.set_value(info.percent as f64); } - // Handle slider value changes + // Handle slider value changes with debouncing + // Create a channel for volume requests - the worker will debounce them + let (volume_tx, volume_rx) = mpsc::channel::(); + + // Spawn a dedicated volume worker thread that debounces requests + std::thread::spawn(move || { + const DEBOUNCE_MS: u64 = 50; // Wait 50ms for more changes before sending + + loop { + // Wait for first volume request + let Ok(mut latest_percent) = volume_rx.recv() else { + break; // Channel closed + }; + + // Drain any pending requests, keeping only the latest value + // Use a short timeout to debounce rapid changes + loop { + match volume_rx.recv_timeout(std::time::Duration::from_millis(DEBOUNCE_MS)) { + Ok(percent) => { + latest_percent = percent; // Update to latest value + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // No more changes for DEBOUNCE_MS, send the request + break; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return; // Channel closed + } + } + } + + // Send only the final value + if let Err(e) = crate::volume::set_volume(latest_percent) { + tracing::error!("Failed to set volume: {}", e); + } + } + }); + let slider_changing = std::rc::Rc::new(std::cell::Cell::new(false)); let slider_changing_clone = slider_changing.clone(); @@ -224,14 +260,10 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { slider_changing_clone.set(true); let percent = value.clamp(0.0, 100.0) as u8; - // Update in background thread to avoid blocking UI - std::thread::spawn(move || { - if let Err(e) = crate::volume::set_volume(percent) { - tracing::error!("Failed to set volume: {}", e); - } - }); + // Send to debounce worker (non-blocking) + let _ = volume_tx.send(percent); - // Allow the slider to update + // Allow the slider to update immediately in UI slider.set_value(value); glib::Propagation::Stop }); @@ -371,8 +403,8 @@ fn build_hud_content(state: SharedState) -> gtk4::Box { battery_label_clone.set_text("--%"); } - // Update volume from daemon - if let Some(volume) = crate::volume::get_volume_status() { + // Update volume from cached state (updated via events, no polling needed) + if let Some(volume) = state.volume_info() { volume_button_clone.set_icon_name(volume.icon_name()); volume_label_clone.set_text(&format!("{}%", volume.percent)); @@ -555,9 +587,25 @@ fn run_event_loop(socket_path: PathBuf, state: SharedState) -> anyhow::Result<() tracing::info!("Connecting to shepherdd at {:?}", socket_path); match IpcClient::connect(&socket_path).await { - Ok(client) => { + Ok(mut client) => { tracing::info!("Connected to shepherdd"); + // Get initial volume before subscribing (can't send commands after subscribe) + match client.send(Command::GetVolume).await { + Ok(response) => { + if let shepherd_api::ResponseResult::Ok( + shepherd_api::ResponsePayload::Volume(info), + ) = response.result + { + tracing::debug!("Got initial volume: {}%", info.percent); + state.set_initial_volume(info); + } + } + Err(e) => { + tracing::warn!("Failed to get initial volume: {}", e); + } + } + let mut stream = match client.subscribe().await { Ok(stream) => stream, Err(e) => { diff --git a/crates/shepherd-hud/src/state.rs b/crates/shepherd-hud/src/state.rs index 7540caf..e481c98 100644 --- a/crates/shepherd-hud/src/state.rs +++ b/crates/shepherd-hud/src/state.rs @@ -3,7 +3,7 @@ //! The HUD subscribes to events from shepherdd and tracks session state. use chrono::Local; -use shepherd_api::{Event, EventPayload, SessionEndReason}; +use shepherd_api::{Event, EventPayload, SessionEndReason, VolumeInfo, VolumeRestrictions}; use shepherd_util::{EntryId, SessionId}; use std::sync::Arc; use tokio::sync::watch; @@ -84,18 +84,25 @@ pub struct SharedState { metrics_tx: Arc>, /// System metrics receiver metrics_rx: watch::Receiver, + /// Volume info sender (updated via events, not polling) + volume_tx: Arc>>, + /// Volume info receiver + volume_rx: watch::Receiver>, } impl SharedState { pub fn new() -> Self { let (session_tx, session_rx) = watch::channel(SessionState::NoSession); let (metrics_tx, metrics_rx) = watch::channel(SystemMetrics::default()); + let (volume_tx, volume_rx) = watch::channel(None); Self { session_tx: Arc::new(session_tx), session_rx, metrics_tx: Arc::new(metrics_tx), metrics_rx, + volume_tx: Arc::new(volume_tx), + volume_rx, } } @@ -124,6 +131,35 @@ impl SharedState { let _ = self.metrics_tx.send(metrics); } + /// Get current volume info (cached from events) + pub fn volume_info(&self) -> Option { + self.volume_rx.borrow().clone() + } + + /// Set initial volume info (called once on connect) + pub fn set_initial_volume(&self, info: VolumeInfo) { + let _ = self.volume_tx.send(Some(info)); + } + + /// Update volume from VolumeChanged event (preserves restrictions from initial fetch) + fn update_volume(&self, percent: u8, muted: bool) { + self.volume_tx.send_modify(|vol| { + if let Some(v) = vol { + v.percent = percent; + v.muted = muted; + } else { + // If we don't have initial volume yet, create a basic one + *vol = Some(VolumeInfo { + percent, + muted, + available: true, + backend: None, + restrictions: VolumeRestrictions::unrestricted(), + }); + } + }); + } + /// Update time remaining for current session pub fn update_time_remaining(&self, remaining_secs: u64) { self.session_tx.send_modify(|state| { @@ -230,6 +266,10 @@ impl SharedState { } } + EventPayload::VolumeChanged { percent, muted } => { + self.update_volume(*percent, *muted); + } + _ => {} } }