ui: fold participant audio controls
This commit is contained in:
+127
-66
@@ -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,15 +6762,48 @@ 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 current_vol = state
|
let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" };
|
||||||
.config
|
|
||||||
.peer_volume
|
|
||||||
.get(&peer_id.to_string())
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(1.0);
|
|
||||||
card_content = card_content.push(
|
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),
|
text("Vol:").size(12).color(color_subtext),
|
||||||
slider(0.0..=2.0, current_vol, move |v| {
|
slider(0.0..=2.0, current_vol, move |v| {
|
||||||
AppMessage::PeerVolumeChanged(peer_id_clone, v)
|
AppMessage::PeerVolumeChanged(peer_id_clone, v)
|
||||||
@@ -6765,47 +6812,24 @@ 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 peer_key = peer_id.to_string();
|
let pan_row = row![
|
||||||
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),
|
text("Pan:").size(12).color(color_subtext),
|
||||||
container(text(pan_label(current_pan)).size(11).color(color_subtext))
|
container(text(pan_label(current_pan)).size(11).color(color_subtext))
|
||||||
.width(iced::Length::Fixed(58.0)),
|
.width(iced::Length::Fixed(58.0)),
|
||||||
slider(
|
slider(-1.0..=1.0, current_pan, move |v| {
|
||||||
-1.0..=1.0,
|
AppMessage::PeerPanChanged(peer_id_clone, v)
|
||||||
current_pan,
|
})
|
||||||
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
|
|
||||||
)
|
|
||||||
.step(0.05)
|
.step(0.05)
|
||||||
.on_release(AppMessage::PersistConfig),
|
.on_release(AppMessage::PersistConfig),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.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.
|
// Peer noise gate: suppress this peer's background noise on our
|
||||||
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
|
// end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
|
||||||
// mic gate; 0 = off.
|
let gate_row = row![
|
||||||
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![
|
|
||||||
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,38 +6840,45 @@ 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 =
|
||||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||||
row![
|
row![
|
||||||
container(
|
container(
|
||||||
text(format!("{label} {value:+.1} dB"))
|
text(format!("{label} {value:+.1} dB"))
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(color_subtext)
|
.color(color_subtext)
|
||||||
)
|
)
|
||||||
.width(iced::Length::Fixed(86.0)),
|
.width(iced::Length::Fixed(86.0)),
|
||||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||||
})
|
})
|
||||||
.step(0.5)
|
.step(0.5)
|
||||||
.on_release(AppMessage::PersistConfig),
|
.on_release(AppMessage::PersistConfig),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.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();
|
||||||
|
|||||||
Reference in New Issue
Block a user