diff --git a/src/app/mod.rs b/src/app/mod.rs index dd94466..b23f231 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, peer_volumes: HashMap, audio_levels: HashMap, + /// Peers we've locally muted (their audio isn't mixed into our output). + locally_muted: HashSet, + /// When we joined the current room, for the in-room call-duration timer. + call_started: Option, /// 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 { 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 { 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 { 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 { 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 { 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 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; diff --git a/src/core/messages.rs b/src/core/messages.rs index 91e2260..3c89cf6 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -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), diff --git a/src/core/mod.rs b/src/core/mod.rs index 03cab21..1bfea36 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -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::::new())); + // Peers locally muted by us: decoded for level metering but not mixed. + let locally_muted = Arc::new(Mutex::new(HashSet::::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); }