diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d86cb7..94ff6a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -392,7 +392,7 @@ dependencies = [ "bitflags 2.8.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -406,24 +406,6 @@ dependencies = [ "which", ] -[[package]] -name = "bindgen" -version = "0.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" -dependencies = [ - "bitflags 2.8.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.96", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -612,8 +594,6 @@ version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" dependencies = [ - "jobserver", - "libc", "shlex", ] @@ -655,9 +635,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "cfg_aliases" @@ -689,12 +669,6 @@ dependencies = [ "libloading 0.8.6", ] -[[package]] -name = "claxon" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" - [[package]] name = "clipboard-win" version = "5.4.0" @@ -884,17 +858,6 @@ dependencies = [ "libc", ] -[[package]] -name = "coreaudio-rs" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" -dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - [[package]] name = "coreaudio-rs" version = "0.13.0" @@ -909,38 +872,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "coreaudio-sys" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b" -dependencies = [ - "bindgen 0.70.1", -] - -[[package]] -name = "cpal" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" -dependencies = [ - "alsa", - "core-foundation-sys", - "coreaudio-rs 0.11.3", - "dasp_sample", - "jni", - "js-sys", - "libc", - "mach2", - "ndk 0.8.0", - "ndk-context", - "oboe", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.54.0", -] - [[package]] name = "cpal" version = "0.16.0" @@ -948,13 +879,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" dependencies = [ "alsa", - "coreaudio-rs 0.13.0", + "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", "mach2", - "ndk 0.9.0", + "ndk", "ndk-context", "num-derive", "num-traits", @@ -1475,6 +1406,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "eyre" version = "0.6.12" @@ -2069,7 +2006,7 @@ name = "handy" version = "0.2.3" dependencies = [ "anyhow", - "cpal 0.16.0", + "cpal", "enigo", "env_logger", "futures-util", @@ -2618,15 +2555,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.14" @@ -2678,15 +2606,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" -dependencies = [ - "libc", -] - [[package]] name = "jpeg-decoder" version = "0.3.1" @@ -2760,17 +2679,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "lewton" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" -dependencies = [ - "byteorder", - "ogg", - "tinyvec", -] - [[package]] name = "libappindicator" version = "0.9.0" @@ -3078,20 +2986,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.8.0", - "jni-sys", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -3101,7 +2995,7 @@ dependencies = [ "bitflags 2.8.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -3113,15 +3007,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3175,6 +3060,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3210,6 +3105,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3561,38 +3467,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - -[[package]] -name = "ogg" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" -dependencies = [ - "byteorder", -] - [[package]] name = "once_cell" version = "1.20.2" @@ -4480,13 +4354,11 @@ checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53" [[package]] name = "rodio" version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +source = "git+https://github.com/cjpais/rodio.git#fed30292db417cb95305c118c0e1d804fb74cbff" dependencies = [ - "claxon", - "cpal 0.15.3", - "hound", - "lewton", + "cpal", + "dasp_sample", + "num-rational", "symphonia", ] @@ -5120,9 +4992,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" dependencies = [ "lazy_static", + "symphonia-bundle-flac", "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97" +dependencies = [ + "log", "symphonia-core", "symphonia-metadata", + "symphonia-utils-xiph", ] [[package]] @@ -5137,6 +5028,38 @@ dependencies = [ "symphonia-metadata", ] +[[package]] +name = "symphonia-codec-aac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-core" version = "0.5.4" @@ -5150,6 +5073,43 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "symphonia-metadata" version = "0.5.4" @@ -5162,6 +5122,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -5273,9 +5243,9 @@ dependencies = [ "lazy_static", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "objc2 0.6.1", "objc2-app-kit 0.3.1", "objc2-foundation 0.3.1", @@ -6538,7 +6508,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76bab42b2c319e3a1e0280137c59368072348d3277873c7588b6466a127dca58" dependencies = [ - "bindgen 0.69.5", + "bindgen", "cfg-if", "cmake", "fs_extra", @@ -7204,7 +7174,7 @@ dependencies = [ "jni", "kuchikiki", "libc", - "ndk 0.9.0", + "ndk", "objc2 0.6.1", "objc2-app-kit 0.3.1", "objc2-core-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6882cbb..d082e4f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -48,7 +48,7 @@ tauri-plugin-store = "2" tauri-plugin-os = "2" enigo = "0.5.0" tauri-plugin-process = "2" -rodio = "0.20.1" +rodio = { git = "https://github.com/cjpais/rodio.git" } reqwest = { version = "0.11", features = ["json", "stream"] } futures-util = "0.3" diff --git a/src-tauri/src/audio_toolkit/audio/device.rs b/src-tauri/src/audio_toolkit/audio/device.rs index e1b2aa1..fbe2dec 100644 --- a/src-tauri/src/audio_toolkit/audio/device.rs +++ b/src-tauri/src/audio_toolkit/audio/device.rs @@ -28,3 +28,25 @@ pub fn list_input_devices() -> Result, Box Result, Box> { + let host = cpal::default_host(); + let default_name = host.default_output_device().and_then(|d| d.name().ok()); + + let mut out = Vec::::new(); + + for (index, device) in host.output_devices()?.enumerate() { + let name = device.name().unwrap_or_else(|_| "Unknown".into()); + + let is_default = Some(name.clone()) == default_name; + + out.push(CpalDeviceInfo { + index: index.to_string(), + name, + is_default, + device, + }); + } + + Ok(out) +} diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index 55ada4e..e837b8e 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -3,6 +3,6 @@ mod device; mod recorder; mod resampler; -pub use device::{CpalDeviceInfo, list_input_devices}; +pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo}; pub use recorder::AudioRecorder; pub use resampler::FrameResampler; diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index a97cc47..a0ca572 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -1,19 +1,19 @@ use std::{ io::Error, - sync::{Arc, Mutex, mpsc}, + sync::{mpsc, Arc, Mutex}, time::Duration, }; use cpal::{ - Device, Sample, SizedSample, traits::{DeviceTrait, HostTrait, StreamTrait}, + Device, Sample, SizedSample, }; use crate::audio_toolkit::{ - VoiceActivityDetector, audio::FrameResampler, constants, vad::{self, VadFrame}, + VoiceActivityDetector, }; enum Cmd { diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index 7c70ae6..b9455a0 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -2,5 +2,5 @@ pub mod audio; pub mod constants; pub mod vad; -pub use audio::{AudioRecorder, CpalDeviceInfo, list_input_devices}; +pub use audio::{list_input_devices, list_output_devices, AudioRecorder, CpalDeviceInfo}; pub use vad::{SileroVad, VoiceActivityDetector}; diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 1842b38..00d6cc3 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,8 +1,17 @@ +use crate::audio_toolkit::audio::{list_input_devices, list_output_devices}; use crate::managers::audio::{AudioRecordingManager, MicrophoneMode}; use crate::settings::{get_settings, write_settings}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, Manager}; +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AudioDevice { + pub index: String, + pub name: String, + pub is_default: bool, +} + #[tauri::command] pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { // Update settings @@ -27,3 +36,89 @@ pub fn get_microphone_mode(app: AppHandle) -> Result { let settings = get_settings(&app); Ok(settings.always_on_microphone) } + +#[tauri::command] +pub fn get_available_microphones() -> Result, String> { + let devices = + list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok(result) +} + +#[tauri::command] +pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { + let mut settings = get_settings(&app); + settings.selected_microphone = if device_name == "default" { + None + } else { + Some(device_name) + }; + write_settings(&app, settings); + + // Update the audio manager to use the new device + let rm = app.state::>(); + rm.update_selected_device() + .map_err(|e| format!("Failed to update selected device: {}", e))?; + + Ok(()) +} + +#[tauri::command] +pub fn get_selected_microphone(app: AppHandle) -> Result { + let settings = get_settings(&app); + Ok(settings + .selected_microphone + .unwrap_or_else(|| "default".to_string())) +} + +#[tauri::command] +pub fn get_available_output_devices() -> Result, String> { + let devices = + list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok(result) +} + +#[tauri::command] +pub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result<(), String> { + let mut settings = get_settings(&app); + settings.selected_output_device = if device_name == "default" { + None + } else { + Some(device_name) + }; + write_settings(&app, settings); + Ok(()) +} + +#[tauri::command] +pub fn get_selected_output_device(app: AppHandle) -> Result { + let settings = get_settings(&app); + Ok(settings + .selected_output_device + .unwrap_or_else(|| "default".to_string())) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 603c8ca..e6f7d25 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -188,7 +188,13 @@ pub fn run() { commands::models::has_any_models_or_downloads, commands::models::get_recommended_first_model, commands::audio::update_microphone_mode, - commands::audio::get_microphone_mode + commands::audio::get_microphone_mode, + commands::audio::get_available_microphones, + commands::audio::set_selected_microphone, + commands::audio::get_selected_microphone, + commands::audio::get_available_output_devices, + commands::audio::set_selected_output_device, + commands::audio::get_selected_output_device ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 3f99dae..3b12ae4 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -1,5 +1,5 @@ +use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad}; use crate::settings::get_settings; -use crate::audio_toolkit::{vad::SmoothedVad, AudioRecorder, SileroVad}; use log::{debug, info}; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -100,8 +100,26 @@ impl AudioRecordingManager { *recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?); } + // Get the selected device from settings + let settings = get_settings(&self.app_handle); + let selected_device = if let Some(device_name) = settings.selected_microphone { + // Find the device by name + match list_input_devices() { + Ok(devices) => devices + .into_iter() + .find(|d| d.name == device_name) + .map(|d| d.device), + Err(e) => { + debug!("Failed to list devices, using default: {}", e); + None + } + } + } else { + None + }; + if let Some(rec) = recorder_opt.as_mut() { - rec.open(None) + rec.open(selected_device) .map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?; } @@ -187,6 +205,15 @@ impl AudioRecordingManager { } } + pub fn update_selected_device(&self) -> Result<(), anyhow::Error> { + // If currently open, restart the microphone stream to use the new device + if *self.is_open.lock().unwrap() { + self.stop_microphone_stream(); + self.start_microphone_stream()?; + } + Ok(()) + } + pub fn stop_recording(&self, binding_id: &str) -> Option> { let mut state = self.state.lock().unwrap(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9adfbd1..aeb96a8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -22,6 +22,10 @@ pub struct AppSettings { pub selected_model: String, #[serde(default = "default_always_on_microphone")] pub always_on_microphone: bool, + #[serde(default)] + pub selected_microphone: Option, + #[serde(default)] + pub selected_output_device: Option, } fn default_model() -> String { @@ -77,6 +81,8 @@ pub fn get_default_settings() -> AppSettings { audio_feedback: false, selected_model: "".to_string(), always_on_microphone: false, + selected_microphone: None, + selected_output_device: None, } } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 8917317..fc7b20c 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -5,7 +5,8 @@ use enigo::Key; use enigo::Keyboard; use enigo::Settings; -use rodio::{Decoder, OutputStream, Sink}; +use cpal::traits::{DeviceTrait, HostTrait}; +use rodio::OutputStreamBuilder; use std::fs::File; use std::io::BufReader; use std::thread; @@ -121,8 +122,12 @@ pub fn play_sound(app: &AppHandle, resource_path: &str) { } }; + // Get the selected output device from settings + let settings = settings::get_settings(&app_handle); + let selected_device = settings.selected_output_device.clone(); + // Try to play the audio file - if let Err(e) = play_audio_file(&audio_path) { + if let Err(e) = play_audio_file(&audio_path, selected_device) { eprintln!("Failed to play sound '{}': {}", resource_path, e); } }); @@ -138,20 +143,50 @@ pub fn play_recording_stop_sound(app: &AppHandle) { play_sound(app, "resources/rec_stop.wav"); } -fn play_audio_file(path: &std::path::Path) -> Result<(), Box> { - // Get a output stream handle to the default physical sound device - let (_stream, stream_handle) = OutputStream::try_default()?; +fn play_audio_file( + path: &std::path::Path, + selected_device: Option, +) -> Result<(), Box> { + let stream_builder = if let Some(device_name) = selected_device { + if device_name == "Default" { + println!("Using default device"); + // Use default device + OutputStreamBuilder::from_default_device()? + } else { + // Try to find the device by name + let host = cpal::default_host(); + let devices = host.output_devices()?; + + let mut found_device = None; + for device in devices { + if device.name()? == device_name { + found_device = Some(device); + break; + } + } + + match found_device { + Some(device) => OutputStreamBuilder::from_device(device)?, + None => { + eprintln!("Device '{}' not found, using default device", device_name); + OutputStreamBuilder::from_default_device()? + } + } + } + } else { + println!("Using default device"); + // Use default device + OutputStreamBuilder::from_default_device()? + }; + + let stream_handle = stream_builder.open_stream()?; + let mixer = stream_handle.mixer(); // Load the audio file let file = File::open(path)?; let buf_reader = BufReader::new(file); - let source = Decoder::new(buf_reader)?; - // Create a sink to play the audio - let sink = Sink::try_new(&stream_handle)?; - sink.append(source); - - // Wait for the audio to finish playing + let sink = rodio::play(mixer, buf_reader)?; sink.sleep_until_end(); Ok(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d3a2557..ffc77be 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,9 +14,9 @@ { "title": "Handy", "width": 540, - "height": 510, + "height": 590, "minWidth": 540, - "minHeight": 510, + "minHeight": 590, "resizable": true, "maximizable": false } diff --git a/src/components/settings/AlwaysOnMicrophone.tsx b/src/components/settings/AlwaysOnMicrophone.tsx new file mode 100644 index 0000000..1acabb6 --- /dev/null +++ b/src/components/settings/AlwaysOnMicrophone.tsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; + +interface AlwaysOnMicrophoneProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const AlwaysOnMicrophone: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [alwaysOnMode, setAlwaysOnMode] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + try { + const alwaysOn: boolean = await invoke("get_microphone_mode"); + setAlwaysOnMode(alwaysOn); + } catch (error) { + console.error("Failed to load always-on microphone setting:", error); + } + }; + + const handleAlwaysOnToggle = async (enabled: boolean) => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("update_microphone_mode", { alwaysOn: enabled }); + setAlwaysOnMode(enabled); + + // Provide user feedback about the change + console.log(`Always-on microphone ${enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Failed to update microphone mode:", error); + // Revert the toggle if the update failed + setAlwaysOnMode(!enabled); + } finally { + setIsUpdating(false); + } + }; + + return ( + + ); +}; diff --git a/src/components/settings/AudioFeedback.tsx b/src/components/settings/AudioFeedback.tsx new file mode 100644 index 0000000..de768ce --- /dev/null +++ b/src/components/settings/AudioFeedback.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; + +interface AudioFeedbackProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const AudioFeedback: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [audioFeedbackEnabled, setAudioFeedbackEnabled] = + useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + try { + // Load from the store since this setting is handled differently + const { load } = await import("@tauri-apps/plugin-store"); + const store = await load("settings_store.json", { autoSave: false }); + const settings = await store.get("settings"); + + if ( + settings && + typeof settings === "object" && + "audio_feedback" in settings + ) { + setAudioFeedbackEnabled(settings.audio_feedback as boolean); + } + } catch (error) { + console.error("Failed to load audio feedback setting:", error); + } + }; + + const handleAudioFeedbackToggle = async (enabled: boolean) => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("change_audio_feedback_setting", { enabled }); + setAudioFeedbackEnabled(enabled); + console.log(`Audio feedback ${enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Failed to update audio feedback setting:", error); + // Revert the toggle if the update failed + setAudioFeedbackEnabled(!enabled); + } finally { + setIsUpdating(false); + } + }; + + return ( + + ); +}; diff --git a/src/components/settings/KeyboardShortcuts.tsx b/src/components/settings/HandyShortcut.tsx similarity index 60% rename from src/components/settings/KeyboardShortcuts.tsx rename to src/components/settings/HandyShortcut.tsx index c1f6a45..6bae54e 100644 --- a/src/components/settings/KeyboardShortcuts.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -1,21 +1,25 @@ import React, { useEffect, useState, useRef } from "react"; -import { load } from "@tauri-apps/plugin-store"; import { BindingResponseSchema, SettingsSchema, - ShortcutBindingSchema, ShortcutBindingsMap, } from "../../lib/types"; import { invoke } from "@tauri-apps/api/core"; import { type } from "@tauri-apps/plugin-os"; import { getKeyName } from "../../lib/utils/keyboard"; import ResetIcon from "../icons/ResetIcon"; +import { SettingContainer } from "../ui/SettingContainer"; -export const KeyboardShortcuts: React.FC = () => { +interface HandyShortcutProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const HandyShortcut: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { const [bindings, setBindings] = React.useState({}); - const [pttEnabled, setPttEnabled] = React.useState(false); - const [audioFeedbackEnabled, setAudioFeedbackEnabled] = - React.useState(false); const [keyPressed, setKeyPressed] = useState([]); const [recordedKeys, setRecordedKeys] = useState([]); const [editingShortcutId, setEditingShortcutId] = useState( @@ -23,13 +27,14 @@ export const KeyboardShortcuts: React.FC = () => { ); const [originalBinding, setOriginalBinding] = useState(""); const [isMacOS, setIsMacOS] = useState(false); + const [isLoading, setIsLoading] = useState(true); const shortcutRefs = useRef>(new Map()); // Check if running on macOS useEffect(() => { const checkOsType = async () => { try { - const osType = type(); + const osType = await type(); setIsMacOS(osType === "macos"); } catch (error) { console.error("Error detecting OS type:", error); @@ -72,18 +77,27 @@ export const KeyboardShortcuts: React.FC = () => { }; useEffect(() => { - load("settings_store.json", { autoSave: false }).then((r) => { - console.log("loaded store", r); - - r.get("settings").then((s) => { - const settings = SettingsSchema.parse(s); - setBindings(settings.bindings); - setPttEnabled(settings.push_to_talk); - setAudioFeedbackEnabled(settings.audio_feedback); - }); - }); + loadSettings(); }, []); + const loadSettings = async () => { + try { + setIsLoading(true); + const { load } = await import("@tauri-apps/plugin-store"); + const store = await load("settings_store.json", { autoSave: false }); + const settings = await store.get("settings"); + + if (settings) { + const parsedSettings = SettingsSchema.parse(settings); + setBindings(parsedSettings.bindings); + } + } catch (error) { + console.error("Failed to load settings:", error); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; @@ -91,7 +105,7 @@ export const KeyboardShortcuts: React.FC = () => { console.log("keyPressed", keyPressed); // Keyboard event listeners - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = async (e: KeyboardEvent) => { e.preventDefault(); // Get the key and normalize it (unify left/right modifiers) @@ -109,7 +123,7 @@ export const KeyboardShortcuts: React.FC = () => { } }; - const handleKeyUp = (e: KeyboardEvent) => { + const handleKeyUp = async (e: KeyboardEvent) => { e.preventDefault(); // Get the key and normalize it @@ -120,7 +134,8 @@ export const KeyboardShortcuts: React.FC = () => { setKeyPressed((prev) => prev.filter((k) => k !== key)); // If no keys are pressed anymore, commit the shortcut - if (keyPressed.length === 1 && keyPressed[0] === key) { + const updatedKeyPressed = keyPressed.filter((k) => k !== key); + if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) { // Create the shortcut string from all recorded keys const newShortcut = recordedKeys.join("+"); @@ -135,10 +150,22 @@ export const KeyboardShortcuts: React.FC = () => { [editingShortcutId]: updatedBinding, })); - invoke("change_binding", { - id: editingShortcutId, - binding: newShortcut, - }); + try { + await invoke("change_binding", { + id: editingShortcutId, + binding: newShortcut, + }); + } catch (error) { + console.error("Failed to change binding:", error); + // Restore original binding on error + setBindings((prev) => ({ + ...prev, + [editingShortcutId]: { + ...prev[editingShortcutId], + current_binding: originalBinding, + }, + })); + } // Exit editing mode and reset states setEditingShortcutId(null); @@ -174,17 +201,19 @@ export const KeyboardShortcuts: React.FC = () => { window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); - window.addEventListener("mousedown", handleClickOutside); + window.addEventListener("click", handleClickOutside); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); - window.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("click", handleClickOutside); }; }, [keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding]); // Start recording a new shortcut const startRecording = (id: string) => { + if (editingShortcutId === id) return; // Already editing this shortcut + // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); setEditingShortcutId(id); @@ -209,69 +238,56 @@ export const KeyboardShortcuts: React.FC = () => { shortcutRefs.current.set(id, ref); }; + // If still loading, show loading state + if (isLoading) { + return ( + +
Loading shortcuts...
+
+ ); + } + + // If no bindings are loaded, show empty state + if (Object.keys(bindings).length === 0) { + return ( + +
No shortcuts configured
+
+ ); + } + return ( -
-
-
-

Push To Talk

-

Hold to record, release to stop

-
- -
-
-
-

Audio Feedback

-

Play sound when recording starts and stops

-
- -
- {Object.entries(bindings).map(([id, binding]) => ( -
-
-

{binding.name}

-

{binding.description}

-
+ return (
- {editingShortcutId === id ? ( + {editingShortcutId === primaryId ? (
setShortcutRef(id, ref)} + ref={(ref) => setShortcutRef(primaryId, ref)} className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[120px] text-center" > {formatCurrentKeys()} @@ -279,15 +295,16 @@ export const KeyboardShortcuts: React.FC = () => { ) : (
startRecording(id)} + onClick={() => startRecording(primaryId)} > - {formatKeyCombination(binding.current_binding)} + {formatKeyCombination(primaryBinding.current_binding)}
)}
-
- ))} -
+ ); + })()} + ); }; diff --git a/src/components/settings/MicrophoneSelector.tsx b/src/components/settings/MicrophoneSelector.tsx new file mode 100644 index 0000000..7a36510 --- /dev/null +++ b/src/components/settings/MicrophoneSelector.tsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { AudioDevice } from "../../lib/types"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import ResetIcon from "../icons/ResetIcon"; + +interface MicrophoneSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const MicrophoneSelector: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [audioDevices, setAudioDevices] = useState([]); + const [selectedMicrophone, setSelectedMicrophone] = + useState("default"); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + loadAudioDevices(); + loadSettings(); + }, []); + + const loadAudioDevices = async () => { + try { + const devices: AudioDevice[] = await invoke("get_available_microphones"); + setAudioDevices(devices); + } catch (error) { + console.error("Failed to load audio devices:", error); + setAudioDevices([]); + } + }; + + const loadSettings = async () => { + try { + setIsLoading(true); + const selectedMic: string = await invoke("get_selected_microphone"); + setSelectedMicrophone(selectedMic); + } catch (error) { + console.error("Failed to load microphone settings:", error); + } finally { + setIsLoading(false); + } + }; + + const handleMicrophoneSelect = async (deviceName: string) => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("set_selected_microphone", { deviceName }); + setSelectedMicrophone(deviceName); + console.log( + `Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`, + ); + } catch (error) { + console.error("Failed to set microphone:", error); + // Revert selection if update failed + loadSettings(); + } finally { + setIsUpdating(false); + } + }; + + const handleReset = async () => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("set_selected_microphone", { deviceName: "default" }); + setSelectedMicrophone("default"); + console.log("Microphone reset to default"); + } catch (error) { + console.error("Failed to reset microphone:", error); + // Revert selection if reset failed + loadSettings(); + } finally { + setIsUpdating(false); + } + }; + + return ( + +
+ + +
+ {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/settings/OutputDeviceSelector.tsx b/src/components/settings/OutputDeviceSelector.tsx new file mode 100644 index 0000000..e66c6db --- /dev/null +++ b/src/components/settings/OutputDeviceSelector.tsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { AudioDevice } from "../../lib/types"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import ResetIcon from "../icons/ResetIcon"; + +interface OutputDeviceSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const OutputDeviceSelector: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [outputDevices, setOutputDevices] = useState([]); + const [selectedOutputDevice, setSelectedOutputDevice] = + useState("default"); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + loadOutputDevices(); + loadSettings(); + }, []); + + const loadOutputDevices = async () => { + try { + const devices: AudioDevice[] = await invoke( + "get_available_output_devices", + ); + setOutputDevices(devices); + } catch (error) { + console.error("Failed to load output devices:", error); + setOutputDevices([]); + } + }; + + const loadSettings = async () => { + try { + setIsLoading(true); + const selectedDevice: string = await invoke("get_selected_output_device"); + setSelectedOutputDevice(selectedDevice); + } catch (error) { + console.error("Failed to load output device settings:", error); + } finally { + setIsLoading(false); + } + }; + + const handleOutputDeviceSelect = async (deviceName: string) => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("set_selected_output_device", { deviceName }); + setSelectedOutputDevice(deviceName); + console.log( + `Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`, + ); + } catch (error) { + console.error("Failed to set output device:", error); + // Revert selection if update failed + loadSettings(); + } finally { + setIsUpdating(false); + } + }; + + const handleReset = async () => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("set_selected_output_device", { deviceName: "default" }); + setSelectedOutputDevice("default"); + console.log("Output device reset to default"); + } catch (error) { + console.error("Failed to reset output device:", error); + // Revert selection if reset failed + loadSettings(); + } finally { + setIsUpdating(false); + } + }; + + return ( + +
+ + +
+ {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/settings/PushToTalk.tsx b/src/components/settings/PushToTalk.tsx new file mode 100644 index 0000000..8c04d7a --- /dev/null +++ b/src/components/settings/PushToTalk.tsx @@ -0,0 +1,68 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; + +interface PushToTalkProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const PushToTalk: React.FC = ({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const [pttEnabled, setPttEnabled] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + try { + // Load from the store since this setting is handled differently + const { load } = await import("@tauri-apps/plugin-store"); + const store = await load("settings_store.json", { autoSave: false }); + const settings = await store.get("settings"); + + if ( + settings && + typeof settings === "object" && + "push_to_talk" in settings + ) { + setPttEnabled(settings.push_to_talk as boolean); + } + } catch (error) { + console.error("Failed to load push-to-talk setting:", error); + } + }; + + const handlePttToggle = async (enabled: boolean) => { + if (isUpdating) return; + + try { + setIsUpdating(true); + await invoke("change_ptt_setting", { enabled }); + setPttEnabled(enabled); + console.log(`Push-to-talk ${enabled ? "enabled" : "disabled"}`); + } catch (error) { + console.error("Failed to update push-to-talk setting:", error); + // Revert the toggle if the update failed + setPttEnabled(!enabled); + } finally { + setIsUpdating(false); + } + }; + + return ( + + ); +}; diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 9255041..423174d 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -1,11 +1,26 @@ import React from "react"; -// import { AIConfig } from "./AIConfig"; -import { KeyboardShortcuts } from "./KeyboardShortcuts"; +import { MicrophoneSelector } from "./MicrophoneSelector"; +import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; +import { PushToTalk } from "./PushToTalk"; +import { AudioFeedback } from "./AudioFeedback"; +import { OutputDeviceSelector } from "./OutputDeviceSelector"; +import { HandyShortcut } from "./HandyShortcut"; +import { SettingsGroup } from "../ui/SettingsGroup"; export const Settings: React.FC = () => { return ( -
- +
+ + + + + + + + + + +
); }; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts new file mode 100644 index 0000000..81c1d65 --- /dev/null +++ b/src/components/settings/index.ts @@ -0,0 +1,7 @@ +export { Settings } from "./Settings"; +export { MicrophoneSelector } from "./MicrophoneSelector"; +export { OutputDeviceSelector } from "./OutputDeviceSelector"; +export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; +export { PushToTalk } from "./PushToTalk"; +export { AudioFeedback } from "./AudioFeedback"; +export { HandyShortcut } from "./HandyShortcut"; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..2ea9a9f --- /dev/null +++ b/src/components/ui/Dropdown.tsx @@ -0,0 +1,107 @@ +import React, { useState, useRef, useEffect } from "react"; +import { AudioDevice } from "../../lib/types"; + +interface DropdownProps { + devices: AudioDevice[]; + selectedDevice: string | null; + onSelect: (deviceName: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export const Dropdown: React.FC = ({ + devices, + selectedDevice, + onSelect, + placeholder = "Select a microphone...", + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + // Find the selected device name + const selectedDeviceName = selectedDevice + ? devices.find((d) => d.name === selectedDevice)?.name || "Unknown Device" + : null; + + const handleSelect = (deviceName: string) => { + onSelect(deviceName); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && !disabled && ( +
+ {devices.length === 0 ? ( +
+ No microphones found +
+ ) : ( + devices.map((device) => ( + + )) + )} +
+ )} +
+ ); +}; diff --git a/src/components/ui/SettingContainer.tsx b/src/components/ui/SettingContainer.tsx new file mode 100644 index 0000000..d3d7bb1 --- /dev/null +++ b/src/components/ui/SettingContainer.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect, useRef } from "react"; + +interface SettingContainerProps { + title: string; + description: string; + children: React.ReactNode; + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const SettingContainer: React.FC = ({ + title, + description, + children, + descriptionMode = "tooltip", + grouped = false, +}) => { + const [showTooltip, setShowTooltip] = useState(false); + const tooltipRef = useRef(null); + + // Handle click outside to close tooltip + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + tooltipRef.current && + !tooltipRef.current.contains(event.target as Node) + ) { + setShowTooltip(false); + } + }; + + if (showTooltip) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [showTooltip]); + + const toggleTooltip = () => { + setShowTooltip(!showTooltip); + }; + + const containerClasses = grouped + ? "flex items-center justify-between px-4 p-2" + : "flex items-center justify-between px-4 p-2 rounded-lg border border-mid-gray/20"; + + if (descriptionMode === "tooltip") { + return ( +
+
+
+

{title}

+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onClick={toggleTooltip} + > + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleTooltip(); + } + }} + > + + + {showTooltip && ( +
+

+ {description} +

+
+
+ )} +
+
+
+
{children}
+
+ ); + } + + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +}; diff --git a/src/components/ui/SettingsGroup.tsx b/src/components/ui/SettingsGroup.tsx new file mode 100644 index 0000000..f9266e7 --- /dev/null +++ b/src/components/ui/SettingsGroup.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface SettingsGroupProps { + title?: string; + description?: string; + children: React.ReactNode; +} + +export const SettingsGroup: React.FC = ({ + title, + description, + children, +}) => { + return ( +
+ {title && ( +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ )} +
+
{children}
+
+
+ ); +}; diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx new file mode 100644 index 0000000..f081d78 --- /dev/null +++ b/src/components/ui/ToggleSwitch.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { SettingContainer } from "./SettingContainer"; + +interface ToggleSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + isUpdating?: boolean; + label: string; + description: string; + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ToggleSwitch: React.FC = ({ + checked, + onChange, + disabled = false, + isUpdating = false, + label, + description, + descriptionMode = "tooltip", + grouped = false, +}) => { + return ( + + + {isUpdating && ( +
+
+
+ )} +
+ ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..4abd84c --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { Dropdown } from "./Dropdown"; +export { ToggleSwitch } from "./ToggleSwitch"; +export { SettingContainer } from "./SettingContainer"; +export { SettingsGroup } from "./SettingsGroup"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 892f719..fd97768 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,10 +13,20 @@ export const ShortcutBindingsMapSchema = z.record( ShortcutBindingSchema, ); +export const AudioDeviceSchema = z.object({ + index: z.string(), + name: z.string(), + is_default: z.boolean(), +}); + export const SettingsSchema = z.object({ bindings: ShortcutBindingsMapSchema, push_to_talk: z.boolean(), audio_feedback: z.boolean(), + selected_model: z.string(), + always_on_microphone: z.boolean(), + selected_microphone: z.string().nullable().optional(), + selected_output_device: z.string().nullable().optional(), }); export const BindingResponseSchema = z.object({ @@ -25,6 +35,7 @@ export const BindingResponseSchema = z.object({ error: z.string().nullable(), }); +export type AudioDevice = z.infer; export type BindingResponse = z.infer; export type ShortcutBinding = z.infer; export type ShortcutBindingsMap = z.infer;