Merge branch 'feature/room-screen': in-room VU meters, local mute, call info bar
This commit is contained in:
+116
-16
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user