//! Notification chimes for room events (join / peer-join / peer-leave, with more //! events in later phases). //! //! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single //! binary is self-contained — no asset directory to ship alongside it. On first //! use each sound is written once to a temp file, then played fire-and-forget //! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs //! on a detached thread that waits on the child, so it never blocks the UI and //! never leaves a zombie. Any failure (no player, no audio) is silent by design — //! a missing chime should never disrupt a call. 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(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Sound { /// You joined a room. SelfJoin, /// Another participant joined. 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 { /// Embedded WAV bytes for this sound. fn bytes(self) -> &'static [u8] { match self { 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"), } } /// Stable file stem for the cached temp file. fn name(self) -> &'static str { match self { 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, custom_path: Option<&str>) { if !is_enabled() { return; } 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 /// cache the path. Returns `None` only if the file can't be written. fn cached_path(sound: Sound) -> Option { static CACHE: OnceLock>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = cache.lock().unwrap(); if let Some(path) = guard.get(sound.name()) { return Some(path.clone()); } let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name())); if std::fs::write(&path, sound.bytes()).is_err() { return None; } guard.insert(sound.name(), path.clone()); Some(path) } /// Try each available player in turn, waiting on the first that starts (which /// reaps the child). Runs on a detached thread, so the wait is harmless. fn spawn_player(path: &Path) { for player in ["pw-play", "paplay", "aplay"] { let started = Command::new(player) .arg(path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); // `status()` errors only if the player binary isn't present; on a real // playback error it still returns (non-zero), so a started player ends // the loop either way — we don't want to double-play through fallbacks. if started.is_ok() { return; } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_expand_tilde() { if let Some(home) = dirs::home_dir() { // bare `~` -> home dir assert_eq!(expand_tilde("~"), home); // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav` assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav")); } // absolute path (`/etc/foo.wav`) -> unchanged assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav")); // relative path (`foo/bar.wav`) -> unchanged assert_eq!(expand_tilde("foo/bar.wav"), PathBuf::from("foo/bar.wav")); // a tilde not at the start (`/opt/~/x.wav`) -> unchanged assert_eq!(expand_tilde("/opt/~/x.wav"), PathBuf::from("/opt/~/x.wav")); // `~username` style (`~bob/x.wav`) -> unchanged assert_eq!(expand_tilde("~bob/x.wav"), PathBuf::from("~bob/x.wav")); // leading/trailing whitespace is trimmed if let Some(home) = dirs::home_dir() { assert_eq!(expand_tilde(" ~ "), home); assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav")); } assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav")); assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav")); } #[test] fn test_validate_custom_path() { // empty and whitespace-only -> None assert_eq!(validate_custom_path(""), None); assert_eq!(validate_custom_path(" "), None); // a path that does not exist -> Some(false) assert_eq!(validate_custom_path("/non/existent/file.wav"), Some(false)); // a real existing file -> Some(true) let existing_file = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); assert_eq!(validate_custom_path(existing_file), Some(true)); // an existing directory -> Some(false) let existing_dir = env!("CARGO_MANIFEST_DIR"); assert_eq!(validate_custom_path(existing_dir), Some(false)); // a `~`-prefixed path that resolves to a non-existent file -> Some(false) assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false)); } }