From c0fc1ba817a8ef09f55b7ea24fd34bc8a7d881e1 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Thu, 7 Aug 2025 16:53:07 -0700 Subject: [PATCH] some cleanup --- index.html | 26 +- src-tauri/src/actions.rs | 9 +- src-tauri/src/audio_feedback.rs | 106 +++++ src-tauri/src/clipboard.rs | 64 +++ src-tauri/src/lib.rs | 4 + src-tauri/src/overlay.rs | 135 +++++++ src-tauri/src/settings.rs | 40 +- src-tauri/src/shortcut.rs | 13 +- src-tauri/src/tray.rs | 115 ++++++ src-tauri/src/utils.rs | 436 +-------------------- src/components/icons/CancelIcon.tsx | 37 ++ src/components/icons/MicrophoneIcon.tsx | 37 ++ src/components/icons/TranscriptionIcon.tsx | 53 +++ src/components/icons/index.ts | 3 + src/components/settings/HandyShortcut.tsx | 1 - src/components/settings/ShowOverlay.tsx | 5 +- src/lib/types.ts | 5 +- src/overlay/RecordingOverlay.tsx | 32 +- 18 files changed, 620 insertions(+), 501 deletions(-) create mode 100644 src-tauri/src/audio_feedback.rs create mode 100644 src-tauri/src/clipboard.rs create mode 100644 src-tauri/src/overlay.rs create mode 100644 src-tauri/src/tray.rs create mode 100644 src/components/icons/CancelIcon.tsx create mode 100644 src/components/icons/MicrophoneIcon.tsx create mode 100644 src/components/icons/TranscriptionIcon.tsx create mode 100644 src/components/icons/index.ts diff --git a/index.html b/index.html index 57bb847..330a7e4 100644 --- a/index.html +++ b/index.html @@ -1,17 +1,13 @@ - + + + + + handy + - - - - handy - - - -
- - - - \ No newline at end of file + +
+ + + diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 53b2a08..560d2b4 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1,13 +1,10 @@ +use crate::audio_feedback::{play_recording_start_sound, play_recording_stop_sound}; use crate::managers::audio::AudioRecordingManager; use crate::managers::transcription::TranscriptionManager; +use crate::overlay::{show_recording_overlay, show_transcribing_overlay}; use crate::settings::get_settings; +use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils; -use crate::utils::change_tray_icon; -use crate::utils::play_recording_start_sound; -use crate::utils::play_recording_stop_sound; -use crate::utils::show_recording_overlay; -use crate::utils::show_transcribing_overlay; -use crate::utils::TrayIconState; use log::debug; use once_cell::sync::Lazy; use std::collections::HashMap; diff --git a/src-tauri/src/audio_feedback.rs b/src-tauri/src/audio_feedback.rs new file mode 100644 index 0000000..feaa00f --- /dev/null +++ b/src-tauri/src/audio_feedback.rs @@ -0,0 +1,106 @@ +use crate::settings; +use cpal::traits::{DeviceTrait, HostTrait}; +use rodio::OutputStreamBuilder; +use std::fs::File; +use std::io::BufReader; +use std::thread; +use tauri::{AppHandle, Manager}; + +/// Plays an audio resource from the resources directory. +/// Checks if audio feedback is enabled in settings before playing. +pub fn play_sound(app: &AppHandle, resource_path: &str) { + // Check if audio feedback is enabled + let settings = settings::get_settings(app); + if !settings.audio_feedback { + return; + } + + let app_handle = app.clone(); + let resource_path = resource_path.to_string(); + + // Spawn a new thread to play the audio without blocking the main thread + thread::spawn(move || { + // Get the path to the audio file in resources + let audio_path = match app_handle + .path() + .resolve(&resource_path, tauri::path::BaseDirectory::Resource) + { + Ok(path) => path.to_path_buf(), + Err(e) => { + eprintln!( + "Failed to resolve audio file path '{}': {}", + resource_path, e + ); + return; + } + }; + + // 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, selected_device) { + eprintln!("Failed to play sound '{}': {}", resource_path, e); + } + }); +} + +/// Convenience function to play the recording start sound +pub fn play_recording_start_sound(app: &AppHandle) { + play_sound(app, "resources/rec_start.wav"); +} + +/// Convenience function to play the recording stop sound +pub fn play_recording_stop_sound(app: &AppHandle) { + play_sound(app, "resources/rec_stop.wav"); +} + +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 sink = rodio::play(mixer, buf_reader)?; + sink.sleep_until_end(); + + Ok(()) +} diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs new file mode 100644 index 0000000..c928351 --- /dev/null +++ b/src-tauri/src/clipboard.rs @@ -0,0 +1,64 @@ +use enigo::Enigo; +use enigo::Key; +use enigo::Keyboard; +use enigo::Settings; +use tauri::AppHandle; +use tauri_plugin_clipboard_manager::ClipboardExt; + +/// Sends a paste command (Cmd+V or Ctrl+V) using platform-specific virtual key codes. +/// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). +fn send_paste() -> Result<(), String> { + // Platform-specific key definitions + #[cfg(target_os = "macos")] + let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); + #[cfg(target_os = "windows")] + let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V + #[cfg(target_os = "linux")] + let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); + + let mut enigo = Enigo::new(&Settings::default()) + .map_err(|e| format!("Failed to initialize Enigo: {}", e))?; + + // Press modifier + V + enigo + .key(modifier_key, enigo::Direction::Press) + .map_err(|e| format!("Failed to press modifier key: {}", e))?; + enigo + .key(v_key_code, enigo::Direction::Press) + .map_err(|e| format!("Failed to press V key: {}", e))?; + + // Release V + modifier (reverse order) + enigo + .key(v_key_code, enigo::Direction::Release) + .map_err(|e| format!("Failed to release V key: {}", e))?; + enigo + .key(modifier_key, enigo::Direction::Release) + .map_err(|e| format!("Failed to release modifier key: {}", e))?; + + Ok(()) +} + +pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { + let clipboard = app_handle.clipboard(); + + // get the current clipboard content + let clipboard_content = clipboard.read_text().unwrap_or_default(); + + clipboard + .write_text(&text) + .map_err(|e| format!("Failed to write to clipboard: {}", e))?; + + // small delay to ensure the clipboard content has been written to + std::thread::sleep(std::time::Duration::from_millis(50)); + + send_paste()?; + + std::thread::sleep(std::time::Duration::from_millis(50)); + + // restore the clipboard + clipboard + .write_text(&clipboard_content) + .map_err(|e| format!("Failed to restore clipboard: {}", e))?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6117d3e..e220fac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,13 @@ mod actions; +mod audio_feedback; pub mod audio_toolkit; +mod clipboard; mod commands; mod managers; +mod overlay; mod settings; mod shortcut; +mod tray; mod utils; use managers::audio::AudioRecordingManager; diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs new file mode 100644 index 0000000..f875a72 --- /dev/null +++ b/src-tauri/src/overlay.rs @@ -0,0 +1,135 @@ +use crate::settings; +use crate::settings::OverlayPosition; +use log::debug; +use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder}; + +const OVERLAY_WIDTH: f64 = 172.0; +const OVERLAY_HEIGHT: f64 = 36.0; +const OVERLAY_OFFSET: f64 = 46.0; + +fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> { + if let Ok(monitors) = app_handle.primary_monitor() { + if let Some(monitor) = monitors { + let work_area = monitor.work_area(); + let scale = monitor.scale_factor(); + let work_area_width = work_area.size.width as f64 / scale; + let work_area_height = work_area.size.height as f64 / scale; + let work_area_x = work_area.position.x as f64 / scale; + let work_area_y = work_area.position.y as f64 / scale; + + let settings = settings::get_settings(app_handle); + + let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; + let y = match settings.overlay_position { + OverlayPosition::Top => work_area_y + OVERLAY_OFFSET, + OverlayPosition::Bottom | OverlayPosition::None => { + work_area_y + work_area_height - OVERLAY_OFFSET + } + }; + + return Some((x, y)); + } + } + None +} + +/// Creates the recording overlay window and keeps it hidden by default +pub fn create_recording_overlay(app_handle: &AppHandle) { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + match WebviewWindowBuilder::new( + app_handle, + "recording_overlay", + tauri::WebviewUrl::App("src/overlay/index.html".into()), + ) + .title("Recording") + .position(x, y) + .resizable(false) + .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) + .shadow(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .accept_first_mouse(true) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .transparent(true) + .focused(false) + .visible(false) + .build() + { + Ok(_window) => { + debug!("Recording overlay window created successfully (hidden)"); + } + Err(e) => { + debug!("Failed to create recording overlay window: {}", e); + } + } + } +} + +/// Shows the recording overlay window with fade-in animation +pub fn show_recording_overlay(app_handle: &AppHandle) { + // Check if overlay should be shown based on position setting + let settings = settings::get_settings(app_handle); + if settings.overlay_position == OverlayPosition::None { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to trigger fade-in animation with recording state + let _ = overlay_window.emit("show-overlay", "recording"); + } +} + +/// Shows the transcribing overlay window +pub fn show_transcribing_overlay(app_handle: &AppHandle) { + // Check if overlay should be shown based on position setting + let settings = settings::get_settings(app_handle); + if settings.overlay_position == OverlayPosition::None { + return; + } + + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.show(); + // Emit event to switch to transcribing state + let _ = overlay_window.emit("show-overlay", "transcribing"); + } +} + +/// Updates the overlay window position based on current settings +pub fn update_overlay_position(app_handle: &AppHandle) { + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + if let Some((x, y)) = calculate_overlay_position(app_handle) { + let _ = overlay_window + .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); + } + } +} + +/// Hides the recording overlay window with fade-out animation +pub fn hide_recording_overlay(app_handle: &AppHandle) { + // Always hide the overlay regardless of settings - if setting was changed while recording, + // we still want to hide it properly + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + // Emit event to trigger fade-out animation + let _ = overlay_window.emit("hide-overlay", ()); + // Hide the window after a short delay to allow animation to complete + let window_clone = overlay_window.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + let _ = window_clone.hide(); + }); + } +} + +pub fn emit_levels(app_handle: &AppHandle, levels: &Vec) { + // emit levels to main app + let _ = app_handle.emit("mic-level", levels); + + // also emit to the recording overlay if it's open + if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { + let _ = overlay_window.emit("mic-level", levels); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 983af26..99d1520 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -12,6 +12,14 @@ pub struct ShortcutBinding { pub current_binding: String, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OverlayPosition { + None, + Top, + Bottom, +} + /* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppSettings { @@ -31,55 +39,46 @@ pub struct AppSettings { #[serde(default = "default_selected_language")] pub selected_language: String, #[serde(default = "default_overlay_position")] - pub overlay_position: String, + pub overlay_position: OverlayPosition, #[serde(default = "default_debug_mode")] pub debug_mode: bool, } fn default_model() -> String { - // Default to empty string if no models are available yet - // The UI will handle prompting for model download "".to_string() } fn default_always_on_microphone() -> bool { - // Default to false for better user experience - // True would be the old behavior (always-on for low latency) false } fn default_translate_to_english() -> bool { - // Default to false - users need to opt-in to translation false } fn default_selected_language() -> String { - // Default to auto-detection for backward compatibility "auto".to_string() } -fn default_overlay_position() -> String { - // Default to "bottom" - less intrusive position - "bottom".to_string() +fn default_overlay_position() -> OverlayPosition { + OverlayPosition::Bottom } fn default_debug_mode() -> bool { - // Default to false - debug mode should be opt-in false } pub const SETTINGS_STORE_PATH: &str = "settings_store.json"; pub fn get_default_settings() -> AppSettings { - // Set platform-specific default keyboard shortcuts #[cfg(target_os = "windows")] let default_shortcut = "ctrl+space"; #[cfg(target_os = "macos")] - let default_shortcut = "alt+space"; // Alt key on macOS (Option key) + let default_shortcut = "alt+space"; #[cfg(target_os = "linux")] let default_shortcut = "ctrl+space"; #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - let default_shortcut = "alt+space"; // Fallback for other platforms + let default_shortcut = "alt+space"; let mut bindings = HashMap::new(); bindings.insert( @@ -93,17 +92,6 @@ pub fn get_default_settings() -> AppSettings { }, ); - // bindings.insert( - // "test".to_string(), - // ShortcutBinding { - // id: "test".to_string(), - // name: "Test".to_string(), - // description: "This is a test binding.".to_string(), - // default_binding: "ctrl+d".to_string(), - // current_binding: "ctrl+d".to_string(), - // }, - // ); - AppSettings { bindings, push_to_talk: true, @@ -114,7 +102,7 @@ pub fn get_default_settings() -> AppSettings { selected_output_device: None, translate_to_english: false, selected_language: "auto".to_string(), - overlay_position: "bottom".to_string(), + overlay_position: OverlayPosition::Bottom, debug_mode: false, } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 5017c13..189f33d 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -5,7 +5,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; use crate::settings::ShortcutBinding; -use crate::settings::{self, get_settings}; +use crate::settings::{self, get_settings, OverlayPosition}; use crate::ManagedToggleState; pub fn init_shortcuts(app: &App) { @@ -137,7 +137,16 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res #[tauri::command] pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> { let mut settings = settings::get_settings(&app); - settings.overlay_position = position; + let parsed = match position.as_str() { + "none" => OverlayPosition::None, + "top" => OverlayPosition::Top, + "bottom" => OverlayPosition::Bottom, + other => { + eprintln!("Invalid overlay position '{}', defaulting to bottom", other); + OverlayPosition::Bottom + } + }; + settings.overlay_position = parsed; settings::write_settings(&app, settings); // Update overlay position without recreating window diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..dcd7312 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,115 @@ +use tauri::image::Image; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::TrayIcon; +use tauri::{AppHandle, Manager, Theme}; + +#[derive(Clone, Debug, PartialEq)] +pub enum TrayIconState { + Idle, + Recording, + Transcribing, +} + +/// Gets the current system theme, defaulting to Dark if unavailable +fn get_current_theme(app: &AppHandle) -> Theme { + if let Some(main_window) = app.get_webview_window("main") { + main_window.theme().unwrap_or(Theme::Dark) + } else { + Theme::Dark + } +} + +pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { + let tray = app.state::(); + let theme = get_current_theme(app); + + let icon_path = match (theme, &icon) { + // Dark theme uses regular icons (lighter colored for visibility) + (Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", + (Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png", + (Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png", + // Light theme uses dark icons (darker colored for visibility) + (Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png", + (Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png", + (Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png", + // Fallback for any other theme variants + (_, TrayIconState::Idle) => "resources/tray_idle.png", + (_, TrayIconState::Recording) => "resources/tray_recording.png", + (_, TrayIconState::Transcribing) => "resources/tray_transcribing.png", + }; + + let _ = tray.set_icon(Some( + Image::from_path( + app.path() + .resolve(icon_path, tauri::path::BaseDirectory::Resource) + .expect("failed to resolve"), + ) + .expect("failed to set icon"), + )); + + // Update menu based on state + update_tray_menu(app, &icon); +} + +pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { + // Platform-specific accelerators + #[cfg(target_os = "macos")] + let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); + #[cfg(not(target_os = "macos"))] + let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q")); + + // Create common menu items + let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); + let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) + .expect("failed to create version item"); + let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) + .expect("failed to create settings item"); + let check_updates_i = MenuItem::with_id( + app, + "check_updates", + "Check for Updates...", + true, + None::<&str>, + ) + .expect("failed to create check updates item"); + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) + .expect("failed to create quit item"); + let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); + + let menu = match state { + TrayIconState::Recording | TrayIconState::Transcribing => { + let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) + .expect("failed to create cancel item"); + Menu::with_items( + app, + &[ + &version_i, + &separator(), + &cancel_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu") + } + TrayIconState::Idle => Menu::with_items( + app, + &[ + &version_i, + &separator(), + &settings_i, + &check_updates_i, + &separator(), + &quit_i, + ], + ) + .expect("failed to create menu"), + }; + + let tray = app.state::(); + let _ = tray.set_menu(Some(menu)); + let _ = tray.set_icon_as_template(true); +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index e22d438..4f1d177 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,136 +1,18 @@ -use crate::settings; +use crate::actions::ACTION_MAP; +use crate::managers::audio::AudioRecordingManager; +use crate::ManagedToggleState; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; -use enigo::Enigo; -use enigo::Key; -use enigo::Keyboard; -use enigo::Settings; - -use cpal::traits::{DeviceTrait, HostTrait}; -use log::debug; -use rodio::OutputStreamBuilder; -use std::fs::File; -use std::io::BufReader; -use std::thread; -use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; -use tauri::tray::TrayIcon; -use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindowBuilder}; -use tauri_plugin_clipboard_manager::ClipboardExt; - -/// Sends a paste command (Cmd+V or Ctrl+V) using platform-specific virtual key codes. -/// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK). -fn send_paste() -> Result<(), String> { - // Platform-specific key definitions - #[cfg(target_os = "macos")] - let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); - #[cfg(target_os = "windows")] - let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V - #[cfg(target_os = "linux")] - let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); - - let mut enigo = Enigo::new(&Settings::default()) - .map_err(|e| format!("Failed to initialize Enigo: {}", e))?; - - // Press modifier + V - enigo - .key(modifier_key, enigo::Direction::Press) - .map_err(|e| format!("Failed to press modifier key: {}", e))?; - enigo - .key(v_key_code, enigo::Direction::Press) - .map_err(|e| format!("Failed to press V key: {}", e))?; - - // Release V + modifier (reverse order) - enigo - .key(v_key_code, enigo::Direction::Release) - .map_err(|e| format!("Failed to release V key: {}", e))?; - enigo - .key(modifier_key, enigo::Direction::Release) - .map_err(|e| format!("Failed to release modifier key: {}", e))?; - - Ok(()) -} - -pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { - let clipboard = app_handle.clipboard(); - - // get the current clipboard content - let clipboard_content = clipboard.read_text().unwrap_or_default(); - - clipboard - .write_text(&text) - .map_err(|e| format!("Failed to write to clipboard: {}", e))?; - - // small delay to ensure the clipboard content has been written to - std::thread::sleep(std::time::Duration::from_millis(50)); - - send_paste()?; - - std::thread::sleep(std::time::Duration::from_millis(50)); - - // restore the clipboard - clipboard - .write_text(&clipboard_content) - .map_err(|e| format!("Failed to restore clipboard: {}", e))?; - - Ok(()) -} - -#[derive(Clone, Debug, PartialEq)] -pub enum TrayIconState { - Idle, - Recording, - Transcribing, -} - -/// Gets the current system theme, defaulting to Dark if unavailable -fn get_current_theme(app: &AppHandle) -> Theme { - if let Some(main_window) = app.get_webview_window("main") { - main_window.theme().unwrap_or(Theme::Dark) - } else { - Theme::Dark - } -} - -pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) { - let tray = app.state::(); - let theme = get_current_theme(app); - - let icon_path = match (theme, &icon) { - // Dark theme uses regular icons (lighter colored for visibility) - (Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png", - (Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png", - (Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png", - // Light theme uses dark icons (darker colored for visibility) - (Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png", - (Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png", - (Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png", - // Fallback for any other theme variants - (_, TrayIconState::Idle) => "resources/tray_idle.png", - (_, TrayIconState::Recording) => "resources/tray_recording.png", - (_, TrayIconState::Transcribing) => "resources/tray_transcribing.png", - }; - - let _ = tray.set_icon(Some( - Image::from_path( - app.path() - .resolve(icon_path, tauri::path::BaseDirectory::Resource) - .expect("failed to resolve"), - ) - .expect("failed to set icon"), - )); - - // Update menu based on state - update_tray_menu(app, &icon); -} +// Re-export all utility modules for easy access +pub use crate::audio_feedback::*; +pub use crate::clipboard::*; +pub use crate::overlay::*; +pub use crate::tray::*; /// Centralized cancellation function that can be called from anywhere in the app. /// Handles cancelling both recording and transcription operations and updates UI state. pub fn cancel_current_operation(app: &AppHandle) { - use crate::actions::ACTION_MAP; - use crate::managers::audio::AudioRecordingManager; - use crate::ManagedToggleState; - use std::sync::Arc; - println!("Initiating operation cancellation..."); // First, reset all shortcut toggle states and call stop actions @@ -167,303 +49,7 @@ pub fn cancel_current_operation(app: &AppHandle) { audio_manager.cancel_recording(); // Update tray icon and menu to idle state - change_tray_icon(app, TrayIconState::Idle); + change_tray_icon(app, crate::tray::TrayIconState::Idle); println!("Operation cancellation completed - returned to idle state"); } - -pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState) { - // Platform-specific accelerators - #[cfg(target_os = "macos")] - let (settings_accelerator, quit_accelerator) = (Some("Cmd+,"), Some("Cmd+Q")); - #[cfg(not(target_os = "macos"))] - let (settings_accelerator, quit_accelerator) = (Some("Ctrl+,"), Some("Ctrl+Q")); - - // Create common menu items - let version_label = format!("Handy v{}", env!("CARGO_PKG_VERSION")); - let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) - .expect("failed to create version item"); - let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, settings_accelerator) - .expect("failed to create settings item"); - let check_updates_i = MenuItem::with_id( - app, - "check_updates", - "Check for Updates...", - true, - None::<&str>, - ) - .expect("failed to create check updates item"); - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, quit_accelerator) - .expect("failed to create quit item"); - let separator = || PredefinedMenuItem::separator(app).expect("failed to create separator"); - - let menu = match state { - TrayIconState::Recording | TrayIconState::Transcribing => { - let cancel_i = MenuItem::with_id(app, "cancel", "Cancel", true, None::<&str>) - .expect("failed to create cancel item"); - Menu::with_items( - app, - &[ - &version_i, - &separator(), - &cancel_i, - &separator(), - &settings_i, - &check_updates_i, - &separator(), - &quit_i, - ], - ) - .expect("failed to create menu") - } - TrayIconState::Idle => Menu::with_items( - app, - &[ - &version_i, - &separator(), - &settings_i, - &check_updates_i, - &separator(), - &quit_i, - ], - ) - .expect("failed to create menu"), - }; - - let tray = app.state::(); - let _ = tray.set_menu(Some(menu)); - let _ = tray.set_icon_as_template(true); -} - -/// Plays an audio resource from the resources directory. -/// Checks if audio feedback is enabled in settings before playing. -pub fn play_sound(app: &AppHandle, resource_path: &str) { - // Check if audio feedback is enabled - let settings = settings::get_settings(app); - if !settings.audio_feedback { - return; - } - - let app_handle = app.clone(); - let resource_path = resource_path.to_string(); - - // Spawn a new thread to play the audio without blocking the main thread - thread::spawn(move || { - // Get the path to the audio file in resources - let audio_path = match app_handle - .path() - .resolve(&resource_path, tauri::path::BaseDirectory::Resource) - { - Ok(path) => path, - Err(e) => { - eprintln!( - "Failed to resolve audio file path '{}': {}", - resource_path, e - ); - return; - } - }; - - // 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, selected_device) { - eprintln!("Failed to play sound '{}': {}", resource_path, e); - } - }); -} - -/// Convenience function to play the recording start sound -pub fn play_recording_start_sound(app: &AppHandle) { - play_sound(app, "resources/rec_start.wav"); -} - -/// Convenience function to play the recording stop sound -pub fn play_recording_stop_sound(app: &AppHandle) { - play_sound(app, "resources/rec_stop.wav"); -} - -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 sink = rodio::play(mixer, buf_reader)?; - sink.sleep_until_end(); - - Ok(()) -} - -/* ──────────────────────────────────────────────────────────────── */ -/* OVERLAY MANAGEMENT */ -/* ──────────────────────────────────────────────────────────────── */ - -const OVERLAY_WIDTH: f64 = 172.0; -const OVERLAY_HEIGHT: f64 = 36.0; -const OVERLAY_OFFSET: f64 = 46.0; - -fn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> { - if let Ok(monitors) = app_handle.primary_monitor() { - if let Some(monitor) = monitors { - let work_area = monitor.work_area(); - let scale = monitor.scale_factor(); - let work_area_width = work_area.size.width as f64 / scale; - let work_area_height = work_area.size.height as f64 / scale; - let work_area_x = work_area.position.x as f64 / scale; - let work_area_y = work_area.position.y as f64 / scale; - - let settings = settings::get_settings(app_handle); - let overlay_position = settings.overlay_position.as_str(); - - let x = work_area_x + (work_area_width - OVERLAY_WIDTH) / 2.0; - let y = match overlay_position { - "top" => work_area_y + OVERLAY_OFFSET, - "bottom" => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, - _ => work_area_y + work_area_height - OVERLAY_HEIGHT - OVERLAY_OFFSET, - }; - - return Some((x, y)); - } - } - None -} - -/// Creates the recording overlay window and keeps it hidden by default -pub fn create_recording_overlay(app_handle: &AppHandle) { - if let Some((x, y)) = calculate_overlay_position(app_handle) { - match WebviewWindowBuilder::new( - app_handle, - "recording_overlay", - tauri::WebviewUrl::App("src/overlay/index.html".into()), - ) - .title("Recording") - .position(x, y) - .resizable(false) - .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT) - .shadow(false) - .maximizable(false) - .minimizable(false) - .closable(false) - .accept_first_mouse(true) - .decorations(false) - .always_on_top(true) - .skip_taskbar(true) - .transparent(true) - .focused(false) - .visible(false) - .build() - { - Ok(_window) => { - debug!("Recording overlay window created successfully (hidden)"); - } - Err(e) => { - debug!("Failed to create recording overlay window: {}", e); - } - } - } -} - -/// Shows the recording overlay window with fade-in animation -pub fn show_recording_overlay(app_handle: &AppHandle) { - // Check if overlay should be shown based on position setting - let settings = settings::get_settings(app_handle); - if settings.overlay_position == "none" { - return; - } - - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.show(); - // Emit event to trigger fade-in animation with recording state - let _ = overlay_window.emit("show-overlay", "recording"); - } -} - -/// Shows the transcribing overlay window -pub fn show_transcribing_overlay(app_handle: &AppHandle) { - // Check if overlay should be shown based on position setting - let settings = settings::get_settings(app_handle); - if settings.overlay_position == "none" { - return; - } - - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.show(); - // Emit event to switch to transcribing state - let _ = overlay_window.emit("show-overlay", "transcribing"); - } -} - -/// Updates the overlay window position based on current settings -pub fn update_overlay_position(app_handle: &AppHandle) { - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - if let Some((x, y)) = calculate_overlay_position(app_handle) { - let _ = overlay_window.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y })); - } - } -} - -/// Hides the recording overlay window with fade-out animation -pub fn hide_recording_overlay(app_handle: &AppHandle) { - // Always hide the overlay regardless of settings - if setting was changed while recording, - // we still want to hide it properly - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - // Emit event to trigger fade-out animation - let _ = overlay_window.emit("hide-overlay", ()); - // Hide the window after a short delay to allow animation to complete - let window_clone = overlay_window.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); - let _ = window_clone.hide(); - }); - } -} - -pub fn emit_levels(app_handle: &AppHandle, levels: &Vec) { - // emit levels to main app - let _ = app_handle.emit("mic-level", levels); - - // also emit to the recording overlay if it's open - if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - let _ = overlay_window.emit("mic-level", levels); - } -} diff --git a/src/components/icons/CancelIcon.tsx b/src/components/icons/CancelIcon.tsx new file mode 100644 index 0000000..f468d62 --- /dev/null +++ b/src/components/icons/CancelIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +interface CancelIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const CancelIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + + + + ); +}; + +export default CancelIcon; diff --git a/src/components/icons/MicrophoneIcon.tsx b/src/components/icons/MicrophoneIcon.tsx new file mode 100644 index 0000000..0bb9e0d --- /dev/null +++ b/src/components/icons/MicrophoneIcon.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +interface MicrophoneIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const MicrophoneIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + ); +}; + +export default MicrophoneIcon; diff --git a/src/components/icons/TranscriptionIcon.tsx b/src/components/icons/TranscriptionIcon.tsx new file mode 100644 index 0000000..08fa0ac --- /dev/null +++ b/src/components/icons/TranscriptionIcon.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +interface TranscriptionIconProps { + width?: number; + height?: number; + color?: string; + className?: string; +} + +const TranscriptionIcon: React.FC = ({ + width = 24, + height = 24, + color = "#FAA2CA", + className = "", +}) => { + return ( + + + + + + + + + ); +}; + +export default TranscriptionIcon; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts new file mode 100644 index 0000000..ee5fec9 --- /dev/null +++ b/src/components/icons/index.ts @@ -0,0 +1,3 @@ +export { default as MicrophoneIcon } from './MicrophoneIcon'; +export { default as TranscriptionIcon } from './TranscriptionIcon'; +export { default as CancelIcon } from './CancelIcon'; diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 2501ab0..7d9e820 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useRef } from "react"; -import { BindingResponseSchema, ShortcutBindingsMap } from "../../lib/types"; import { type } from "@tauri-apps/plugin-os"; import { getKeyName, diff --git a/src/components/settings/ShowOverlay.tsx b/src/components/settings/ShowOverlay.tsx index 9812abf..3810852 100644 --- a/src/components/settings/ShowOverlay.tsx +++ b/src/components/settings/ShowOverlay.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; +import type { OverlayPosition } from "../../lib/types"; interface ShowOverlayProps { descriptionMode?: "inline" | "tooltip"; @@ -20,7 +21,7 @@ export const ShowOverlay: React.FC = ({ }) => { const { getSetting, updateSetting, isUpdating } = useSettings(); - const selectedPosition = getSetting("overlay_position") || "bottom"; + const selectedPosition = (getSetting("overlay_position") || "bottom") as OverlayPosition; return ( = ({ updateSetting("overlay_position", value)} + onSelect={(value) => updateSetting("overlay_position", value as OverlayPosition)} disabled={isUpdating("overlay_position")} /> diff --git a/src/lib/types.ts b/src/lib/types.ts index 4ecd42e..dfec7b3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,9 @@ export const AudioDeviceSchema = z.object({ is_default: z.boolean(), }); +export const OverlayPositionSchema = z.enum(["none", "top", "bottom"]); +export type OverlayPosition = z.infer; + export const SettingsSchema = z.object({ bindings: ShortcutBindingsMapSchema, push_to_talk: z.boolean(), @@ -29,7 +32,7 @@ export const SettingsSchema = z.object({ selected_output_device: z.string().nullable().optional(), translate_to_english: z.boolean(), selected_language: z.string(), - overlay_position: z.string(), + overlay_position: OverlayPositionSchema, debug_mode: z.boolean(), }); diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 39faefa..16fc4b9 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -1,6 +1,11 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import React, { useEffect, useRef, useState } from "react"; +import { + MicrophoneIcon, + TranscriptionIcon, + CancelIcon, +} from "../components/icons"; import "./RecordingOverlay.css"; type OverlayState = "recording" | "transcribing"; @@ -53,34 +58,15 @@ const RecordingOverlay: React.FC = () => { const getIcon = () => { if (state === "recording") { - return ( - - - - - - ); + return ; } else { - return ( - - - - - - - - - - ); + return ; } }; - return (
-
- {getIcon()} -
+
{getIcon()}
{state === "recording" && ( @@ -111,7 +97,7 @@ const RecordingOverlay: React.FC = () => { invoke("cancel_operation"); }} > - +
)}