feat(notify): per-sound notification toggles (W6)
Add a per-event enable checkbox next to each chime in Settings so a user can silence individual sounds (e.g. keep 'message'/peer-join but drop reconnect chimes) while the master 'Enable sound notifications' toggle stays as the global kill-switch. The gate lives in one place at the play() seam: a pure should_play(master, sound) AND that's unit-tested, fed by a per-sound AtomicBool array in notify keyed by a stable Sound::index/ALL. Flags persist as 8 sound_*_enabled bools in AppConfig (default true, so upgrades are silent-change-free) with sound_enabled/set_sound_enabled accessors centralizing the field mapping. The per-sound checkbox greys out (drops on_toggle) while the master is off. +3 notify unit tests (should_play truth table, index bijection, flag set/query independence) + extended config backward-compat test. 230 lib tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+21
-2
@@ -141,6 +141,8 @@ pub enum AppMessage {
|
||||
ToggleNotifications(bool),
|
||||
ToggleEchoCancellation(bool),
|
||||
CustomSoundPathChanged(Sound, String),
|
||||
/// Toggle the per-sound enable flag for a single chime (W6).
|
||||
ToggleSoundEnabled(Sound, bool),
|
||||
ToggleMicTest(bool),
|
||||
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
||||
ToggleRecording,
|
||||
@@ -282,6 +284,9 @@ impl Default for AppState {
|
||||
config.controls_width = clamp_controls_width(config.controls_width, ww);
|
||||
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
|
||||
notify::set_enabled(config.notifications_enabled);
|
||||
for sound in Sound::ALL {
|
||||
notify::set_sound_enabled(sound, config.sound_enabled(sound));
|
||||
}
|
||||
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));
|
||||
@@ -724,6 +729,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
||||
}
|
||||
}
|
||||
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||||
state.config.set_sound_enabled(sound, enabled);
|
||||
state.config.save();
|
||||
notify::set_sound_enabled(sound, enabled);
|
||||
}
|
||||
AppMessage::ToggleRecording => {
|
||||
// Optimistic intent; the core flips `recording` for real via the
|
||||
// Recording{Started,Stopped} events (so a failed start won't lie).
|
||||
@@ -1121,12 +1131,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
Some(false) => text("✗ File not found").size(10).color(color_red),
|
||||
};
|
||||
|
||||
// Per-sound enable checkbox (W6). Interactive only while the master
|
||||
// toggle is on — without it every chime is muted anyway, so we drop
|
||||
// `on_toggle` to render it greyed out and inert.
|
||||
let mut enable = checkbox(state.config.sound_enabled(sound)).size(16);
|
||||
if state.config.notifications_enabled {
|
||||
enable = enable.on_toggle(move |on| AppMessage::ToggleSoundEnabled(sound, on));
|
||||
}
|
||||
|
||||
column![
|
||||
row![
|
||||
enable,
|
||||
text(label).size(12).color(color_subtext),
|
||||
horizontal_space(),
|
||||
validation_widget,
|
||||
].align_y(iced::alignment::Vertical::Center),
|
||||
].spacing(6).align_y(iced::alignment::Vertical::Center),
|
||||
text_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
@@ -1460,7 +1479,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.label("Enable sound notifications")
|
||||
.on_toggle(AppMessage::ToggleNotifications),
|
||||
vertical_space(6.0),
|
||||
text("Custom chime files (WAV paths) — leave blank for the built-in sounds.").size(12).color(color_subtext),
|
||||
text("Tick a sound to enable its chime; untick to silence just that one. Optional WAV path overrides the built-in sound (blank = built-in).").size(12).color(color_subtext),
|
||||
row![
|
||||
path_field("Self Join", Sound::SelfJoin),
|
||||
path_field("Peer Join", Sound::PeerJoin),
|
||||
|
||||
Reference in New Issue
Block a user