diff --git a/src/app/mod.rs b/src/app/mod.rs index 81df11c..5236b59 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -700,6 +700,8 @@ pub enum AppMessage { PeerPanChanged(EndpointId, f32), PeerGateChanged(EndpointId, 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). TogglePeerMute(EndpointId), InputDeviceSelected(AudioDevice), @@ -1047,6 +1049,9 @@ pub struct AppState { conn_stats: HashMap, /// Peers we've locally muted (their audio isn't mixed into our output). locally_muted: HashSet, + /// 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, /// When we joined the current room, for the in-room call-duration timer. call_started: Option, /// Whether a local call recording is in progress (confirmed by the core). @@ -1240,6 +1245,7 @@ impl AppState { self.audio_levels.clear(); self.conn_stats.clear(); self.locally_muted.clear(); + self.peer_audio_expanded.clear(); self.chat_messages.clear(); // Unsent queue + retry bytes die with the room's transcript. The pacer // and id counter deliberately survive: receivers' per-author buckets @@ -1502,6 +1508,7 @@ impl Default for AppState { audio_levels: HashMap::new(), conn_stats: HashMap::new(), locally_muted: HashSet::new(), + peer_audio_expanded: HashSet::new(), call_started: None, recording: false, recording_started: None, @@ -2217,6 +2224,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.peers.remove(&id); state.audio_levels.remove(&id); state.locally_muted.remove(&id); + state.peer_audio_expanded.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); if state.music_listening_to == Some(id) { @@ -2238,6 +2246,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.peers.remove(&id); state.audio_levels.remove(&id); state.locally_muted.remove(&id); + state.peer_audio_expanded.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); notify::play( @@ -2588,6 +2597,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { let settings = set_peer_eq_config(&mut state.config, id, band, gain_db); 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) => { let now_muted = if state.locally_muted.contains(&id) { state.locally_muted.remove(&id); @@ -6748,15 +6762,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(8); - // Peer volume slider - let current_vol = state - .config - .peer_volume - .get(&peer_id.to_string()) - .copied() - .unwrap_or(1.0); + let advanced_audio_open = state.peer_audio_expanded.contains(peer_id); + let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" }; card_content = card_content.push( - row![ + 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 + .config + .peer_volume + .get(&peer_key) + .copied() + .unwrap_or(1.0); + let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0); + let current_gate = state + .config + .peer_gate + .get(&peer_key) + .copied() + .unwrap_or(0.0); + let gate_label = if current_gate <= 0.0 { + "Off".to_string() + } else { + format!( + "{:.0}%", + (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0) + ) + }; + + 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) @@ -6765,47 +6812,24 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_release(AppMessage::PersistConfig) ] .spacing(8) - .align_y(iced::alignment::Vertical::Center), - ); + .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); - card_content = card_content.push( - row![ + 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) - ) + 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), - ); + .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 - .config - .peer_gate - .get(&peer_key) - .copied() - .unwrap_or(0.0); - let gate_label = if current_gate <= 0.0 { - "Off".to_string() - } else { - format!( - "{:.0}%", - (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0) - ) - }; - card_content = card_content.push( - row![ + // 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), container(text(gate_label).size(11).color(color_subtext)) .width(iced::Length::Fixed(58.0)), @@ -6816,38 +6840,45 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_release(AppMessage::PersistConfig), ] .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_row = - |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { - row![ - container( - text(format!("{label} {value:+.1} dB")) - .size(11) - .color(color_subtext) - ) - .width(iced::Length::Fixed(86.0)), - slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { - AppMessage::PeerEqChanged(peer_id_clone, band, v) - }) - .step(0.5) - .on_release(AppMessage::PersistConfig), - ] - .spacing(8) - .align_y(iced::alignment::Vertical::Center) - .into() - }; - card_content = card_content.push( - column![ + let eq = peer_eq_settings(&state.config, peer_id); + let eq_row = + |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> { + row![ + container( + text(format!("{label} {value:+.1} dB")) + .size(11) + .color(color_subtext) + ) + .width(iced::Length::Fixed(86.0)), + slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| { + AppMessage::PeerEqChanged(peer_id_clone, band, v) + }) + .step(0.5) + .on_release(AppMessage::PersistConfig), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) + .into() + }; + let advanced_audio = column![ + volume_row, + pan_row, + gate_row, text("EQ").size(11).color(color_subtext), eq_row("Low", EqBand::Low, eq.low_gain_db), eq_row("Mid", EqBand::Mid, eq.mid_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) .style(c_style( @@ -9776,6 +9807,7 @@ mod tests { ); state.audio_levels.insert(peer, 0.5); state.locally_muted.insert(peer); + state.peer_audio_expanded.insert(peer); state.chat_messages.push(ChatEntry { name: "Peer".to_string(), text: "old room".to_string(), @@ -9837,6 +9869,7 @@ mod tests { assert!(state.peers.is_empty()); assert!(state.audio_levels.is_empty()); assert!(state.locally_muted.is_empty()); + assert!(state.peer_audio_expanded.is_empty()); assert!(state.chat_messages.is_empty()); assert!(state.chat_input.is_empty()); assert!(state.attachments.len() == 0); @@ -9886,6 +9919,34 @@ mod tests { 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] fn clock_skew_warning_shows_dismisses_and_expires() { let mut state = AppState::default();