feat: sound notification chimes for connection lifecycle events
Audible chimes for reconnect/reconnected/reconnect-failed, self-leave,
peer join/leave, and mic/deafen toggle, completing the 4-phase
notification plan (phase 1 room/peer join+leave shipped in 673b72d).
- Per-event custom WAV overrides in Settings, with ~ expansion and live
file-found/not-found validation; persisted on settings exit. Each event
falls back to its embedded default chime when no custom path is set.
- Reconnect-attempt chime is edge-triggered: fires once per disconnect,
not once per redial attempt.
- Global "enable sound notifications" toggle.
This commit is contained in:
+126
-27
@@ -45,6 +45,8 @@ pub enum AppMessage {
|
||||
EventOccurred(Event),
|
||||
NavigateToSettings,
|
||||
NavigateBack,
|
||||
ToggleNotifications(bool),
|
||||
CustomSoundPathChanged(Sound, String),
|
||||
}
|
||||
|
||||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||
@@ -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> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
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<AppMessage> {
|
||||
// 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<AppMessage> {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user