From c5375e200adcc57d40bee9a93d48470fa322f9ee Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 20 Jun 2026 21:29:18 -0400 Subject: [PATCH] Make per-peer in-call volume continuous and persistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-peer volume slider had no .step(), so iced's default step of 1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or 200% — it felt like hard-left/hard-right only. Add .step(0.01) for smooth 1%-increment control (matching the Pan slider below it, which already set its own step). Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq: - new AppConfig.peer_volume map (keyed by peer id string, serde default for back-compat; never sent over the wire) - replace the in-memory peer_volumes map with config-backed storage via a new set_peer_volume_config helper (clamps to range, drops at-unity entries so the config stays tidy) - replay saved volumes to core on startup alongside pan/eq - the slider writes to disk on release (AppMessage::PersistConfig) +1 unit test for the config helper; +1 config back-compat assertion. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++----- src/config.rs | 6 ++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index d546bec..8b4b6e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -349,7 +349,6 @@ pub struct AppState { /// refreshed when the background is changed/removed. `None` = no custom bg. background_image: Option, peers: HashMap, - peer_volumes: HashMap, audio_levels: HashMap, /// Peers we've locally muted (their audio isn't mixed into our output). locally_muted: HashSet, @@ -474,6 +473,11 @@ impl Default for AppState { let _ = controller.send(CoreCommand::SetPeerPan(id, *pan)); } } + for (peer, volume) in &config.peer_volume { + if let Ok(id) = peer.parse::() { + let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume)); + } + } let pixelpass_available = crate::screenshare::is_available(config.pixelpass_path.as_deref()); let all_devices = enumerate_audio_devices(); @@ -505,7 +509,6 @@ impl Default for AppState { config, background_image, peers: HashMap::new(), - peer_volumes: HashMap::new(), audio_levels: HashMap::new(), locally_muted: HashSet::new(), call_started: None, @@ -744,6 +747,20 @@ fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 pan } +/// Store the per-peer listener volume, clamped to the slider range. Unity gain +/// (`1.0`) is the implicit default, so an at-unity entry is removed rather than +/// stored to keep the config tidy. Returns the clamped value. +fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 { + let volume = volume.clamp(0.0, 2.0); + let key = id.to_string(); + if (volume - 1.0).abs() <= 0.001 { + config.peer_volume.remove(&key); + } else { + config.peer_volume.insert(key, volume); + } + volume +} + fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings { config .peer_eq @@ -1038,7 +1055,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); } AppMessage::PeerVolumeChanged(id, vol) => { - state.peer_volumes.insert(id, vol); + let vol = set_peer_volume_config(&mut state.config, id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } AppMessage::PeerPanChanged(id, pan) => { @@ -3348,11 +3365,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(8); // Peer volume slider - let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0); + let current_vol = state + .config + .peer_volume + .get(&peer_id.to_string()) + .copied() + .unwrap_or(1.0); card_content = card_content.push( row![ text("Vol:").size(12).color(color_subtext), slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v)) + .step(0.01) + .on_release(AppMessage::PersistConfig) ].spacing(8).align_y(iced::alignment::Vertical::Center) ); @@ -4900,8 +4924,30 @@ impl Program for Icon { mod tests { use super::{ format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime, - GateMeter, METER_MAX, + set_peer_volume_config, AppConfig, GateMeter, METER_MAX, }; + use iroh::SecretKey; + + #[test] + fn peer_volume_persists_non_unity_and_clears_at_unity() { + let mut config = AppConfig::default(); + let id = SecretKey::generate().public(); + + // A non-unity value is clamped into range and stored. + let stored = set_peer_volume_config(&mut config, id, 1.5); + assert_eq!(stored, 1.5); + assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5)); + + // Out-of-range values clamp to the slider bounds. + assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0); + assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0); + + // Returning to unity removes the entry (unity is the implicit default), + // so the config doesn't accumulate no-op entries. + let stored = set_peer_volume_config(&mut config, id, 1.0); + assert_eq!(stored, 1.0); + assert!(!config.peer_volume.contains_key(&id.to_string())); + } #[test] fn x11_restores_saved_window_position() { diff --git a/src/config.rs b/src/config.rs index c4c0193..210d2b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -255,6 +255,10 @@ pub struct AppConfig { /// keyed by peer node id string. Local preference only. #[serde(default)] pub peer_pan: HashMap, + /// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id + /// string. Local preference only; never sent to peers. Absent entry = unity. + #[serde(default)] + pub peer_volume: HashMap, /// Focused app-local keyboard shortcuts. #[serde(default)] pub hotkeys: crate::hotkeys::HotkeyMap, @@ -317,6 +321,7 @@ impl Default for AppConfig { recents: Vec::new(), peer_eq: HashMap::new(), peer_pan: HashMap::new(), + peer_volume: HashMap::new(), hotkeys: crate::hotkeys::HotkeyMap::default(), window_width: default_window_width(), window_height: default_window_height(), @@ -457,6 +462,7 @@ mod tests { // shortcut settings. assert!(deserialized.peer_eq.is_empty()); assert!(deserialized.peer_pan.is_empty()); + assert!(deserialized.peer_volume.is_empty()); assert_eq!( crate::hotkeys::format_binding( deserialized