Make per-peer in-call volume continuous and persistent
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 21:29:18 -04:00
co-authored by Claude Opus 4.8
parent 06e97b9f50
commit c5375e200a
2 changed files with 57 additions and 5 deletions
+51 -5
View File
@@ -349,7 +349,6 @@ pub struct AppState {
/// refreshed when the background is changed/removed. `None` = no custom bg.
background_image: Option<bytes::Bytes>,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
@@ -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::<EndpointId>() {
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<AppMessage> {
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<AppMessage> 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() {
+6
View File
@@ -255,6 +255,10 @@ pub struct AppConfig {
/// keyed by peer node id string. Local preference only.
#[serde(default)]
pub peer_pan: HashMap<String, f32>,
/// 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<String, f32>,
/// 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