diff --git a/assets/sounds/generate_chimes.py b/assets/sounds/generate_chimes.py index ee38c70..e46e500 100644 --- a/assets/sounds/generate_chimes.py +++ b/assets/sounds/generate_chimes.py @@ -66,6 +66,16 @@ CHIMES = { "peer-join.wav": [(G4, 0.13), (C5, 0.28)], # Someone left: two-note fall — the mirror of an arrival. "peer-leave.wav": [(C5, 0.13), (G4, 0.28)], + # Reconnecting to peer: soft single Bb4 note. + "reconnect-attempt.wav": [(466.16, 0.20)], + # Reconnected to peer: bright two-note rise (C5 -> G5). + "reconnected.wav": [(C5, 0.10), (G5, 0.25)], + # Left a room: descending triad. + "self-leave.wav": [(G5, 0.10), (E5, 0.10), (C5, 0.25)], + # Mute/unmute toggle: tiny clean blip. + "mic-toggle.wav": [(E5, 0.08)], + # Reconnect gave up: disappointing low two-note fall. + "reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)], } diff --git a/assets/sounds/mic-toggle.wav b/assets/sounds/mic-toggle.wav new file mode 100644 index 0000000..57fbd23 Binary files /dev/null and b/assets/sounds/mic-toggle.wav differ diff --git a/assets/sounds/reconnect-attempt.wav b/assets/sounds/reconnect-attempt.wav new file mode 100644 index 0000000..310e59b Binary files /dev/null and b/assets/sounds/reconnect-attempt.wav differ diff --git a/assets/sounds/reconnect-failed.wav b/assets/sounds/reconnect-failed.wav new file mode 100644 index 0000000..43c364c Binary files /dev/null and b/assets/sounds/reconnect-failed.wav differ diff --git a/assets/sounds/reconnected.wav b/assets/sounds/reconnected.wav new file mode 100644 index 0000000..7c5fb43 Binary files /dev/null and b/assets/sounds/reconnected.wav differ diff --git a/assets/sounds/self-leave.wav b/assets/sounds/self-leave.wav new file mode 100644 index 0000000..fa00529 Binary files /dev/null and b/assets/sounds/self-leave.wav differ diff --git a/src/app/mod.rs b/src/app/mod.rs index 33bb4fb..3603096 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -45,6 +45,8 @@ pub enum AppMessage { EventOccurred(Event), NavigateToSettings, NavigateBack, + ToggleNotifications(bool), + CustomSoundPathChanged(Sound, String), } fn core_subscription() -> impl iced::futures::Stream { @@ -90,6 +92,22 @@ pub struct AppState { 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); @@ -97,6 +115,7 @@ impl Default for AppState { 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(); @@ -197,10 +216,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { 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 { @@ -209,7 +230,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.self_id = self_id; state.status_message = "Connected".to_string(); state.current_screen = Screen::Room; - notify::play(Sound::SelfJoin); + notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref()); } UiEvent::RoomLeft => { state.ticket = "".to_string(); @@ -219,27 +240,44 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.ever_connected.clear(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; + 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); + 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); + 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 } => { + let is_reconnect_attempt = state.ever_connected.contains(&id); + let was_already_connecting = state.connecting.contains(&id); state.connecting.insert(id); + if is_reconnect_attempt && !was_already_connecting { + notify::play(Sound::ReconnectAttempt, state.config.custom_sound_reconnect_attempt.as_deref()); + } } UiEvent::PeerConnected { id } => { + let was_reconnect = state.ever_connected.contains(&id); state.connecting.remove(&id); state.ever_connected.insert(id); + if was_reconnect { + notify::play(Sound::Reconnected, state.config.custom_sound_reconnected.as_deref()); + } } UiEvent::AudioLevels(levels) => { for (id, val) in levels { @@ -288,6 +326,24 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // 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::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::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => { if state.is_setting_hotkey { state.ptt_hotkey = key.clone(); @@ -308,6 +364,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.current_screen = Screen::Settings; } AppMessage::NavigateBack => { + state.config.save(); if state.ticket.is_empty() { state.current_screen = Screen::Home; } else { @@ -407,30 +464,50 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].width(iced::Length::Fill).padding(10); if state.current_screen == Screen::Settings { - let settings_box = container( + 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::Fixed(320.0)) + }; + + let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), vertical_space(20.0), text("Device Settings").size(14).color(color_subtext), - row![ - column![ - text("Input Target").size(12).color(color_subtext), - pick_list( - &state.input_devices[..], - state.selected_input.clone(), - AppMessage::InputDeviceSelected, - ).width(iced::Length::Fixed(160.0)) - ].spacing(4), - horizontal_space(), - column![ - text("Output Target").size(12).color(color_subtext), - pick_list( - &state.output_devices[..], - state.selected_output.clone(), - AppMessage::OutputDeviceSelected, - ).width(iced::Length::Fixed(160.0)) - ].spacing(4), - ].spacing(10), + vertical_space(10.0), + 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::Fixed(320.0)), + ].spacing(4).width(iced::Length::Fixed(320.0)), + vertical_space(10.0), + 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::Fixed(320.0)), + ].spacing(4).width(iced::Length::Fixed(320.0)), vertical_space(20.0), column![ text(format!("Mic Sensitivity (Noise Gate): {:.1}%", state.config.noise_gate_threshold * 100.0)).size(14).color(color_subtext), @@ -447,6 +524,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { 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(6).width(iced::Length::Fixed(320.0)), + vertical_space(20.0), + column![ + text("Notification Chimes").size(14).color(color_subtext), + checkbox(state.config.notifications_enabled) + .label("Enable sound notifications") + .on_toggle(AppMessage::ToggleNotifications), + ].spacing(10).width(iced::Length::Fixed(320.0)), + vertical_space(20.0), + text("Custom Chime Files (WAV Paths)").size(14).color(color_subtext), + + path_field("Self Join", Sound::SelfJoin), + path_field("Self Leave", Sound::SelfLeave), + path_field("Peer Join", Sound::PeerJoin), + path_field("Peer Leave", Sound::PeerLeave), + path_field("Reconnect Attempt", Sound::ReconnectAttempt), + path_field("Reconnected", Sound::Reconnected), + path_field("Mic Toggle", Sound::MicToggle), + path_field("Reconnect Failed", Sound::ReconnectFailed), + vertical_space(30.0), button( text("Back") @@ -460,10 +556,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .align_x(iced::alignment::Horizontal::Center) .spacing(10) - ) - .style(c_style(color_mantle, color_surface, 12.0)) - .padding(30) - .width(420); + ); + + let settings_box = container(settings_content) + .style(c_style(color_mantle, color_surface, 12.0)) + .padding(30) + .width(460) + .height(500); let content = column![settings_box].align_x(iced::alignment::Horizontal::Center); diff --git a/src/config.rs b/src/config.rs index 3468231..e1bca9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,10 @@ impl std::fmt::Display for NetworkMode { } } +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub input_device: String, @@ -43,6 +47,24 @@ pub struct AppConfig { pub noise_gate_threshold: f32, #[serde(default)] pub network_mode: NetworkMode, + #[serde(default = "default_true")] + pub notifications_enabled: bool, + #[serde(default)] + pub custom_sound_self_join: Option, + #[serde(default)] + pub custom_sound_peer_join: Option, + #[serde(default)] + pub custom_sound_peer_leave: Option, + #[serde(default)] + pub custom_sound_reconnect_attempt: Option, + #[serde(default)] + pub custom_sound_reconnected: Option, + #[serde(default)] + pub custom_sound_self_leave: Option, + #[serde(default)] + pub custom_sound_mic_toggle: Option, + #[serde(default)] + pub custom_sound_reconnect_failed: Option, } impl Default for AppConfig { @@ -52,6 +74,15 @@ impl Default for AppConfig { output_device: "".to_string(), noise_gate_threshold: 0.01, network_mode: NetworkMode::default(), + notifications_enabled: true, + custom_sound_self_join: None, + custom_sound_peer_join: None, + custom_sound_peer_leave: None, + custom_sound_reconnect_attempt: None, + custom_sound_reconnected: None, + custom_sound_self_leave: None, + custom_sound_mic_toggle: None, + custom_sound_reconnect_failed: None, } } } diff --git a/src/core/messages.rs b/src/core/messages.rs index 37933a8..2eee7af 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -23,6 +23,7 @@ pub enum UiEvent { RoomLeft, PeerJoined { id: EndpointId, state: PeerState }, PeerLeft { id: EndpointId }, + PeerConnectionFailed { id: EndpointId }, PeerUpdated { id: EndpointId, state: PeerState }, /// Audio link to a peer is being (re)established — show a connecting state. PeerConnecting { id: EndpointId }, diff --git a/src/core/mod.rs b/src/core/mod.rs index b4a68c2..7b8feac 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -510,7 +510,7 @@ async fn run_core_loop( )); transport_evict.disconnect_peer(peer_id).await; jitter_evict.lock().await.remove(&peer_id); - let _ = ui_evict.send(UiEvent::PeerLeft { id: peer_id }).await; + let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; timers_evict.lock().unwrap().remove(&peer_id); }); // Replace (and abort) any timer already pending for diff --git a/src/notify.rs b/src/notify.rs index 644ed2b..b03a3ff 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -12,10 +12,24 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; +static ENABLED: AtomicBool = AtomicBool::new(true); + +/// Enable or disable all notification chimes globally. +pub fn set_enabled(enabled: bool) { + ENABLED.store(enabled, Ordering::Relaxed); +} + +/// Check if notifications are enabled. +pub fn is_enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + + /// A notification event with a distinct chime. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Sound { /// You joined a room. SelfJoin, @@ -23,6 +37,16 @@ pub enum Sound { PeerJoin, /// A participant left. PeerLeave, + /// Reconnecting to a peer. + ReconnectAttempt, + /// Reconnected to a peer. + Reconnected, + /// You left a room. + SelfLeave, + /// Mic mute/unmute toggle. + MicToggle, + /// Reconnect failed / peer evicted. + ReconnectFailed, } impl Sound { @@ -32,6 +56,11 @@ impl Sound { Sound::SelfJoin => include_bytes!("../assets/sounds/self-join.wav"), Sound::PeerJoin => include_bytes!("../assets/sounds/peer-join.wav"), Sound::PeerLeave => include_bytes!("../assets/sounds/peer-leave.wav"), + Sound::ReconnectAttempt => include_bytes!("../assets/sounds/reconnect-attempt.wav"), + Sound::Reconnected => include_bytes!("../assets/sounds/reconnected.wav"), + Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"), + Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"), + Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"), } } @@ -41,16 +70,60 @@ impl Sound { Sound::SelfJoin => "self-join", Sound::PeerJoin => "peer-join", Sound::PeerLeave => "peer-leave", + Sound::ReconnectAttempt => "reconnect-attempt", + Sound::Reconnected => "reconnected", + Sound::SelfLeave => "self-leave", + Sound::MicToggle => "mic-toggle", + Sound::ReconnectFailed => "reconnect-failed", } } } +/// Helper to expand `~` at the start of a path to the user's home directory. +pub fn expand_tilde(path_str: &str) -> PathBuf { + let trimmed = path_str.trim(); + if trimmed == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(trimmed)) + } else if let Some(stripped) = trimmed.strip_prefix("~/") { + dirs::home_dir() + .map(|home| home.join(stripped)) + .unwrap_or_else(|| PathBuf::from(trimmed)) + } else { + PathBuf::from(trimmed) + } +} + +/// Check if a custom path is valid (i.e. if it exists and is a file). +/// Returns `None` if empty/whitespace, `Some(true)` if valid, `Some(false)` if invalid. +pub fn validate_custom_path(path_str: &str) -> Option { + if path_str.trim().is_empty() { + None + } else { + let path = expand_tilde(path_str); + Some(path.exists() && path.is_file()) + } +} + /// Play a notification chime, fire-and-forget. Never blocks; never errors out. -pub fn play(sound: Sound) { - let Some(path) = cached_path(sound) else { +pub fn play(sound: Sound, custom_path: Option<&str>) { + if !is_enabled() { return; - }; - std::thread::spawn(move || spawn_player(&path)); + } + let custom_path = custom_path.map(String::from); + std::thread::spawn(move || { + if let Some(ref path_str) = custom_path + && !path_str.trim().is_empty() + { + let path = expand_tilde(path_str); + if path.exists() && path.is_file() { + spawn_player(&path); + return; + } + } + if let Some(path) = cached_path(sound) { + spawn_player(&path); + } + }); } /// Materialize the embedded WAV to a temp file the first time it's needed and @@ -89,3 +162,26 @@ fn spawn_player(path: &Path) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_tilde() { + if let Some(home) = dirs::home_dir() { + assert_eq!(expand_tilde("~"), home); + assert_eq!(expand_tilde("~/foo/bar.wav"), home.join("foo/bar.wav")); + } + assert_eq!(expand_tilde("/absolute/path.wav"), PathBuf::from("/absolute/path.wav")); + assert_eq!(expand_tilde("relative/path.wav"), PathBuf::from("relative/path.wav")); + assert_eq!(expand_tilde(" "), PathBuf::from("")); + } + + #[test] + fn test_validate_custom_path() { + assert_eq!(validate_custom_path(""), None); + assert_eq!(validate_custom_path(" "), None); + assert_eq!(validate_custom_path("/non/existent/file.wav"), Some(false)); + } +}