diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cf16250..e5acfe6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -32,6 +32,28 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -534,6 +556,50 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni 0.22.4", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -650,6 +716,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "dbus" version = "0.9.12" @@ -889,6 +961,9 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" name = "enclave" version = "0.1.0" dependencies = [ + "cpal", + "resampler", + "ringbuf", "serde", "serde_json", "tauri", @@ -1861,6 +1936,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2018,6 +2123,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2107,6 +2218,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2128,6 +2245,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2182,6 +2310,32 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -2193,6 +2347,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -2210,7 +2387,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2282,6 +2461,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2813,6 +2993,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resampler" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2" + +[[package]] +name = "ringbuf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3134,6 +3331,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3340,7 +3553,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3356,7 +3569,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3396,7 +3609,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3427,7 +3640,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3526,7 +3739,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.20", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -3540,7 +3753,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3552,7 +3765,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -3563,7 +3776,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3577,7 +3790,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4325,7 +4538,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -4349,7 +4562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4405,11 +4618,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -4421,6 +4646,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -4455,7 +4689,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4502,6 +4747,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4605,6 +4860,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -4768,7 +5032,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4788,7 +5052,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9f54383..8b27ac7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,7 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +cpal = "0.18.2" +resampler = "0.5.1" +ringbuf = "0.5.1" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs new file mode 100644 index 0000000..b8a2ac3 --- /dev/null +++ b/src-tauri/src/commands/audio.rs @@ -0,0 +1,550 @@ +use std::{ + collections::BTreeMap, + net::UdpSocket, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use ringbuf::{ + storage::Heap, + traits::{Consumer, Observer, Producer, Split}, + CachingCons, CachingProd, HeapRb, SharedRb, +}; +use tauri::State; + +use crate::commands::config::ConfigState; + +const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz +const HEADER_SIZE: usize = 4; +const MAX_PACKET_SIZE: usize = 4096; + +const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion + +// --- Voice Activity Detection (VAD) Settings --- +const VAD_THRESHOLD: f32 = 0.01; +const VAD_HANGOVER_FRAMES: usize = 10; + +// ============================================================================ +// STATE & TYPES +// ============================================================================ + +type AudioProducer = Arc>>>>>; +type AudioConsumer = Arc>>>>>; + +#[derive(Clone, Default)] +pub struct VoiceState { + pub inner: Arc, +} + +#[derive(Default)] +pub struct VoiceStateInner { + pub session: Mutex>, +} + +pub struct VoiceSession { + pub input_stream: cpal::Stream, + pub output_stream: Arc>, + pub socket: Arc, + pub pin: u64, + pub shutdown: Arc, + + pub current_input_device: Option, + pub current_output_device: Option, + + pub producer_in: AudioProducer, + pub consumer_out: AudioConsumer, +} + +impl VoiceSession { + pub fn update_input_device( + &mut self, + device_name: Option, + state_inner: Arc, + ) -> Result<(), String> { + let host = cpal::default_host(); + let device = match &device_name { + Some(name) => host + .input_devices() + .map_err(|e| e.to_string())? + .find(|d| { + d.description() + .ok() + .map(|x| x.name() == *name) + .unwrap_or(false) + }) + .ok_or_else(|| format!("Input device '{name}' not found"))?, + None => host + .default_input_device() + .ok_or("No default input device")?, + }; + + let input_config = device + .default_input_config() + .map_err(|e| format!("Failed to get default input config: {e}"))? + .config(); + + let producer = Arc::clone(&self.producer_in); + let inner_clone = Arc::clone(&state_inner); + + let new_stream = device + .build_input_stream( + input_config, + move |data: &[f32], _| { + if let Ok(mut prod) = producer.lock() { + let _ = prod.push_slice(data); + } + }, + move |err| { + eprintln!("[vc] Input error: {err}. Attempting input stream recovery..."); + if let Ok(mut lock) = inner_clone.session.lock() { + if let Some(session) = lock.as_mut() { + let target_device = session.current_input_device.clone(); + if let Err(e) = + session.update_input_device(target_device, Arc::clone(&inner_clone)) + { + eprintln!("[vc] Input recovery failed: {e}"); + } + } + } + }, + None, + ) + .map_err(|e| e.to_string())?; + + new_stream + .play() + .map_err(|e| format!("Failed to play input stream: {e}"))?; + + self.input_stream = new_stream; + self.current_input_device = device_name; + Ok(()) + } + + pub fn update_output_device( + &mut self, + device_name: Option, + state_inner: Arc, + ) -> Result<(), String> { + let host = cpal::default_host(); + let device = match &device_name { + Some(name) => host + .output_devices() + .map_err(|e| e.to_string())? + .find(|d| { + d.description() + .ok() + .map(|x| x.name() == *name) + .unwrap_or(false) + }) + .ok_or_else(|| format!("Output device '{name}' not found"))?, + None => host + .default_output_device() + .ok_or("No default output device")?, + }; + + let output_config = device + .default_output_config() + .map_err(|e| format!("Failed to get default output config: {e}"))? + .config(); + + let consumer = Arc::clone(&self.consumer_out); + let new_stream = build_output_stream(&device, output_config, consumer, state_inner)?; + new_stream + .play() + .map_err(|e| format!("Failed to play output stream: {e}"))?; + + if let Ok(mut active_stream) = self.output_stream.lock() { + *active_stream = new_stream; + } + + self.current_output_device = device_name; + Ok(()) + } +} + +// ============================================================================ +// DEVICES +// ============================================================================ + +#[tauri::command] +pub fn list_input_devices() -> Result, String> { + let host = cpal::default_host(); + Ok(host + .input_devices() + .map_err(|e| e.to_string())? + .filter_map(|d| d.description().ok().map(|x| x.name().to_string())) + .collect()) +} + +#[tauri::command] +pub fn list_output_devices() -> Result, String> { + let host = cpal::default_host(); + Ok(host + .output_devices() + .map_err(|e| e.to_string())? + .filter_map(|d| d.description().ok().map(|x| x.name().to_string())) + .collect()) +} + +// ============================================================================ +// DISCONNECT +// ============================================================================ + +#[tauri::command] +pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> { + let mut lock = voice_state + .inner + .session + .lock() + .map_err(|e| e.to_string())?; + + if let Some(session) = lock.take() { + session.shutdown.store(true, Ordering::SeqCst); + + let _ = session.input_stream.pause(); + if let Ok(output) = session.output_stream.lock() { + let _ = output.pause(); + } + } + + Ok(()) +} + +// ============================================================================ +// CONNECT +// ============================================================================ + +#[tauri::command] +pub fn connect_to_vc( + hostname: String, + pin: u64, + config_state: State<'_, ConfigState>, + voice_state: State<'_, VoiceState>, +) -> Result<(), String> { + disconnect_from_vc(voice_state.clone())?; + + let config = config_state.0.lock().unwrap().clone(); + let state_inner = Arc::clone(&voice_state.inner); + + let socket = Arc::new( + UdpSocket::bind("0.0.0.0:0").map_err(|e| format!("Failed to bind UDP socket: {e}"))?, + ); + socket + .connect(&hostname) + .map_err(|e| format!("Failed to connect UDP socket: {e}"))?; + socket + .set_read_timeout(Some(Duration::from_millis(5))) + .map_err(|e| e.to_string())?; + socket + .send(&pin.to_be_bytes()) + .map_err(|e| format!("Failed to send pin: {e}"))?; + + let host = cpal::default_host(); + + let input_device = match &config.input_device_name { + Some(name) => host + .input_devices() + .map_err(|e| e.to_string())? + .find(|d| { + d.description() + .ok() + .map(|x| x.name() == *name) + .unwrap_or(false) + }) + .ok_or("Input device not found")?, + None => host + .default_input_device() + .ok_or("No default input device")?, + }; + + let output_device = match &config.output_device_name { + Some(name) => host + .output_devices() + .map_err(|e| e.to_string())? + .find(|d| { + d.description() + .ok() + .map(|x| x.name() == *name) + .unwrap_or(false) + }) + .ok_or("Output device not found")?, + None => host + .default_output_device() + .ok_or("No default output device")?, + }; + + let shutdown = Arc::new(AtomicBool::new(false)); + + // Output Ring Buffer setup + let rb_out = HeapRb::::new(19200); + let (producer_out, consumer_out) = rb_out.split(); + let mut producer_out = producer_out; + let shared_consumer_out = Arc::new(Mutex::new(consumer_out)); + + let output_config = output_device + .default_output_config() + .map_err(|e| format!("Failed to get default output config: {e}"))? + .config(); + + let output_stream = build_output_stream( + &output_device, + output_config, + Arc::clone(&shared_consumer_out), + Arc::clone(&state_inner), + )?; + output_stream + .play() + .map_err(|e| format!("Failed to start output: {e}"))?; + let output_stream = Arc::new(Mutex::new(output_stream)); + + // Input Ring Buffer setup + let rb_in = HeapRb::::new(19200); + let (producer_in, mut consumer_in) = rb_in.split(); + let shared_producer_in = Arc::new(Mutex::new(producer_in)); + + let input_config = input_device + .default_input_config() + .map_err(|e| format!("Failed to get default input config: {e}"))? + .config(); + + let cb_producer = Arc::clone(&shared_producer_in); + let inner_input_err = Arc::clone(&state_inner); + + let input_stream = input_device + .build_input_stream( + input_config, + move |data: &[f32], _| { + if let Ok(mut prod) = cb_producer.lock() { + let _ = prod.push_slice(data); + } + }, + move |err| { + eprintln!("[vc] Input error: {err}. Attempting input stream recovery..."); + if let Ok(mut lock) = inner_input_err.session.lock() { + if let Some(session) = lock.as_mut() { + let target_device = session.current_input_device.clone(); + if let Err(e) = + session.update_input_device(target_device, Arc::clone(&inner_input_err)) + { + eprintln!("[vc] Input recovery failed: {e}"); + } + } + } + }, + None, + ) + .map_err(|e| e.to_string())?; + + // UDP Sender Thread + { + let input_socket = socket.clone(); + let shutdown = shutdown.clone(); + + thread::spawn(move || { + let mut sequence = 0u32; + let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2]; + let mut frame_buf = vec![0.0f32; PACKET_SAMPLES]; + let mut hangover_counter = 0; + + while !shutdown.load(Ordering::Relaxed) { + if consumer_in.occupied_len() >= PACKET_SAMPLES { + let _ = consumer_in.pop_slice(&mut frame_buf); + + let sum_squares: f32 = frame_buf.iter().map(|&s| s * s).sum(); + let rms = (sum_squares / PACKET_SAMPLES as f32).sqrt(); + + let is_speaking = if rms >= VAD_THRESHOLD { + hangover_counter = VAD_HANGOVER_FRAMES; + true + } else if hangover_counter > 0 { + hangover_counter -= 1; + true + } else { + false + }; + + if is_speaking { + net_packet[0..4].copy_from_slice(&sequence.to_be_bytes()); + for (i, sample) in frame_buf.iter().enumerate() { + let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16; + let offset = HEADER_SIZE + i * 2; + net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes()); + } + + let _ = input_socket.send(&net_packet); + sequence = sequence.wrapping_add(1); + } + } else { + thread::sleep(Duration::from_millis(2)); + } + } + }); + } + + // UDP Receiver Thread + { + let socket = socket.clone(); + let shutdown = shutdown.clone(); + + thread::spawn(move || { + let mut packets: BTreeMap> = BTreeMap::new(); + let mut expected: Option = None; + let mut is_prebuffering = true; + let mut last_good_frame = vec![0.0f32; PACKET_SAMPLES]; + let mut udp_buffer = [0u8; MAX_PACKET_SIZE]; + let mut next_frame_time = Instant::now(); + + while !shutdown.load(Ordering::Relaxed) { + if let Ok(len) = socket.recv(&mut udp_buffer) { + if len > HEADER_SIZE { + let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap()); + let pcm = &udp_buffer[HEADER_SIZE..len]; + let samples: Vec = pcm + .chunks_exact(2) + .map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + + packets.insert(seq, samples); + } + } + + if is_prebuffering { + if packets.len() >= INITIAL_PACKET_CUSHION { + expected = packets.keys().next().copied(); + is_prebuffering = false; + next_frame_time = Instant::now(); + } else { + thread::sleep(Duration::from_millis(1)); + continue; + } + } + + if Instant::now() >= next_frame_time { + if let Some(seq) = expected { + if let Some(samples) = packets.remove(&seq) { + if samples.len() == PACKET_SAMPLES { + last_good_frame.copy_from_slice(&samples); + } else { + last_good_frame.clear(); + last_good_frame + .extend(samples.iter().take(PACKET_SAMPLES).copied()); + if last_good_frame.len() < PACKET_SAMPLES { + last_good_frame.resize(PACKET_SAMPLES, 0.0); + } + } + + let _ = producer_out.push_slice(&last_good_frame); + expected = Some(seq.wrapping_add(1)); + } else if packets.keys().any(|&x| x > seq) { + for sample in last_good_frame.iter_mut() { + *sample *= 0.65; + } + let _ = producer_out.push_slice(&last_good_frame); + expected = Some(seq.wrapping_add(1)); + } else if packets.is_empty() { + is_prebuffering = true; + } + } + next_frame_time += Duration::from_millis(20); + } + + thread::sleep(Duration::from_millis(1)); + } + }); + } + + input_stream + .play() + .map_err(|e| format!("Failed to start input: {e}"))?; + + *state_inner.session.lock().unwrap() = Some(VoiceSession { + input_stream, + output_stream, + socket, + pin, + shutdown, + current_input_device: config.input_device_name, + current_output_device: config.output_device_name, + producer_in: shared_producer_in, + consumer_out: shared_consumer_out, + }); + + Ok(()) +} + +// ============================================================================ +// AUDIO CALLBACK +// ============================================================================ + +fn build_output_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + consumer: AudioConsumer, + state_inner: Arc, +) -> Result { + let channels = config.channels as usize; + let mut last_sample = 0.0f32; + let inner_output_err = Arc::clone(&state_inner); + + device + .build_output_stream( + config, + move |data: &mut [f32], _| { + let mut idx = 0; + let mut cons_guard = consumer.lock().ok(); + + while idx < data.len() { + let sample = cons_guard.as_mut().and_then(|c| c.try_pop()); + if let Some(mono_sample) = sample { + last_sample = mono_sample; + for ch in 0..channels { + data[idx + ch] = mono_sample; + } + idx += channels; + } else { + while idx < data.len() { + last_sample *= 0.92; + for ch in 0..channels { + data[idx + ch] = last_sample; + } + idx += channels; + } + break; + } + } + }, + move |err| { + eprintln!("[vc] Output error: {err}. Attempting output stream recovery..."); + if let Ok(mut lock) = inner_output_err.session.lock() { + if let Some(session) = lock.as_mut() { + let target_device = session.current_output_device.clone(); + if let Err(e) = session + .update_output_device(target_device, Arc::clone(&inner_output_err)) + { + eprintln!("[vc] Output recovery failed: {e}"); + } + } + } + }, + None, + ) + .map_err(|e| e.to_string()) +} + +impl Drop for VoiceSession { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + + let _ = self.input_stream.pause(); + if let Ok(output) = self.output_stream.lock() { + let _ = output.pause(); + } + + eprintln!("[vc] VoiceSession dropped and audio streams paused."); + } +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..cc7cc22 --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,77 @@ +use cpal::traits::{DeviceTrait, HostTrait}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, State}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Config { + pub input_device_name: Option, + pub output_device_name: Option, + pub input_volume: u8, + pub output_volume: u8, +} + +impl Default for Config { + fn default() -> Self { + let host = cpal::default_host(); + + Self { + input_device_name: host + .default_input_device() + .and_then(|d| Some(d.description().ok()?.name().to_string())), + output_device_name: host + .default_output_device() + .and_then(|d| Some(d.description().ok()?.name().to_string())), + input_volume: 0, + output_volume: 0, + } + } +} + +pub struct ConfigState(pub Mutex); + +fn config_file_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create app data dir: {e}"))?; + Ok(dir.join("config.json")) +} + +#[tauri::command] +pub fn update_config(state: State, config: Config) -> Result<(), String> { + *state.0.lock().unwrap() = config; + Ok(()) +} + +#[tauri::command] +pub fn save_config(app: AppHandle, state: State) -> Result<(), String> { + let path = config_file_path(&app)?; + let config = state.0.lock().unwrap(); + + let json = serde_json::to_string_pretty(&*config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + + fs::write(&path, json).map_err(|e| format!("Failed to write config: {e}"))?; + + Ok(()) +} + +#[tauri::command] +pub fn get_config(app: AppHandle, state: State) -> Result { + let path = config_file_path(&app)?; + + let config = if path.exists() { + let json = fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("Failed to parse config: {e}"))? + } else { + Config::default() + }; + + *state.0.lock().unwrap() = config.clone(); + + Ok(config) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c714d1c..62c6f59 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,4 @@ pub mod accounts; +pub mod audio; +pub mod config; pub mod server_list; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a7af70..cd3b64b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,7 @@ +use std::sync::Mutex; + +use crate::commands::config::ConfigState; + pub mod commands; pub mod types; @@ -5,11 +9,20 @@ pub mod types; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .manage(ConfigState(Mutex::new(commands::config::Config::default()))) + .manage(commands::audio::VoiceState::default()) .invoke_handler(tauri::generate_handler![ commands::server_list::save_server_list, commands::server_list::get_server_list, commands::accounts::save_accounts, commands::accounts::get_accounts, + commands::audio::list_input_devices, + commands::audio::list_output_devices, + commands::audio::connect_to_vc, + commands::audio::disconnect_from_vc, + commands::config::update_config, + commands::config::save_config, + commands::config::get_config, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 45fbf04..bf24892 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { import Sidebar from "./components/view/Sidebar"; import { ThemeProvider } from "next-themes"; import Page from "./components/page/PageView"; +import { SettingsDialog } from "./components/settings/SettingsDialog"; export default function App() { const appRef = useRef(null); @@ -54,6 +55,8 @@ export default function App() { + + )} diff --git a/src/app/app.ts b/src/app/app.ts index 1150284..55efdec 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -28,12 +28,14 @@ export default class Enclave

{ public accounts?: AccountsFile; public server?: EnclaveServer; public serverList: ServerList; + public isSettingsOpen: boolean; public forceRender: () => void; public page?: P; public constructor() { this.serverList = {}; this.forceRender = () => {}; + this.isSettingsOpen = false; } public async init() { @@ -204,6 +206,39 @@ export default class Enclave

{ server.messages[msg.channel_id][msg.message.id] = msg.message; this.forceRender(); return; + + case "JoinVoice": + server.voiceJoin && server.voiceJoin(msg.pin, msg.channel_id); + return; + + case "UserJoinedVoice": + if (!server.voiceChatUsers[msg.channel_id]) + server.voiceChatUsers[msg.channel_id] = []; + + server.voiceChatUsers[msg.channel_id].push(msg.pubkey); + this.server?.getUsers([msg.pubkey]); + return; + + case "UserLeftVoice": + if (!server.voiceChatUsers[msg.channel_id]) return; + + server.voiceChatUsers[msg.channel_id] = server.voiceChatUsers[ + msg.channel_id + ].filter((v) => v !== msg.pubkey); + + this.forceRender(); + + return; + + case "Speaking": + clearTimeout(server.voiceChatSpeakers[msg.pubkey]); + + server.voiceChatSpeakers[msg.pubkey] = setTimeout(() => { + delete server.voiceChatSpeakers[msg.pubkey]; + this.forceRender(); + }, 800); + this.forceRender(); + return; } } } diff --git a/src/app/protocol.ts b/src/app/protocol.ts index 0d1c204..a31272e 100644 --- a/src/app/protocol.ts +++ b/src/app/protocol.ts @@ -29,6 +29,25 @@ export type ClientMethod = method: "MessageDeleted"; channel_id: string; message_id: string; + } + | { + method: "JoinVoice"; + channel_id: string; + pin: bigint; + } + | { + method: "UserJoinedVoice"; + channel_id: string; + pubkey: string; + } + | { + method: "UserLeftVoice"; + channel_id: string; + pubkey: string; + } + | { + method: "Speaking"; + pubkey: string; }; export type ServerMethod = @@ -71,4 +90,11 @@ export type ServerMethod = method: "DeleteMessage"; channel_id: string; message_id: string; + } + | { + method: "JoinVoice"; + channel_id: string; + } + | { + method: "LeaveVoice"; }; diff --git a/src/app/server.ts b/src/app/server.ts index 37ed228..a3e56d1 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -29,14 +29,21 @@ export default class EnclaveServer { public isSecure: boolean; public websocket?: EnclaveWebSocket; public meta?: ServerMeta; + public messages: Record>; public users: Record; + public voiceJoin?: (pin: bigint, channelId: string) => void; + public voiceChatUsers: Record; + public voiceChatSpeakers: Record; + public constructor(hostname: string, isSecure: boolean) { this.hostname = hostname; this.isSecure = isSecure; this.messages = {}; this.users = {}; + this.voiceChatUsers = {}; + this.voiceChatSpeakers = {}; } public disconnect() { diff --git a/src/app/ws.ts b/src/app/ws.ts index 6b08d04..9962d7b 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { ClientMethod, ServerMethod } from "./protocol"; /** @@ -15,6 +16,9 @@ export default class EnclaveWebSocket { this.onOpenQueue = new Array(); this.websocket = new WebSocket(hostname); + this.websocket.onclose = () => { + invoke("disconnect_from_vc"); + }; this.websocket.onopen = () => { this.onOpenQueue.forEach((fun) => fun()); }; diff --git a/src/components/page/channel/ChannelPage.tsx b/src/components/page/channel/ChannelPage.tsx index f9b811b..cac049e 100644 --- a/src/components/page/channel/ChannelPage.tsx +++ b/src/components/page/channel/ChannelPage.tsx @@ -1,6 +1,7 @@ import Enclave from "@/app/app"; import { ChannelPageProps } from "@/components/page/PageView"; import TextChannel from "./TextChannel"; +import VoiceChannel from "./VoiceChannel"; export default function ChannelPage({ appRef, @@ -12,5 +13,7 @@ export default function ChannelPage({ switch (appRef.current?.page?.channel.kind) { case "text": return ; + case "voice": + return ; } } diff --git a/src/components/page/channel/VoiceChannel.tsx b/src/components/page/channel/VoiceChannel.tsx new file mode 100644 index 0000000..52a7ed0 --- /dev/null +++ b/src/components/page/channel/VoiceChannel.tsx @@ -0,0 +1,167 @@ +import Enclave from "@/app/app"; +import { ChannelPageProps } from "../PageView"; +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Phone, PhoneOff } from "lucide-react"; + +export default function VoiceChannel({ + appRef, +}: { + appRef: React.RefObject | null>; +}) { + const channel = appRef.current?.page?.channel; + if (!channel) return null; + + const [isConnected, setIsConnected] = useState(false); + const lastChannelId = useRef(null); + + // Leave current channel setup + const handleLeave = () => { + appRef.current?.server?.websocket?.send({ method: "LeaveVoice" }); + invoke("disconnect_from_vc").catch(console.error); + setIsConnected(false); + }; + + // Explicit action to join the voice channel + const handleJoin = () => { + const hostname = appRef.current?.server?.hostname; + if (!hostname || channel.kind !== "voice" || !appRef.current?.server) + return; + + appRef.current.server.voiceJoin = (pin, channelId) => { + invoke("connect_to_vc", { hostname, pin, channelId }) + .then(() => setIsConnected(true)) + .catch(console.error); + }; + + appRef.current.server.websocket?.send({ + method: "JoinVoice", + channel_id: channel.id, + }); + + setIsConnected(true); + }; + + // Clean up when channel changes or component unmounts + useEffect(() => { + if (lastChannelId.current !== channel.id) { + if (isConnected) { + invoke("disconnect_from_vc").catch(console.error); + setIsConnected(false); + } + lastChannelId.current = channel.id; + } + + return () => { + invoke("disconnect_from_vc").catch(console.error); + }; + }, [channel.id]); + + const speakers = + appRef.current?.server?.voiceChatSpeakers && + Object.keys(appRef.current?.server?.voiceChatSpeakers); + + const users = appRef.current?.server?.voiceChatUsers[channel.id]; + const usersLength = users?.length || 0; + + // Dynamic grid column layout based on user count + const getGridCols = () => { + if (usersLength <= 3) return "grid-cols-1"; + if (usersLength <= 6) return "grid-cols-3"; + if (usersLength <= 9) return "grid-cols-3"; + return "grid-cols-5"; + }; + + // Avatar sizing scales up for fewer participants + const getAvatarSize = () => { + if (usersLength <= 2) return "h-24 w-24 text-3xl"; + if (usersLength <= 4) return "h-20 w-20 text-2xl"; + return "h-14 w-14 text-lg"; + }; + + return ( +

+ {/* Header */} +
+

{channel.name}

+ + {usersLength} {usersLength === 1 ? "Participant" : "Participants"} + +
+ + {/* Main Grid */} +
+ {users?.map((pubkey) => { + const user = appRef.current?.server?.users[pubkey]; + const isSpeaking = speakers?.includes(pubkey); + const displayName = user?.displayName || "Unknown User"; + const fallbackLetter = displayName.charAt(0).toUpperCase(); + + return ( +
+ {/* Avatar Container */} +
+ + + + {fallbackLetter} + + +
+ + {/* Name Plate */} +
+ {displayName} +
+
+ ); + })} +
+ + {/* Bottom Control Bar */} +
+ {isConnected ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx new file mode 100644 index 0000000..19e5c8f --- /dev/null +++ b/src/components/settings/AppearanceSettings.tsx @@ -0,0 +1,14 @@ +import { Palette } from "lucide-react"; + +export function AppearanceSettings() { + return ( +
+

+ Appearance +

+

+ Theme and display settings go here. +

+
+ ); +} diff --git a/src/components/settings/AudioVideoSettings.tsx b/src/components/settings/AudioVideoSettings.tsx new file mode 100644 index 0000000..a474f92 --- /dev/null +++ b/src/components/settings/AudioVideoSettings.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; +import { Volume2, Mic, Speaker } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getConfig, updateConfig, saveConfig, Config } from "@/lib/config"; + +export function AudioVideoSettings() { + const [config, setConfig] = useState(null); + const [inputDevices, setInputDevices] = useState([]); + const [outputDevices, setOutputDevices] = useState([]); + + useEffect(() => { + getConfig().then(setConfig); + invoke("list_input_devices").then(setInputDevices); + invoke("list_output_devices").then(setOutputDevices); + }, []); + + async function handleChange(patch: Partial) { + if (!config) return; + const updated = { ...config, ...patch }; + setConfig(updated); + await updateConfig(updated); + await saveConfig(); + } + + if (!config) return null; + + return ( +
+

+ Audio & Video +

+ +
+
+ + Input +
+ +
+ + +
+ +
+
+ + + {config.inputVolume}% + +
+ + handleChange({ inputVolume: value as any }) + } + max={100} + step={1} + /> +
+
+ +
+
+ + Output +
+ +
+ + +
+ +
+
+ + + {config.outputVolume}% + +
+ + handleChange({ outputVolume: value as any }) + } + max={100} + step={1} + /> +
+
+
+ ); +} diff --git a/src/components/settings/ProfileSettings.tsx b/src/components/settings/ProfileSettings.tsx new file mode 100644 index 0000000..d6957c1 --- /dev/null +++ b/src/components/settings/ProfileSettings.tsx @@ -0,0 +1,14 @@ +import { User } from "lucide-react"; + +export function ProfileSettings() { + return ( +
+

+ Profile +

+

+ Display name and avatar settings go here. +

+
+ ); +} diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..2b6f19c --- /dev/null +++ b/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { User, Palette, Volume2 } from "lucide-react"; +import { ProfileSettings } from "./ProfileSettings"; +import { AppearanceSettings } from "./AppearanceSettings"; +import { AudioVideoSettings } from "./AudioVideoSettings"; +import Enclave from "@/app/app"; + +type SettingsCategory = "profile" | "appearance" | "audio-video"; + +const CATEGORIES: { + id: SettingsCategory; + label: string; + icon: React.ElementType; +}[] = [ + { id: "profile", label: "Profile", icon: User }, + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "audio-video", label: "Audio & Video", icon: Volume2 }, +]; + +export function SettingsDialog({ + appRef, +}: { + appRef: React.RefObject; +}) { + const [category, setCategory] = useState("profile"); + + return ( + { + if (!appRef.current) return; + + appRef.current.isSettingsOpen = open; + appRef.current.forceRender(); + }} + > + +
+ + Settings + + +
+ +
+ {category === "profile" && } + {category === "appearance" && } + {category === "audio-video" && } +
+
+
+ ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 3fc1dda..3770278 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,24 +1,24 @@ -import * as React from "react" -import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import * as React from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { XIcon } from "lucide-react" +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; function Dialog({ ...props }: DialogPrimitive.Root.Props) { - return + return ; } function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { - return + return ; } function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return + return ; } function DialogClose({ ...props }: DialogPrimitive.Close.Props) { - return + return ; } function DialogOverlay({ @@ -29,12 +29,12 @@ function DialogOverlay({ - ) + ); } function DialogContent({ @@ -43,7 +43,7 @@ function DialogContent({ showCloseButton = true, ...props }: DialogPrimitive.Popup.Props & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return ( @@ -52,7 +52,7 @@ function DialogContent({ data-slot="dialog-content" className={cn( "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", - className + className, )} {...props} > @@ -68,14 +68,13 @@ function DialogContent({ /> } > - + Close )} - ) + ); } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -85,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-2", className)} {...props} /> - ) + ); } function DialogFooter({ @@ -94,14 +93,14 @@ function DialogFooter({ children, ...props }: React.ComponentProps<"div"> & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return (
@@ -112,7 +111,7 @@ function DialogFooter({ )}
- ) + ); } function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { @@ -121,11 +120,11 @@ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { data-slot="dialog-title" className={cn( "font-heading text-base leading-none font-medium", - className + className, )} {...props} /> - ) + ); } function DialogDescription({ @@ -137,11 +136,11 @@ function DialogDescription({ data-slot="dialog-description" className={cn( "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", - className + className, )} {...props} /> - ) + ); } export { @@ -155,4 +154,4 @@ export { DialogPortal, DialogTitle, DialogTrigger, -} +}; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..56a7734 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,199 @@ +import * as React from "react" +import { Select as SelectPrimitive } from "@base-ui/react/select" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +const Select = SelectPrimitive.Root + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + } + /> + + ) +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..691fff3 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,52 @@ +import { Slider as SliderPrimitive } from "@base-ui/react/slider" + +import { cn } from "@/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: SliderPrimitive.Root.Props) { + const _values = Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max] + + return ( + + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + + ) +} + +export { Slider } diff --git a/src/components/view/AccountCard.tsx b/src/components/view/AccountCard.tsx index 67b8a9a..d5d63e4 100644 --- a/src/components/view/AccountCard.tsx +++ b/src/components/view/AccountCard.tsx @@ -41,7 +41,16 @@ export default function AccountCard({
-
diff --git a/src/components/view/Sidebar.tsx b/src/components/view/Sidebar.tsx index d786dc2..1fe61fa 100644 --- a/src/components/view/Sidebar.tsx +++ b/src/components/view/Sidebar.tsx @@ -1,16 +1,18 @@ import Enclave from "@/app/app"; import { Channel, ChannelKind } from "@/lib/types"; -import { ChevronDown, ChevronUp, HashIcon } from "lucide-react"; +import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react"; import { useState } from "react"; import AccountCard from "./AccountCard"; import { cn } from "@/lib/utils"; export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) { switch (kind) { + case "category": + return null; case "text": return ; - default: - return null; + case "voice": + return ; } } diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..270e766 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,20 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type Config = { + outputDeviceName: string | null; + inputDeviceName: string | null; + inputVolume: number; + outputVolume: number; +}; + +export async function updateConfig(config: Config): Promise { + await invoke("update_config", { config }); +} + +export async function saveConfig(): Promise { + await invoke("save_config"); +} + +export async function getConfig(): Promise { + return await invoke("get_config"); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 647e08e..4aecd18 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -24,4 +24,6 @@ export interface StoredMessage extends MessageData { export type Channel = { id: string; name: string } & ChannelKind; export type ChannelKind = - { kind: "text" } | { kind: "category"; channels: Channel[] }; + | { kind: "category"; channels: Channel[] } + | { kind: "voice"; max_users: number } + | { kind: "text" };