GossipMessage::Chat serde round-trips (normal, empty strings, u64::MAX ts,
unicode/emoji), GossipPayload{Chat} round-trip, and push_chat history-cap
behaviour (single, below cap order-preserved, above cap drops oldest keeping
the newest CHAT_HISTORY_MAX in order). Gemini, senior-audited.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1618 lines
65 KiB
Rust
1618 lines
65 KiB
Rust
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,
|
|
progress_bar, 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<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Screen {
|
|
Home,
|
|
Room,
|
|
Settings,
|
|
}
|
|
|
|
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
|
/// messages from peers' for colouring.
|
|
#[derive(Debug, Clone)]
|
|
struct ChatEntry {
|
|
name: String,
|
|
text: String,
|
|
mine: bool,
|
|
}
|
|
|
|
/// Cap on retained chat history so a long call can't grow it without bound.
|
|
const CHAT_HISTORY_MAX: usize = 300;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum AppMessage {
|
|
NicknameChanged(String),
|
|
TicketInputChanged(String),
|
|
JoinPressed,
|
|
CreatePressed,
|
|
LeavePressed,
|
|
ToggleMutePressed,
|
|
ToggleDeafenPressed,
|
|
UiEventReceived(UiEvent),
|
|
CopyToClipboard,
|
|
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).
|
|
InputVolumeChanged(f32),
|
|
/// Live output-gain drag (applies immediately, persisted on release).
|
|
OutputVolumeChanged(f32),
|
|
/// Persist the current config to disk (slider release).
|
|
PersistConfig,
|
|
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),
|
|
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
|
ToggleRecording,
|
|
/// Live edits to the chat input line.
|
|
ChatInputChanged(String),
|
|
/// Send the current chat input line (Enter or the Send button).
|
|
ChatSubmit,
|
|
}
|
|
|
|
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
|
iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender<UiEvent>| 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<AudioDevice>,
|
|
output_devices: Vec<AudioDevice>,
|
|
selected_input: Option<AudioDevice>,
|
|
selected_output: Option<AudioDevice>,
|
|
config: AppConfig,
|
|
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>,
|
|
/// Whether a local call recording is in progress (confirmed by the core).
|
|
recording: bool,
|
|
/// When the current recording started, for the header REC timer.
|
|
recording_started: Option<std::time::Instant>,
|
|
/// Room text-chat history (newest last) and the pending input line.
|
|
chat_messages: Vec<ChatEntry>,
|
|
chat_input: String,
|
|
/// 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<EndpointId>,
|
|
/// 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<EndpointId>,
|
|
controller: Arc<CoreController>,
|
|
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::SetInputVolume(config.input_volume));
|
|
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
|
|
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(),
|
|
locally_muted: HashSet::new(),
|
|
call_started: None,
|
|
recording: false,
|
|
recording_started: None,
|
|
chat_messages: Vec::new(),
|
|
chat_input: String::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 {
|
|
// Taller default so the bottom chat dock doesn't squeeze the controls
|
|
// column. Layout is responsive (Fill), so resizing still works.
|
|
size: iced::Size::new(900.0, 760.0),
|
|
position: iced::window::Position::Centered,
|
|
exit_on_close_request: true,
|
|
..Default::default()
|
|
})
|
|
.run()
|
|
}
|
|
|
|
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
|
|
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<EndpointId>,
|
|
ever_connected: &HashSet<EndpointId>,
|
|
id: EndpointId,
|
|
) -> Option<Sound> {
|
|
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<EndpointId>,
|
|
ever_connected: &mut HashSet<EndpointId>,
|
|
id: EndpointId,
|
|
) -> Option<Sound> {
|
|
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<AppMessage> {
|
|
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;
|
|
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;
|
|
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.locally_muted.clear();
|
|
state.call_started = None;
|
|
state.recording = false;
|
|
state.recording_started = None;
|
|
state.chat_messages.clear();
|
|
state.chat_input.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.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());
|
|
}
|
|
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());
|
|
}
|
|
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::RecordingStarted { path } => {
|
|
state.recording = true;
|
|
state.recording_started = Some(std::time::Instant::now());
|
|
state.status_message = format!("Recording → {path}");
|
|
}
|
|
UiEvent::RecordingStopped { path } => {
|
|
state.recording = false;
|
|
state.recording_started = None;
|
|
state.status_message = format!("Saved recording → {path}");
|
|
}
|
|
UiEvent::ChatMessage { name, text } => {
|
|
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
|
|
}
|
|
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::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();
|
|
state.selected_input = Some(dev);
|
|
}
|
|
AppMessage::OutputDeviceSelected(dev) => {
|
|
state.config.output_device = dev.name.clone();
|
|
state.config.save();
|
|
state.selected_output = Some(dev);
|
|
}
|
|
AppMessage::InputVolumeChanged(vol) => {
|
|
// Live apply; disk write deferred to release (PersistConfig).
|
|
state.config.input_volume = vol;
|
|
let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
|
|
}
|
|
AppMessage::OutputVolumeChanged(vol) => {
|
|
state.config.output_volume = vol;
|
|
let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
|
|
}
|
|
AppMessage::PersistConfig => {
|
|
state.config.save();
|
|
}
|
|
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::ToggleRecording => {
|
|
// Optimistic intent; the core flips `recording` for real via the
|
|
// Recording{Started,Stopped} events (so a failed start won't lie).
|
|
let _ = state.controller.send(CoreCommand::SetRecording(!state.recording));
|
|
}
|
|
AppMessage::ChatInputChanged(val) => {
|
|
state.chat_input = val;
|
|
}
|
|
AppMessage::ChatSubmit => {
|
|
let text = state.chat_input.trim().to_string();
|
|
if !text.is_empty() {
|
|
// Local echo (gossip suppresses our own author, so it won't come back).
|
|
push_chat(&mut state.chat_messages, ChatEntry {
|
|
name: format!("{} (You)", state.name),
|
|
text: text.clone(),
|
|
mine: true,
|
|
});
|
|
let _ = state.controller.send(CoreCommand::SendChat(text));
|
|
state.chat_input.clear();
|
|
}
|
|
}
|
|
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.",
|
|
}
|
|
}
|
|
|
|
/// 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}")
|
|
}
|
|
}
|
|
|
|
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
|
|
/// call can't grow the buffer without bound.
|
|
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
|
messages.push(entry);
|
|
if messages.len() > CHAT_HISTORY_MAX {
|
|
let overflow = messages.len() - CHAT_HISTORY_MAX;
|
|
messages.drain(..overflow);
|
|
}
|
|
}
|
|
|
|
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),
|
|
text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
|
|
slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
|
|
.step(0.05)
|
|
.on_release(AppMessage::PersistConfig),
|
|
].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),
|
|
text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
|
|
slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
|
|
.step(0.05)
|
|
.on_release(AppMessage::PersistConfig),
|
|
].spacing(4).width(iced::Length::Fill),
|
|
].spacing(20).align_y(iced::alignment::Vertical::Top).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 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!("👥 {participant_count} in room"))
|
|
.size(14)
|
|
.color(color_subtext),
|
|
text(format!("⏱ {}", format_duration(call_secs)))
|
|
.size(14)
|
|
.color(color_subtext),
|
|
if state.recording {
|
|
let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0);
|
|
container(
|
|
text(format!("● REC {}", format_duration(rec_secs)))
|
|
.size(13)
|
|
.color(color_red)
|
|
)
|
|
.style(c_style(color_crust, color_red, 6.0))
|
|
.padding(6)
|
|
} else {
|
|
container(text("")).padding(0)
|
|
},
|
|
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)
|
|
.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 — 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(
|
|
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);
|
|
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 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![
|
|
text(&peer.name).size(16).color(color_text),
|
|
text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext)
|
|
],
|
|
horizontal_space(),
|
|
mute_btn,
|
|
indicator
|
|
]
|
|
.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);
|
|
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(20.0),
|
|
{
|
|
let (rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
|
|
("⏹ Stop Recording", color_red, color_maroon, color_crust)
|
|
} else {
|
|
("⏺ Record Call", color_surface, color_blue, color_text)
|
|
};
|
|
button(
|
|
text(rec_label)
|
|
.size(16)
|
|
.align_x(iced::alignment::Horizontal::Center)
|
|
)
|
|
.on_press(AppMessage::ToggleRecording)
|
|
.style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
|
|
.padding(14)
|
|
.width(iced::Length::Fill)
|
|
},
|
|
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)
|
|
.height(iced::Length::Fill);
|
|
|
|
// --- CHAT DOCK (full-width strip along the bottom) ---
|
|
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
|
if state.chat_messages.is_empty() {
|
|
chat_col = chat_col.push(
|
|
text("No messages yet — say hi to the room.")
|
|
.size(12)
|
|
.color(color_subtext),
|
|
);
|
|
} else {
|
|
for m in &state.chat_messages {
|
|
let name_color = if m.mine { color_green } else { color_lavender };
|
|
chat_col = chat_col.push(
|
|
row![
|
|
text(format!("{}:", m.name)).size(12).color(name_color),
|
|
text(&m.text).size(13).color(color_text).width(iced::Length::Fill),
|
|
]
|
|
.spacing(8),
|
|
);
|
|
}
|
|
}
|
|
let chat_scroll = scrollable(chat_col)
|
|
.width(iced::Length::Fill)
|
|
.height(iced::Length::Fill)
|
|
.anchor_bottom();
|
|
|
|
let chat_input_row = row![
|
|
text_input("Message the room…", &state.chat_input)
|
|
.on_input(AppMessage::ChatInputChanged)
|
|
.on_submit(AppMessage::ChatSubmit)
|
|
.style(t_style)
|
|
.padding(8),
|
|
button(text("Send").size(13))
|
|
.on_press(AppMessage::ChatSubmit)
|
|
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
|
.padding(8),
|
|
]
|
|
.spacing(8)
|
|
.align_y(iced::alignment::Vertical::Center);
|
|
|
|
let chat_dock = container(
|
|
column![
|
|
text("Chat").size(16).color(color_blue),
|
|
chat_scroll,
|
|
chat_input_row,
|
|
]
|
|
.spacing(8),
|
|
)
|
|
.style(c_style(color_mantle, color_surface, 8.0))
|
|
.padding(12)
|
|
.width(iced::Length::Fill)
|
|
.height(iced::Length::Fixed(180.0));
|
|
|
|
container(
|
|
column![
|
|
top_bar,
|
|
header_container,
|
|
vertical_space(12.0),
|
|
main_layout,
|
|
vertical_space(12.0),
|
|
chat_dock
|
|
]
|
|
)
|
|
.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<AppMessage> for GateMeter {
|
|
type State = GateMeterState;
|
|
|
|
fn update(
|
|
&self,
|
|
state: &mut Self::State,
|
|
event: &Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<AppMessage>> {
|
|
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<Geometry> {
|
|
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::{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(59), "0:59");
|
|
assert_eq!(format_duration(65), "1:05");
|
|
assert_eq!(format_duration(600), "10:00");
|
|
assert_eq!(format_duration(3599), "59:59");
|
|
// 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;
|
|
|
|
const W: f32 = 200.0;
|
|
|
|
#[test]
|
|
fn gate_drag_maps_left_edge_to_zero() {
|
|
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_drag_maps_right_edge_to_full_scale() {
|
|
assert!((GateMeter::x_to_threshold(W, W) - METER_MAX).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_drag_maps_midpoint_to_half_scale() {
|
|
assert!((GateMeter::x_to_threshold(W / 2.0, W) - METER_MAX / 2.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_drag_clamps_out_of_bounds() {
|
|
// Dragging past either edge clamps to the axis ends (no overshoot).
|
|
assert_eq!(GateMeter::x_to_threshold(-50.0, W), 0.0);
|
|
assert!((GateMeter::x_to_threshold(W + 80.0, W) - METER_MAX).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_drag_zero_width_is_finite() {
|
|
// A degenerate bound (pre-layout) must not divide by zero / produce NaN.
|
|
let t = GateMeter::x_to_threshold(10.0, 0.0);
|
|
assert!(t.is_finite());
|
|
assert!((0.0..=METER_MAX).contains(&t));
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_chat_single() {
|
|
use super::{push_chat, ChatEntry};
|
|
let mut messages = Vec::new();
|
|
let entry = ChatEntry {
|
|
name: "Alice".to_string(),
|
|
text: "Hello".to_string(),
|
|
mine: true,
|
|
};
|
|
push_chat(&mut messages, entry);
|
|
assert_eq!(messages.len(), 1);
|
|
assert_eq!(messages[0].name, "Alice");
|
|
assert_eq!(messages[0].text, "Hello");
|
|
assert!(messages[0].mine);
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_chat_below_cap() {
|
|
use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX};
|
|
let mut messages = Vec::new();
|
|
for i in 0..CHAT_HISTORY_MAX - 10 {
|
|
push_chat(
|
|
&mut messages,
|
|
ChatEntry {
|
|
name: format!("User{}", i),
|
|
text: format!("Msg{}", i),
|
|
mine: i % 2 == 0,
|
|
},
|
|
);
|
|
}
|
|
assert_eq!(messages.len(), CHAT_HISTORY_MAX - 10);
|
|
assert_eq!(messages[0].name, "User0");
|
|
assert_eq!(messages[0].text, "Msg0");
|
|
assert_eq!(messages[messages.len() - 1].name, format!("User{}", CHAT_HISTORY_MAX - 11));
|
|
assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", CHAT_HISTORY_MAX - 11));
|
|
}
|
|
|
|
#[test]
|
|
fn test_push_chat_above_cap() {
|
|
use super::{push_chat, ChatEntry, CHAT_HISTORY_MAX};
|
|
let mut messages = Vec::new();
|
|
let total_pushes = CHAT_HISTORY_MAX + 5;
|
|
for i in 0..total_pushes {
|
|
push_chat(
|
|
&mut messages,
|
|
ChatEntry {
|
|
name: format!("User{}", i),
|
|
text: format!("Msg{}", i),
|
|
mine: i % 2 == 0,
|
|
},
|
|
);
|
|
}
|
|
assert_eq!(messages.len(), CHAT_HISTORY_MAX);
|
|
// The first 5 should be dropped. First remaining should be index 5.
|
|
assert_eq!(messages[0].name, "User5");
|
|
assert_eq!(messages[0].text, "Msg5");
|
|
// The last remaining should be index total_pushes - 1.
|
|
assert_eq!(messages[messages.len() - 1].name, format!("User{}", total_pushes - 1));
|
|
assert_eq!(messages[messages.len() - 1].text, format!("Msg{}", total_pushes - 1));
|
|
}
|
|
}
|