Make per-peer in-call volume continuous and persistent
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:
+51
-5
@@ -349,7 +349,6 @@ pub struct AppState {
|
|||||||
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||||||
background_image: Option<bytes::Bytes>,
|
background_image: Option<bytes::Bytes>,
|
||||||
peers: HashMap<EndpointId, PeerState>,
|
peers: HashMap<EndpointId, PeerState>,
|
||||||
peer_volumes: HashMap<EndpointId, f32>,
|
|
||||||
audio_levels: HashMap<EndpointId, f32>,
|
audio_levels: HashMap<EndpointId, f32>,
|
||||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||||
locally_muted: HashSet<EndpointId>,
|
locally_muted: HashSet<EndpointId>,
|
||||||
@@ -474,6 +473,11 @@ impl Default for AppState {
|
|||||||
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
|
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 =
|
let pixelpass_available =
|
||||||
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
||||||
let all_devices = enumerate_audio_devices();
|
let all_devices = enumerate_audio_devices();
|
||||||
@@ -505,7 +509,6 @@ impl Default for AppState {
|
|||||||
config,
|
config,
|
||||||
background_image,
|
background_image,
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
peer_volumes: HashMap::new(),
|
|
||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
locally_muted: HashSet::new(),
|
locally_muted: HashSet::new(),
|
||||||
call_started: None,
|
call_started: None,
|
||||||
@@ -744,6 +747,20 @@ fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32
|
|||||||
pan
|
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 {
|
fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
|
||||||
config
|
config
|
||||||
.peer_eq
|
.peer_eq
|
||||||
@@ -1038,7 +1055,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.config.save();
|
state.config.save();
|
||||||
}
|
}
|
||||||
AppMessage::PeerVolumeChanged(id, vol) => {
|
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));
|
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
|
||||||
}
|
}
|
||||||
AppMessage::PeerPanChanged(id, pan) => {
|
AppMessage::PeerPanChanged(id, pan) => {
|
||||||
@@ -3348,11 +3365,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
].spacing(8);
|
].spacing(8);
|
||||||
|
|
||||||
// Peer volume slider
|
// 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(
|
card_content = card_content.push(
|
||||||
row![
|
row![
|
||||||
text("Vol:").size(12).color(color_subtext),
|
text("Vol:").size(12).color(color_subtext),
|
||||||
slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v))
|
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)
|
].spacing(8).align_y(iced::alignment::Vertical::Center)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4900,8 +4924,30 @@ impl Program<AppMessage> for Icon {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
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]
|
#[test]
|
||||||
fn x11_restores_saved_window_position() {
|
fn x11_restores_saved_window_position() {
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ pub struct AppConfig {
|
|||||||
/// keyed by peer node id string. Local preference only.
|
/// keyed by peer node id string. Local preference only.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub peer_pan: HashMap<String, f32>,
|
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.
|
/// Focused app-local keyboard shortcuts.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hotkeys: crate::hotkeys::HotkeyMap,
|
pub hotkeys: crate::hotkeys::HotkeyMap,
|
||||||
@@ -317,6 +321,7 @@ impl Default for AppConfig {
|
|||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
peer_eq: HashMap::new(),
|
peer_eq: HashMap::new(),
|
||||||
peer_pan: HashMap::new(),
|
peer_pan: HashMap::new(),
|
||||||
|
peer_volume: HashMap::new(),
|
||||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||||
window_width: default_window_width(),
|
window_width: default_window_width(),
|
||||||
window_height: default_window_height(),
|
window_height: default_window_height(),
|
||||||
@@ -457,6 +462,7 @@ mod tests {
|
|||||||
// shortcut settings.
|
// shortcut settings.
|
||||||
assert!(deserialized.peer_eq.is_empty());
|
assert!(deserialized.peer_eq.is_empty());
|
||||||
assert!(deserialized.peer_pan.is_empty());
|
assert!(deserialized.peer_pan.is_empty());
|
||||||
|
assert!(deserialized.peer_volume.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::hotkeys::format_binding(
|
crate::hotkeys::format_binding(
|
||||||
deserialized
|
deserialized
|
||||||
|
|||||||
Reference in New Issue
Block a user