Merge branch 'feature/room-screen': in-room VU meters, local mute, call info bar

This commit is contained in:
2026-06-02 16:57:50 -04:00
3 changed files with 140 additions and 16 deletions
+116 -16
View File
@@ -6,7 +6,7 @@ use crate::config::{AppConfig, NetworkMode};
use iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
canvas, Canvas, Column,
progress_bar, canvas, Canvas, Column,
};
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{
@@ -41,6 +41,8 @@ pub enum AppMessage {
TogglePtt(bool),
StartSettingHotkey,
PeerVolumeChanged(EndpointId, f32),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice),
OutputDeviceSelected(AudioDevice),
/// Live input-gain drag (applies immediately, persisted on release).
@@ -97,6 +99,10 @@ pub struct AppState {
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
mic_level: f32,
/// Whether the standalone (off-call) mic test stream is running.
@@ -166,6 +172,8 @@ impl Default for AppState {
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
mic_level: 0.0,
mic_test_active: false,
connecting: HashSet::new(),
@@ -290,6 +298,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.self_id = self_id;
state.status_message = "Connected".to_string();
state.current_screen = Screen::Room;
state.call_started = Some(std::time::Instant::now());
// The core tore down any standalone mic monitor when joining;
// the in-call meter now drives mic_level.
state.mic_test_active = false;
@@ -299,6 +308,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ticket = "".to_string();
state.peers.clear();
state.audio_levels.clear();
state.locally_muted.clear();
state.call_started = None;
state.connecting.clear();
state.ever_connected.clear();
state.status_message = "Ready to connect".to_string();
@@ -313,6 +324,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::PeerLeft { id } => {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref());
@@ -320,6 +332,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::PeerConnectionFailed { id } => {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(Sound::ReconnectFailed, state.config.custom_sound_reconnect_failed.as_deref());
@@ -370,6 +383,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peer_volumes.insert(id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
}
AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id);
false
} else {
state.locally_muted.insert(id);
true
};
let _ = state.controller.send(CoreCommand::SetPeerMuted(id, now_muted));
}
AppMessage::InputDeviceSelected(dev) => {
state.config.input_device = dev.name.clone();
state.config.save();
@@ -490,6 +513,18 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str {
}
}
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
fn format_duration(total_secs: u64) -> String {
let h = total_secs / 3600;
let m = (total_secs % 3600) / 60;
let s = total_secs % 60;
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
fn horizontal_space() -> iced::widget::Space {
iced::widget::Space::new().width(iced::Length::Fill)
}
@@ -849,20 +884,29 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.into()
} else {
// --- ROOM SCREEN ---
let participant_count = state.peers.len() + 1; // peers + you
let call_secs = state.call_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
let header = row![
text("PEERSPEAK")
.size(20)
.color(color_blue),
horizontal_space(),
text(format!("My ID: {}", &state.self_id[..8]))
text(format!("👥 {participant_count} in room"))
.size(14)
.color(color_subtext),
text(format!("{}", format_duration(call_secs)))
.size(14)
.color(color_subtext),
horizontal_space(),
text(format!("My ID: {}", &state.self_id[..8]))
.size(14)
.color(color_subtext),
button(text("Copy Ticket").size(12))
.on_press(AppMessage::CopyToClipboard)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(6)
]
.spacing(16)
.align_y(iced::alignment::Vertical::Center);
let header_container = container(header)
@@ -873,18 +917,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Peers Column
let mut peers_list = Column::new().spacing(10);
// Add ourselves
// Add ourselves — name/status row plus a live mic meter so you can
// confirm you're being picked up (and see mute / PTT / gate at work).
let transmitting = !state.is_muted && (!state.ptt_enabled || state.ptt_active);
let self_mic_color = if transmitting { color_green } else { color_subtext };
let self_card = container(
row![
text(format!("{} (You)", &state.name)).size(16).color(color_text),
horizontal_space(),
if state.is_muted {
text("[Muted]").size(14).color(color_red)
} else {
text("[Active]").size(14).color(color_green)
}
]
.align_y(iced::alignment::Vertical::Center)
column![
row![
text(format!("{} (You)", &state.name)).size(16).color(color_text),
horizontal_space(),
if state.is_muted {
text("[Muted]").size(14).color(color_red)
} else {
text("[Active]").size(14).color(color_green)
}
]
.align_y(iced::alignment::Vertical::Center),
progress_bar(0.0..=0.3, state.mic_level)
.girth(8.0)
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
background: Background::Color(color_crust),
bar: Background::Color(self_mic_color),
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
}),
].spacing(8)
)
.style(c_style(color_base, color_surface, 6.0))
.padding(12);
@@ -910,6 +966,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text("[Idle]").size(14).color(color_subtext)
};
let peer_id_clone = *peer_id;
let is_locally_muted = state.locally_muted.contains(peer_id);
// Local-mute toggle (silences this peer for us only).
let (mute_label, mute_bg, mute_fg) = if is_locally_muted {
("🔇", color_red, color_crust)
} else {
("🔊", color_surface, color_text)
};
let mute_btn = button(text(mute_label).size(14))
.on_press(AppMessage::TogglePeerMute(peer_id_clone))
.style(b_style(mute_bg, color_blue, mute_fg, 6.0))
.padding(6);
// VU meter colour: dim when locally muted (you don't hear them),
// green while speaking, faint otherwise.
let vu_color = if is_locally_muted {
color_subtext
} else if is_speaking {
color_green
} else {
color_surface
};
let mut card_content = column![
row![
column![
@@ -917,14 +997,22 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext)
],
horizontal_space(),
mute_btn,
indicator
]
.align_y(iced::alignment::Vertical::Center)
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
progress_bar(0.0..=0.3, level)
.girth(8.0)
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
background: Background::Color(color_crust),
bar: Background::Color(vu_color),
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
}),
].spacing(8);
// Peer volume slider
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
let peer_id_clone = *peer_id;
card_content = card_content.push(
row![
text("Vol:").size(12).color(color_subtext),
@@ -1178,7 +1266,19 @@ impl Program<AppMessage> for GateMeter {
#[cfg(test)]
mod tests {
use super::{reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
#[test]
fn format_duration_renders_mss_and_hmmss() {
assert_eq!(format_duration(0), "0:00");
assert_eq!(format_duration(5), "0:05");
assert_eq!(format_duration(65), "1:05");
assert_eq!(format_duration(600), "10:00");
// Past an hour switches to h:mm:ss with zero-padded minutes/seconds.
assert_eq!(format_duration(3600), "1:00:00");
assert_eq!(format_duration(3661), "1:01:01");
assert_eq!(format_duration(3725), "1:02:05");
}
use crate::notify::Sound;
use iroh::EndpointId;
use std::collections::HashSet;
+3
View File
@@ -11,6 +11,9 @@ pub enum CoreCommand {
SetPttMode(bool),
SetPttActive(bool),
SetPeerVolume(EndpointId, f32),
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
/// still show) but not mixed into our output.
SetPeerMuted(EndpointId, bool),
SetNoiseGateThreshold(f32),
/// App-internal capture gain (mic), applied before the gate/encode. 1.0 = unity.
SetInputVolume(f32),
+21
View File
@@ -380,6 +380,8 @@ async fn run_core_loop(
let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string();
let mut network_mode = NetworkMode::default();
@@ -660,6 +662,7 @@ async fn run_core_loop(
let jitter_mixer = jitter.clone();
let is_deafened_clone = is_deafened.clone();
let peer_volumes_mixer = peer_volumes.clone();
let locally_muted_mixer = locally_muted.clone();
let output_gain_mixer = output_gain.clone();
let ui_tx_mixer = ui_tx.clone();
let ring_fill_mixer = ring_fill.clone();
@@ -687,6 +690,7 @@ async fn run_core_loop(
}
let current_volumes = peer_volumes_mixer.lock().await.clone();
let muted_peers = locally_muted_mixer.lock().await.clone();
let mut peer_frames = Vec::new();
{
@@ -702,9 +706,17 @@ async fn run_core_loop(
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(&mut frame, vol);
// Level is recorded even for locally-muted peers so
// the UI still shows that they're speaking.
let peak = level_peaks.entry(peer_id).or_insert(0.0);
*peak = peak.max(frame_level(&frame));
// Locally muted: decoded above (jitter buffer advances,
// level shown) but not mixed into our output.
if muted_peers.contains(&peer_id) {
continue;
}
peer_frames.push(frame);
}
}
@@ -885,6 +897,15 @@ async fn run_core_loop(
guard.insert(peer_id, vol);
}
CoreCommand::SetPeerMuted(peer_id, muted) => {
let mut guard = locally_muted.lock().await;
if muted {
guard.insert(peer_id);
} else {
guard.remove(&peer_id);
}
}
CoreCommand::SetNoiseGateThreshold(threshold) => {
noise_gate_threshold.store(threshold.to_bits(), Ordering::Relaxed);
}