use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, canvas, Canvas, Column, }; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::{ Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse, Point, Rectangle, Renderer, Size, }; use iroh::EndpointId; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; use tokio::sync::Mutex; static UI_RX: OnceLock>>> = OnceLock::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Home, Room, Settings, } #[derive(Debug, Clone)] pub enum AppMessage { NicknameChanged(String), TicketInputChanged(String), JoinPressed, CreatePressed, LeavePressed, ToggleMutePressed, ToggleDeafenPressed, UiEventReceived(UiEvent), CopyToClipboard, TogglePtt(bool), StartSettingHotkey, PeerVolumeChanged(EndpointId, f32), InputDeviceSelected(AudioDevice), OutputDeviceSelected(AudioDevice), NoiseGateChanged(f32), /// Live value while dragging the gate handle on the meter — updates the gate /// immediately but does not persist (saved once on release via NoiseGateChanged). NoiseGateDragging(f32), NetworkModeSelected(NetworkMode), EventOccurred(Event), NavigateToSettings, NavigateBack, ToggleNotifications(bool), ToggleEchoCancellation(bool), CustomSoundPathChanged(Sound, String), ToggleMicTest(bool), } fn core_subscription() -> impl iced::futures::Stream { iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender| async move { if let Some(rx_lock) = UI_RX.get() { let mut guard = rx_lock.lock().await; if let Some(mut rx) = guard.take() { use iced::futures::sink::SinkExt; while let Some(event) = rx.recv().await { let _ = output.send(event).await; } } } }) } pub struct AppState { name: String, ticket_input: String, status_message: String, self_id: String, ticket: String, is_muted: bool, is_deafened: bool, ptt_enabled: bool, ptt_active: bool, ptt_hotkey: keyboard::Key, is_setting_hotkey: bool, input_devices: Vec, output_devices: Vec, selected_input: Option, selected_output: Option, config: AppConfig, peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, /// 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. mic_test_active: bool, /// Peers whose audio link is currently down (initial connect or reconnect). connecting: HashSet, /// Peers we've had a live link to at least once — used to say "Reconnecting" /// rather than "Connecting" the second time around. ever_connected: HashSet, controller: Arc, current_screen: Screen, } impl AppState { fn custom_sound_path(&self, sound: Sound) -> &str { let opt = match sound { Sound::SelfJoin => &self.config.custom_sound_self_join, Sound::PeerJoin => &self.config.custom_sound_peer_join, Sound::PeerLeave => &self.config.custom_sound_peer_leave, Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt, Sound::Reconnected => &self.config.custom_sound_reconnected, Sound::SelfLeave => &self.config.custom_sound_self_leave, Sound::MicToggle => &self.config.custom_sound_mic_toggle, Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed, }; opt.as_deref().unwrap_or("") } } impl Default for AppState { fn default() -> Self { let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100); let controller = Arc::new(CoreController::new(ui_tx)); let _ = UI_RX.set(Mutex::new(Some(ui_rx))); let config = AppConfig::load(); notify::set_enabled(config.notifications_enabled); let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold)); let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode)); let all_devices = enumerate_audio_devices(); let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect(); let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect(); let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned(); let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); Self { name: "Peer".to_string(), ticket_input: "".to_string(), status_message: "Ready to connect".to_string(), self_id: "".to_string(), ticket: "".to_string(), is_muted: false, is_deafened: false, ptt_enabled: false, ptt_active: false, ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), is_setting_hotkey: false, input_devices, output_devices, selected_input, selected_output, config, peers: HashMap::new(), peer_volumes: HashMap::new(), audio_levels: HashMap::new(), mic_level: 0.0, mic_test_active: false, connecting: HashSet::new(), ever_connected: HashSet::new(), controller, current_screen: Screen::Home, } } } fn theme(_state: &AppState) -> Theme { Theme::Dark } pub fn run_gui() -> iced::Result { iced::application(AppState::default, update, view) .title("PeerSpeak P2P Voice Chat") .theme(theme) .subscription(subscription) .window(iced::window::Settings { size: iced::Size::new(900.0, 600.0), position: iced::window::Position::Centered, exit_on_close_request: true, ..Default::default() }) .run() } fn subscription(_state: &AppState) -> Subscription { let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); let event_sub = iced::event::listen().map(AppMessage::EventOccurred); Subscription::batch(vec![core_sub, event_sub]) } /// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as /// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage: /// only when the peer had a live link before (a genuine reconnect, not a first /// dial) AND we weren't already in the connecting state (so the supervisor's /// repeated redials while still down don't re-chime). Pure so the once-per- /// disconnect behavior is unit-testable without a GUI or audio. fn reconnect_attempt_chime( connecting: &mut HashSet, ever_connected: &HashSet, id: EndpointId, ) -> Option { let is_reconnect_attempt = ever_connected.contains(&id); let was_already_connecting = connecting.contains(&id); connecting.insert(id); (is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt) } /// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting /// state, records that we've linked with this peer at least once, and returns /// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not /// the first link). Pure so the logic is unit-testable. fn reconnected_chime( connecting: &mut HashSet, ever_connected: &mut HashSet, id: EndpointId, ) -> Option { let was_reconnect = ever_connected.contains(&id); connecting.remove(&id); ever_connected.insert(id); was_reconnect.then_some(Sound::Reconnected) } fn update(state: &mut AppState, message: AppMessage) -> Task { match message { AppMessage::NicknameChanged(val) => { state.name = val; } AppMessage::TicketInputChanged(val) => { state.ticket_input = val; } AppMessage::JoinPressed => { let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); if !state.ticket_input.is_empty() { state.status_message = "Joining room...".to_string(); // Core releases any standalone mic monitor on join. state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: state.ticket_input.clone(), input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, }); } } AppMessage::CreatePressed => { let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); let output_device = state.selected_output.as_ref().map(|d| d.name.clone()); state.status_message = "Creating room...".to_string(); // Core releases any standalone mic monitor on join. state.mic_test_active = false; let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: "create".to_string(), input_device, output_device, echo_cancellation: state.config.echo_cancellation_enabled, }); } AppMessage::LeavePressed => { let _ = state.controller.send(CoreCommand::Leave); } AppMessage::ToggleMutePressed => { let _ = state.controller.send(CoreCommand::ToggleMute); state.is_muted = !state.is_muted; notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); } AppMessage::ToggleDeafenPressed => { let _ = state.controller.send(CoreCommand::ToggleDeafen); state.is_deafened = !state.is_deafened; notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref()); } AppMessage::UiEventReceived(event) => { match event { UiEvent::RoomJoined { ticket, self_id } => { state.ticket = ticket; state.self_id = self_id; state.status_message = "Connected".to_string(); state.current_screen = Screen::Room; // The core tore down any standalone mic monitor when joining; // the in-call meter now drives mic_level. state.mic_test_active = false; notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref()); } UiEvent::RoomLeft => { state.ticket = "".to_string(); state.peers.clear(); state.audio_levels.clear(); state.connecting.clear(); state.ever_connected.clear(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; state.mic_level = 0.0; notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref()); } UiEvent::PeerJoined { id, state: peer_state } => { state.peers.insert(id, peer_state); notify::play(Sound::PeerJoin, state.config.custom_sound_peer_join.as_deref()); } UiEvent::PeerLeft { id } => { state.peers.remove(&id); state.audio_levels.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); notify::play(Sound::PeerLeave, state.config.custom_sound_peer_leave.as_deref()); } UiEvent::PeerConnectionFailed { id } => { state.peers.remove(&id); state.audio_levels.remove(&id); state.connecting.remove(&id); state.ever_connected.remove(&id); notify::play(Sound::ReconnectFailed, state.config.custom_sound_reconnect_failed.as_deref()); } UiEvent::PeerUpdated { id, state: peer_state } => { state.peers.insert(id, peer_state); } UiEvent::PeerConnecting { id } => { if let Some(sound) = reconnect_attempt_chime(&mut state.connecting, &state.ever_connected, id) { notify::play(sound, state.config.custom_sound_reconnect_attempt.as_deref()); } } UiEvent::PeerConnected { id } => { if let Some(sound) = reconnected_chime(&mut state.connecting, &mut state.ever_connected, id) { notify::play(sound, state.config.custom_sound_reconnected.as_deref()); } } UiEvent::AudioLevels(levels) => { for (id, val) in levels { state.audio_levels.insert(id, val); } } UiEvent::MicLevel(level) => { state.mic_level = level; } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); } } } AppMessage::CopyToClipboard => { if !state.ticket.is_empty() { return iced::clipboard::write(state.ticket.clone()); } } AppMessage::TogglePtt(enabled) => { state.ptt_enabled = enabled; let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); } AppMessage::StartSettingHotkey => { state.is_setting_hotkey = true; } AppMessage::PeerVolumeChanged(id, vol) => { state.peer_volumes.insert(id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } AppMessage::InputDeviceSelected(dev) => { state.config.input_device = dev.name.clone(); state.config.save(); state.selected_input = Some(dev); } AppMessage::OutputDeviceSelected(dev) => { state.config.output_device = dev.name.clone(); state.config.save(); state.selected_output = Some(dev); } AppMessage::NoiseGateChanged(val) => { state.config.noise_gate_threshold = val; state.config.save(); let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); } AppMessage::NoiseGateDragging(val) => { // Live drag: apply immediately, defer the disk write to release. state.config.noise_gate_threshold = val; let _ = state.controller.send(CoreCommand::SetNoiseGateThreshold(val)); } AppMessage::NetworkModeSelected(mode) => { state.config.network_mode = mode; state.config.save(); // Applied on the next join, since the endpoint is rebuilt then. let _ = state.controller.send(CoreCommand::SetNetworkMode(mode)); } AppMessage::ToggleNotifications(enabled) => { state.config.notifications_enabled = enabled; state.config.save(); notify::set_enabled(enabled); } AppMessage::ToggleEchoCancellation(enabled) => { state.config.echo_cancellation_enabled = enabled; state.config.save(); // Applied on the next join, since the audio graph is rebuilt then. } AppMessage::CustomSoundPathChanged(sound, path) => { let path_opt = if path.trim().is_empty() { None } else { Some(path) }; match sound { Sound::SelfJoin => state.config.custom_sound_self_join = path_opt, Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt, Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt, Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt, Sound::Reconnected => state.config.custom_sound_reconnected = path_opt, Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt, Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt, Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt, } } AppMessage::ToggleMicTest(enabled) => { state.mic_test_active = enabled; if !enabled { state.mic_level = 0.0; } let input_device = state.selected_input.as_ref().map(|d| d.name.clone()); let _ = state .controller .send(CoreCommand::SetMicMonitor { enabled, input_device }); } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { state.ptt_hotkey = key.clone(); state.is_setting_hotkey = false; } else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active { state.ptt_active = true; let _ = state.controller.send(CoreCommand::SetPttActive(true)); } } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active { state.ptt_active = false; let _ = state.controller.send(CoreCommand::SetPttActive(false)); } } AppMessage::EventOccurred(_) => {} AppMessage::NavigateToSettings => { state.current_screen = Screen::Settings; } AppMessage::NavigateBack => { state.config.save(); // Release the mic when leaving Settings if the test was running. if state.mic_test_active { state.mic_test_active = false; state.mic_level = 0.0; let _ = state.controller.send(CoreCommand::SetMicMonitor { enabled: false, input_device: None, }); } if state.ticket.is_empty() { state.current_screen = Screen::Home; } else { state.current_screen = Screen::Room; } } } Task::none() } /// One-line explanation of a network posture for the settings picker. fn network_mode_hint(mode: NetworkMode) -> &'static str { match mode { NetworkMode::RelayNoDiscovery => "n0 relay for NAT traversal; no presence published to n0 DNS.", NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.", NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.", } } fn horizontal_space() -> iced::widget::Space { iced::widget::Space::new().width(iced::Length::Fill) } fn vertical_space(height: f32) -> iced::widget::Space { iced::widget::Space::new().height(height) } fn view(state: &AppState) -> Element<'_, AppMessage> { // Theme Colors let color_crust = Color::from_rgb8(17, 17, 27); let color_mantle = Color::from_rgb8(24, 24, 37); let color_base = Color::from_rgb8(30, 30, 46); let color_surface = Color::from_rgb8(49, 50, 68); let color_text = Color::from_rgb8(205, 214, 244); let color_subtext = Color::from_rgb8(166, 173, 200); let color_blue = Color::from_rgb8(137, 180, 250); let color_lavender = Color::from_rgb8(180, 190, 254); let color_red = Color::from_rgb8(243, 139, 168); let color_maroon = Color::from_rgb8(233, 146, 160); let color_green = Color::from_rgb8(166, 227, 161); let color_yellow = Color::from_rgb8(249, 226, 175); // Style Helpers let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { text_color: Some(color_text), background: Some(Background::Color(bg)), border: Border { color: b_color, width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, radius: radius.into(), }, ..Default::default() } }; let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| { move |_theme: &Theme, status: button::Status| { let active_bg = match status { button::Status::Hovered => hover_bg, _ => bg, }; button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into(), }, ..Default::default() } } }; let t_style = move |_theme: &Theme, _status: text_input::Status| { text_input::Style { background: Background::Color(color_crust), border: Border { color: color_surface, width: 1.0, radius: 6.0.into(), }, icon: color_subtext, placeholder: Color::from_rgb8(108, 112, 134), value: color_text, selection: color_blue, } }; let top_bar = row![ horizontal_space(), button(text("⚙ Settings").size(14)) .on_press(AppMessage::NavigateToSettings) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) ].width(iced::Length::Fill).padding(10); if state.current_screen == Screen::Settings { let path_field = |label: &'static str, sound: Sound| { let path = state.custom_sound_path(sound); let validation_widget = match notify::validate_custom_path(path) { None => text(""), Some(true) => text("✓ File found").size(10).color(color_green), Some(false) => text("✗ File not found").size(10).color(color_red), }; column![ row![ text(label).size(12).color(Color::from_rgb8(180, 180, 180)), horizontal_space(), validation_widget, ].align_y(iced::alignment::Vertical::Center), text_input("Default (embedded)...", path) .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) .style(t_style) .padding(8) ].spacing(4).width(iced::Length::Fill) }; // Live mic level meter for gate calibration. Shares the gate slider's // 0..0.1 scale so you can read your voice against the threshold directly. // During a call the in-call meter feeds it; otherwise a "Test mic" toggle // spins up a standalone capture stream. let in_call = !state.ticket.is_empty(); let mic_test_control: Element<'_, AppMessage> = if in_call { text("Live (in call)").size(11).color(color_green).into() } else { let (label, bg) = if state.mic_test_active { ("⏹ Stop mic test", color_red) } else { ("🎙 Test mic", color_surface) }; button(text(label).size(12)) .on_press(AppMessage::ToggleMicTest(!state.mic_test_active)) .style(b_style(bg, color_blue, color_text, 6.0)) .padding(6) .into() }; // Unified meter + draggable gate (Discord/OBS-style): the live mic level // fills the bar and the yellow handle is the gate threshold, dragged // directly on the same axis. Green fill = above the gate (transmitting), // dim = below it (muted). Live status word reinforces the colour. let gate_thresh = state.config.noise_gate_threshold; let speaking = state.mic_level >= 0.001; let passing = speaking && state.mic_level >= gate_thresh; let (status_label, status_color) = if !speaking { ("○ Idle", color_subtext) } else if passing { ("● Transmitting", color_green) } else { ("● Muted by gate", color_red) }; let gate_meter = Canvas::new(GateMeter { level: state.mic_level, threshold: gate_thresh, track: color_crust, border: color_surface, fill_on: color_green, fill_off: color_surface, handle: Color::from_rgb8(255, 40, 40), handle_edge: color_crust, }) .width(iced::Length::Fill) .height(iced::Length::Fixed(20.0)); let mic_meter = column![ gate_meter, row![ text(status_label).size(12).color(status_color), horizontal_space(), text(format!("gate {:.1}%", gate_thresh * 100.0)).size(11).color(color_subtext), horizontal_space(), mic_test_control, ].align_y(iced::alignment::Vertical::Center).spacing(8), ].spacing(6).width(iced::Length::Fill); let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), vertical_space(10.0), text("Device Settings").size(14).color(color_subtext), vertical_space(6.0), row![ column![ text("Input Device").size(12).color(Color::from_rgb8(180, 180, 180)), pick_list( &state.input_devices[..], state.selected_input.as_ref(), AppMessage::InputDeviceSelected, ).width(iced::Length::Fill), ].spacing(4).width(iced::Length::Fill), column![ text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)), pick_list( &state.output_devices[..], state.selected_output.as_ref(), AppMessage::OutputDeviceSelected, ).width(iced::Length::Fill), ].spacing(4).width(iced::Length::Fill), ].spacing(20).width(iced::Length::Fill), vertical_space(12.0), row![ column![ text("Mic Level & Noise Gate").size(14).color(color_subtext), mic_meter, text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext), vertical_space(4.0), checkbox(state.config.echo_cancellation_enabled) .label("Echo cancellation") .on_toggle(AppMessage::ToggleEchoCancellation), text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext), ].spacing(8).width(iced::Length::Fill), column![ text("Network Privacy").size(14).color(color_subtext), pick_list( &NetworkMode::ALL[..], Some(state.config.network_mode), AppMessage::NetworkModeSelected, ).width(iced::Length::Fill), text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext), text("Takes effect on your next room join.").size(11).color(color_surface), ].spacing(4).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), vertical_space(12.0), column![ text("Notification Chimes").size(14).color(color_subtext), checkbox(state.config.notifications_enabled) .label("Enable sound notifications") .on_toggle(AppMessage::ToggleNotifications), ].spacing(8).width(iced::Length::Fill), vertical_space(12.0), text("Custom Chime Files (WAV Paths)").size(14).color(color_subtext), row![ path_field("Self Join", Sound::SelfJoin), path_field("Peer Join", Sound::PeerJoin), ].spacing(20).width(iced::Length::Fill), row![ path_field("Self Leave", Sound::SelfLeave), path_field("Peer Leave", Sound::PeerLeave), ].spacing(20).width(iced::Length::Fill), row![ path_field("Reconnect Attempt", Sound::ReconnectAttempt), path_field("Reconnected", Sound::Reconnected), ].spacing(20).width(iced::Length::Fill), row![ path_field("Mic Toggle", Sound::MicToggle), path_field("Reconnect Failed", Sound::ReconnectFailed), ].spacing(20).width(iced::Length::Fill), vertical_space(14.0), button( text("Back") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::NavigateBack) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(14) .width(iced::Length::Fixed(150.0)) ] .align_x(iced::alignment::Horizontal::Center) .spacing(10) .width(iced::Length::Fill) ); let settings_box = container(settings_content) .style(c_style(color_mantle, color_surface, 12.0)) .padding(24) .width(iced::Length::Fill) .height(iced::Length::Fill); return container(settings_box) .width(iced::Length::Fill) .height(iced::Length::Fill) .padding(24) .center_x(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into(); } if state.current_screen == Screen::Home { // --- HOME SCREEN --- let logo = text("PEERSPEAK") .size(36) .color(color_blue); let subtitle = text("NAT-traversing full-mesh voice chat") .size(16) .color(color_subtext); let nickname_input = column![ text("Nickname").size(14).color(color_subtext), vertical_space(4.0), text_input("Enter nickname...", &state.name) .on_input(AppMessage::NicknameChanged) .style(t_style) .padding(10) ]; let create_btn = button( text("Create New Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::CreatePressed) .style(b_style(color_blue, color_lavender, color_crust, 8.0)) .padding(12) .width(iced::Length::Fill); let join_group = column![ text("Join Existing Room").size(14).color(color_subtext), vertical_space(4.0), text_input("Paste room ticket here...", &state.ticket_input) .on_input(AppMessage::TicketInputChanged) .style(t_style) .padding(10), vertical_space(8.0), button( text("Join Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::JoinPressed) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(12) .width(iced::Length::Fill) ]; let status = text(&state.status_message) .size(14) .color(color_subtext); let content = container( column![ logo, subtitle, vertical_space(20.0), nickname_input, vertical_space(16.0), create_btn, vertical_space(16.0), text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, vertical_space(10.0), status ] .spacing(10) .align_x(iced::alignment::Horizontal::Center) ) .style(c_style(color_mantle, color_surface, 12.0)) .padding(30) .width(420); let scroll = scrollable(content); container( column![ top_bar, vertical_space(20.0), scroll ].align_x(iced::alignment::Horizontal::Center) ) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } else { // --- ROOM SCREEN --- let header = row![ text("PEERSPEAK") .size(20) .color(color_blue), horizontal_space(), text(format!("My ID: {}", &state.self_id[..8])) .size(14) .color(color_subtext), horizontal_space(), button(text("Copy Ticket").size(12)) .on_press(AppMessage::CopyToClipboard) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6) ] .align_y(iced::alignment::Vertical::Center); let header_container = container(header) .style(c_style(color_mantle, color_surface, 8.0)) .padding(15) .width(iced::Length::Fill); // Peers Column let mut peers_list = Column::new().spacing(10); // Add ourselves 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) ) .style(c_style(color_base, color_surface, 6.0)) .padding(12); peers_list = peers_list.push(self_card); for (peer_id, peer) in &state.peers { let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0); let is_connecting = state.connecting.contains(peer_id); let is_speaking = !is_connecting && level > 0.01; let indicator = if is_connecting { let label = if state.ever_connected.contains(peer_id) { "[Reconnecting…]" } else { "[Connecting…]" }; text(label).size(14).color(color_yellow) } else if peer.is_muted { text("[Muted]").size(14).color(color_red) } else if is_speaking { text("[Speaking]").size(14).color(color_green) } else { text("[Idle]").size(14).color(color_subtext) }; let mut card_content = column![ row![ column![ text(&peer.name).size(16).color(color_text), text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext) ], horizontal_space(), indicator ] .align_y(iced::alignment::Vertical::Center) ].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), slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v)) ].spacing(8).align_y(iced::alignment::Vertical::Center) ); let card = container(card_content) .style(c_style( if is_speaking { color_base } else { color_mantle }, if is_connecting { color_yellow } else if is_speaking { color_green } else { color_surface }, 6.0 )) .padding(12); peers_list = peers_list.push(card); } let scroll_peers = scrollable(peers_list); let peers_panel = container( column![ text("Room Participants").size(18).color(color_blue), vertical_space(10.0), scroll_peers ] ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .width(iced::Length::FillPortion(2)) .height(iced::Length::Fill); // Control Panel Column let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" }; let mute_bg = if state.is_muted { color_red } else { color_surface }; let mute_hover = if state.is_muted { color_maroon } else { color_blue }; let mute_fg = if state.is_muted { color_crust } else { color_text }; let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" }; let deafen_bg = if state.is_deafened { color_red } else { color_surface }; let deafen_hover = if state.is_deafened { color_maroon } else { color_blue }; let deafen_fg = if state.is_deafened { color_crust } else { color_text }; let ctrl_buttons = column![ button( text(mute_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleMutePressed) .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(10.0), button( text(deafen_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleDeafenPressed) .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(20.0), checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), vertical_space(10.0), if state.ptt_enabled { column![ text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext), button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center)) .on_press(AppMessage::StartSettingHotkey) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) .width(iced::Length::Fill) ].spacing(8) } else { column![] }, vertical_space(30.0), button( text("Leave Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::LeavePressed) .style(b_style(color_red, color_maroon, color_crust, 8.0)) .padding(14) .width(iced::Length::Fill) ]; let control_panel = container( column![ text("Controls").size(18).color(color_blue), vertical_space(15.0), ctrl_buttons, vertical_space(20.0), text(&state.status_message).size(12).color(color_subtext) ] ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .width(iced::Length::FillPortion(1)) .height(iced::Length::Fill); let main_layout = row![peers_panel, control_panel].spacing(15); container( column![ top_bar, header_container, vertical_space(15.0), main_layout ] ) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } } /// Full-scale of the meter's RMS axis. Speech RMS runs to ~0.3 normalized, so /// this keeps a normal voice off the ceiling while leaving the gate threshold /// (usually a few percent) draggable across the lower part of the bar. const METER_MAX: f32 = 0.3; /// A unified mic-level meter with a draggable noise-gate handle (Discord/OBS /// style). The bar fills to the live mic level; the yellow handle marks the gate /// threshold on the same axis and can be dragged to set it. The fill turns green /// when the level is above the gate (transmitting), dim when below it (muted). struct GateMeter { level: f32, threshold: f32, track: Color, border: Color, fill_on: Color, fill_off: Color, /// Bright core of the gate handle. handle: Color, /// Dark outline behind the handle, so it stays visible over the green fill. handle_edge: Color, } impl GateMeter { /// Maps a cursor x (relative to the bar) to a gate threshold on the meter axis. fn x_to_threshold(x: f32, width: f32) -> f32 { (x / width.max(1.0)).clamp(0.0, 1.0) * METER_MAX } } #[derive(Default)] struct GateMeterState { dragging: bool, } impl Program for GateMeter { type State = GateMeterState; fn update( &self, state: &mut Self::State, event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if let Some(p) = cursor.position_in(bounds) { state.dragging = true; let t = Self::x_to_threshold(p.x, bounds.width); return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture()); } } // Track moves anywhere on screen so the drag survives leaving the bar. Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => { if let Some(p) = cursor.position() { let t = Self::x_to_threshold(p.x - bounds.x, bounds.width); return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture()); } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => { state.dragging = false; let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0); let t = Self::x_to_threshold(x, bounds.width); // Persist the final value on release. return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture()); } _ => {} } None } fn draw( &self, _state: &Self::State, renderer: &Renderer, _theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { let mut frame = Frame::new(renderer, bounds.size()); let w = bounds.width; let h = bounds.height; // Track. frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track); // Level fill, coloured by whether we're above the gate. let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0); let fill = if self.level >= self.threshold { self.fill_on } else { self.fill_off }; if level_frac > 0.0 { frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill); } // Gate handle: a bright vertical line + grip caps, each backed by a dark // edge so the handle stays legible even when the green level sweeps past it. let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0); let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0)); // Dark edge (slightly larger), then bright core. frame.fill(&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)), self.handle_edge); frame.fill(&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)), self.handle); // Grip caps top and bottom. frame.fill(&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)), self.handle_edge); frame.fill(&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)), self.handle); frame.fill(&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)), self.handle_edge); frame.fill(&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)), self.handle); // Border. frame.stroke( &Path::rectangle(Point::ORIGIN, Size::new(w, h)), canvas::Stroke::default().with_color(self.border).with_width(1.0), ); vec![frame.into_geometry()] } fn mouse_interaction( &self, state: &Self::State, bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { if state.dragging || cursor.is_over(bounds) { mouse::Interaction::ResizingHorizontally } else { mouse::Interaction::default() } } } #[cfg(test)] mod tests { use super::{reconnect_attempt_chime, reconnected_chime}; use crate::notify::Sound; use iroh::EndpointId; use std::collections::HashSet; /// A distinct, real `EndpointId` (via the same path the network tests use). fn id() -> EndpointId { iroh::EndpointAddr::from(iroh::SecretKey::generate().public()).id } #[test] fn first_dial_does_not_chime() { let mut connecting = HashSet::new(); let ever = HashSet::new(); // never connected let peer = id(); // A peer we've never linked with is just an initial connect, not a reconnect. assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None); assert!(connecting.contains(&peer)); // but it is now marked connecting } #[test] fn first_connected_does_not_chime() { let mut connecting = HashSet::from([id()]); let mut ever = HashSet::new(); let peer = id(); connecting.insert(peer); // First successful link: record it, but no "reconnected" chime. assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None); assert!(ever.contains(&peer)); assert!(!connecting.contains(&peer)); // connecting state cleared } #[test] fn reconnect_attempt_chimes_once_then_stays_silent_on_redials() { let peer = id(); let mut connecting = HashSet::new(); let ever = HashSet::from([peer]); // previously connected // First drop → one ReconnectAttempt chime. assert_eq!( reconnect_attempt_chime(&mut connecting, &ever, peer), Some(Sound::ReconnectAttempt) ); // The supervisor redials repeatedly while still down — must NOT re-chime. assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None); assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None); } #[test] fn full_outage_cycle_chimes_attempt_then_reconnected_each_time() { let peer = id(); let mut connecting = HashSet::new(); let mut ever = HashSet::new(); // Initial connect: silent, records the peer. assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None); // Outage 1: attempt chimes once, recovery chimes "reconnected". assert_eq!( reconnect_attempt_chime(&mut connecting, &ever, peer), Some(Sound::ReconnectAttempt) ); assert_eq!( reconnected_chime(&mut connecting, &mut ever, peer), Some(Sound::Reconnected) ); // Outage 2: a fresh disconnect chimes again (per-outage, not once-ever). assert_eq!( reconnect_attempt_chime(&mut connecting, &ever, peer), Some(Sound::ReconnectAttempt) ); assert_eq!( reconnected_chime(&mut connecting, &mut ever, peer), Some(Sound::Reconnected) ); } }