ui: fold participant audio controls
This commit is contained in:
+127
-66
@@ -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<EndpointId, crate::core::connstats::PeerConnInfo>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
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.
|
||||
call_started: Option<std::time::Instant>,
|
||||
/// 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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user