ui: fold participant audio controls

This commit is contained in:
2026-07-18 20:14:09 -04:00
parent 3d7b01c8a2
commit 26d66007de
+104 -43
View File
@@ -700,6 +700,8 @@ pub enum AppMessage {
PeerPanChanged(EndpointId, f32), PeerPanChanged(EndpointId, f32),
PeerGateChanged(EndpointId, f32), PeerGateChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32), PeerEqChanged(EndpointId, EqBand, f32),
/// Show or hide the secondary audio controls on one participant card.
TogglePeerAdvancedAudio(EndpointId),
/// Toggle local mute of a peer (silence them just for us). /// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId), TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice), InputDeviceSelected(AudioDevice),
@@ -1047,6 +1049,9 @@ pub struct AppState {
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>, conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
/// 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>,
/// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
/// a fresh room starts compact, regardless of the previous room's UI state.
peer_audio_expanded: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer. /// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>, call_started: Option<std::time::Instant>,
/// Whether a local call recording is in progress (confirmed by the core). /// Whether a local call recording is in progress (confirmed by the core).
@@ -1240,6 +1245,7 @@ impl AppState {
self.audio_levels.clear(); self.audio_levels.clear();
self.conn_stats.clear(); self.conn_stats.clear();
self.locally_muted.clear(); self.locally_muted.clear();
self.peer_audio_expanded.clear();
self.chat_messages.clear(); self.chat_messages.clear();
// Unsent queue + retry bytes die with the room's transcript. The pacer // Unsent queue + retry bytes die with the room's transcript. The pacer
// and id counter deliberately survive: receivers' per-author buckets // and id counter deliberately survive: receivers' per-author buckets
@@ -1502,6 +1508,7 @@ impl Default for AppState {
audio_levels: HashMap::new(), audio_levels: HashMap::new(),
conn_stats: HashMap::new(), conn_stats: HashMap::new(),
locally_muted: HashSet::new(), locally_muted: HashSet::new(),
peer_audio_expanded: HashSet::new(),
call_started: None, call_started: None,
recording: false, recording: false,
recording_started: None, recording_started: None,
@@ -2217,6 +2224,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id); state.peers.remove(&id);
state.audio_levels.remove(&id); state.audio_levels.remove(&id);
state.locally_muted.remove(&id); state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id); state.connecting.remove(&id);
state.ever_connected.remove(&id); state.ever_connected.remove(&id);
if state.music_listening_to == Some(id) { if state.music_listening_to == Some(id) {
@@ -2238,6 +2246,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id); state.peers.remove(&id);
state.audio_levels.remove(&id); state.audio_levels.remove(&id);
state.locally_muted.remove(&id); state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id); state.connecting.remove(&id);
state.ever_connected.remove(&id); state.ever_connected.remove(&id);
notify::play( notify::play(
@@ -2588,6 +2597,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings)); let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
} }
AppMessage::TogglePeerAdvancedAudio(id) => {
if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
state.peer_audio_expanded.insert(id);
}
}
AppMessage::TogglePeerMute(id) => { AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) { let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id); state.locally_muted.remove(&id);
@@ -6748,48 +6762,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(8); .spacing(8);
// Peer volume slider let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
let foldout_symbol = if advanced_audio_open { "" } else { "" };
card_content = card_content.push(
button(
row![
text(foldout_symbol).size(13).color(color_subtext),
text("Advanced audio").size(12).color(color_text),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
)
.on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding([6, 8])
.width(iced::Length::Fill),
);
if advanced_audio_open {
let peer_key = peer_id.to_string();
let current_vol = state let current_vol = state
.config .config
.peer_volume .peer_volume
.get(&peer_id.to_string()) .get(&peer_key)
.copied() .copied()
.unwrap_or(1.0); .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),
);
let peer_key = peer_id.to_string();
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0); let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
card_content = card_content.push(
row![
text("Pan:").size(12).color(color_subtext),
container(text(pan_label(current_pan)).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
slider(
-1.0..=1.0,
current_pan,
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
)
.step(0.05)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
// Peer noise gate: suppress this peer's background noise on our end.
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
// mic gate; 0 = off.
let current_gate = state let current_gate = state
.config .config
.peer_gate .peer_gate
@@ -6804,8 +6802,34 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0) (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
) )
}; };
card_content = card_content.push(
row![ let volume_row = 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);
let pan_row = row![
text("Pan:").size(12).color(color_subtext),
container(text(pan_label(current_pan)).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
slider(-1.0..=1.0, current_pan, move |v| {
AppMessage::PeerPanChanged(peer_id_clone, v)
})
.step(0.05)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center);
// Peer noise gate: suppress this peer's background noise on our
// end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
let gate_row = row![
text("Gate:").size(12).color(color_subtext), text("Gate:").size(12).color(color_subtext),
container(text(gate_label).size(11).color(color_subtext)) container(text(gate_label).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)), .width(iced::Length::Fixed(58.0)),
@@ -6816,8 +6840,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_release(AppMessage::PersistConfig), .on_release(AppMessage::PersistConfig),
] ]
.spacing(8) .spacing(8)
.align_y(iced::alignment::Vertical::Center), .align_y(iced::alignment::Vertical::Center);
);
let eq = peer_eq_settings(&state.config, peer_id); let eq = peer_eq_settings(&state.config, peer_id);
let eq_row = let eq_row =
@@ -6839,15 +6862,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.align_y(iced::alignment::Vertical::Center) .align_y(iced::alignment::Vertical::Center)
.into() .into()
}; };
card_content = card_content.push( let advanced_audio = column![
column![ volume_row,
pan_row,
gate_row,
text("EQ").size(11).color(color_subtext), text("EQ").size(11).color(color_subtext),
eq_row("Low", EqBand::Low, eq.low_gain_db), eq_row("Low", EqBand::Low, eq.low_gain_db),
eq_row("Mid", EqBand::Mid, eq.mid_gain_db), eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
eq_row("High", EqBand::High, eq.high_gain_db), eq_row("High", EqBand::High, eq.high_gain_db),
] ]
.spacing(4), .spacing(6);
card_content = card_content.push(
container(advanced_audio)
.style(c_style(color_crust, color_surface, 6.0))
.padding(10)
.width(iced::Length::Fill),
); );
}
let card = container(card_content) let card = container(card_content)
.style(c_style( .style(c_style(
@@ -9776,6 +9807,7 @@ mod tests {
); );
state.audio_levels.insert(peer, 0.5); state.audio_levels.insert(peer, 0.5);
state.locally_muted.insert(peer); state.locally_muted.insert(peer);
state.peer_audio_expanded.insert(peer);
state.chat_messages.push(ChatEntry { state.chat_messages.push(ChatEntry {
name: "Peer".to_string(), name: "Peer".to_string(),
text: "old room".to_string(), text: "old room".to_string(),
@@ -9837,6 +9869,7 @@ mod tests {
assert!(state.peers.is_empty()); assert!(state.peers.is_empty());
assert!(state.audio_levels.is_empty()); assert!(state.audio_levels.is_empty());
assert!(state.locally_muted.is_empty()); assert!(state.locally_muted.is_empty());
assert!(state.peer_audio_expanded.is_empty());
assert!(state.chat_messages.is_empty()); assert!(state.chat_messages.is_empty());
assert!(state.chat_input.is_empty()); assert!(state.chat_input.is_empty());
assert!(state.attachments.len() == 0); assert!(state.attachments.len() == 0);
@@ -9886,6 +9919,34 @@ mod tests {
panic!("clip player did not stop during room reset"); panic!("clip player did not stop during room reset");
} }
#[test]
fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
let mut state = AppState::default();
let peer = SecretKey::generate().public();
state.peers.insert(
peer,
crate::network::PeerState {
name: "Peer".to_string(),
is_muted: false,
addr: iroh::EndpointAddr::from(peer),
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
},
);
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(state.peer_audio_expanded.contains(&peer));
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(!state.peer_audio_expanded.contains(&peer));
let stale = SecretKey::generate().public();
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
assert!(!state.peer_audio_expanded.contains(&stale));
}
#[test] #[test]
fn clock_skew_warning_shows_dismisses_and_expires() { fn clock_skew_warning_shows_dismisses_and_expires() {
let mut state = AppState::default(); let mut state = AppState::default();