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:
+101
-5
@@ -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<bool> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user