some cleanup

This commit is contained in:
CJ Pais 2025-08-07 16:53:07 -07:00
parent 091b392aa6
commit c0fc1ba817
18 changed files with 620 additions and 501 deletions

View file

@ -1,17 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>handy</title>
</head>
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0" />
<title>handy</title>
</head>
<body>
<div id="root"></div>
<script type="module"
src="/src/main.tsx"></script>
</body>
</html>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -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;

View file

@ -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<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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(())
}

View file

@ -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(())
}

View file

@ -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;

135
src-tauri/src/overlay.rs Normal file
View file

@ -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<f32>) {
// 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);
}
}

View file

@ -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,
}
}

View file

@ -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

115
src-tauri/src/tray.rs Normal file
View file

@ -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::<TrayIcon>();
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::<TrayIcon>();
let _ = tray.set_menu(Some(menu));
let _ = tray.set_icon_as_template(true);
}

View file

@ -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::<TrayIcon>();
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::<TrayIcon>();
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<String>,
) -> Result<(), Box<dyn std::error::Error>> {
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<f32>) {
// 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);
}
}

View file

@ -0,0 +1,37 @@
import React from "react";
interface CancelIconProps {
width?: number;
height?: number;
color?: string;
className?: string;
}
const CancelIcon: React.FC<CancelIconProps> = ({
width = 24,
height = 24,
color = "#FAA2CA",
className = "",
}) => {
return (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g fill={color}>
<path d="m14.293 8.29297c.3905-.39052 1.0235-.39052 1.414 0s.3905 1.02354 0 1.41406l-5.99998 5.99997c-.39053.3906-1.02354.3906-1.41407 0-.39052-.3905-.39052-1.0235 0-1.414z" />
<path d="m8.29295 8.29297c.39053-.39052 1.02354-.39052 1.41407 0l5.99998 6.00003c.3905.3905.3905 1.0235 0 1.414-.3905.3906-1.0235.3906-1.414 0l-6.00005-5.99997c-.39052-.39052-.39052-1.02354 0-1.41406z" />
<path
d="m20 12c0-4.41828-3.5817-8-8-8-4.41828 0-8 3.58172-8 8 0 4.4183 3.58172 8 8 8 4.4183 0 8-3.5817 8-8zm2 0c0 5.5228-4.4772 10-10 10-5.52285 0-10-4.4772-10-10 0-5.52285 4.47715-10 10-10 5.5228 0 10 4.47715 10 10z"
opacity=".4"
/>
</g>
</svg>
);
};
export default CancelIcon;

View file

@ -0,0 +1,37 @@
import React from "react";
interface MicrophoneIconProps {
width?: number;
height?: number;
color?: string;
className?: string;
}
const MicrophoneIcon: React.FC<MicrophoneIconProps> = ({
width = 24,
height = 24,
color = "#FAA2CA",
className = "",
}) => {
return (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M17.1562 10.2C17.1562 8.83247 16.613 7.52099 15.646 6.554C14.679 5.58702 13.3675 5.04375 12 5.04375C10.6325 5.04375 9.32099 5.58702 8.354 6.554C7.38702 7.52099 6.84375 8.83247 6.84375 10.2C6.84375 11.1586 6.99743 11.7554 7.18689 12.1629C7.37547 12.5685 7.62633 12.848 7.94019 13.1553C8.23392 13.443 8.67357 13.8299 8.99524 14.3488C9.34195 14.9081 9.54381 15.5869 9.54382 16.4999C9.54382 17.1513 9.80245 17.7762 10.2631 18.2369C10.7237 18.6975 11.3486 18.9561 12 18.9561C12.7207 18.9561 13.268 18.6453 13.7494 18.0625C14.0462 17.7033 14.5781 17.6526 14.9374 17.9494C15.2967 18.2461 15.3473 18.7781 15.0505 19.1374C14.3214 20.0201 13.3283 20.6436 12 20.6436C10.901 20.6436 9.84705 20.2071 9.06995 19.43C8.29287 18.6529 7.85632 17.5989 7.85632 16.4999C7.85631 15.8572 7.72032 15.4953 7.56079 15.238C7.37622 14.9402 7.14072 14.7342 6.75952 14.3609C6.39843 14.0073 5.97449 13.5575 5.65686 12.8744C5.34008 12.1931 5.15625 11.3413 5.15625 10.2C5.15625 8.38492 5.87744 6.64434 7.16089 5.36089C8.44434 4.07743 10.1849 3.35625 12 3.35625C13.8151 3.35625 15.5557 4.07743 16.8391 5.36089C18.1226 6.64434 18.8438 8.38492 18.8438 10.2C18.8437 10.666 18.466 11.0437 18 11.0437C17.534 11.0437 17.1563 10.666 17.1562 10.2Z"
fill={color}
/>
<path
d="M14.1562 10.2C14.1562 9.62812 13.9289 9.07984 13.5245 8.67546C13.1454 8.29636 12.6399 8.07275 12.1069 8.04631L12 8.04375C11.4281 8.04375 10.8798 8.27109 10.4755 8.67546C10.0711 9.07984 9.84375 9.62812 9.84375 10.2C9.84375 10.666 9.46599 11.0437 9 11.0437C8.53401 11.0437 8.15625 10.666 8.15625 10.2C8.15625 9.18057 8.56114 8.20282 9.28198 7.48198C10.0028 6.76114 10.9806 6.35625 12 6.35625L12.1904 6.36101C13.1405 6.4081 14.0422 6.80615 14.718 7.48198C15.4389 8.20282 15.8438 9.18057 15.8438 10.2C15.8438 11.4145 15.2126 12.223 14.7751 12.8063C14.3126 13.423 14.0438 13.8146 14.0438 14.4001C14.0438 14.4785 14.0697 14.555 14.1174 14.6172C14.1652 14.6795 14.2321 14.7244 14.3079 14.7447C14.3836 14.7649 14.4639 14.7597 14.5364 14.7297C14.6088 14.6996 14.6693 14.6464 14.7085 14.5784C14.9413 14.1748 15.4573 14.0363 15.861 14.269C16.2646 14.5018 16.4032 15.0178 16.1704 15.4214C15.9456 15.8113 15.5984 16.1163 15.1827 16.2886C14.767 16.4609 14.3057 16.4911 13.871 16.3747C13.4363 16.2582 13.0521 16.0015 12.7782 15.6445C12.5043 15.2874 12.3562 14.8497 12.3563 14.3997C12.3564 13.1854 12.9875 12.377 13.4249 11.7937C13.8875 11.177 14.1562 10.7855 14.1562 10.2Z"
fill={color}
/>
</svg>
);
};
export default MicrophoneIcon;

View file

@ -0,0 +1,53 @@
import React from "react";
interface TranscriptionIconProps {
width?: number;
height?: number;
color?: string;
className?: string;
}
const TranscriptionIcon: React.FC<TranscriptionIconProps> = ({
width = 24,
height = 24,
color = "#FAA2CA",
className = "",
}) => {
return (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M8.99996 11.85C9.74164 11.85 10.4666 12.07 11.0833 12.4821C11.7 12.8941 12.1809 13.4796 12.4647 14.1648C12.7485 14.85 12.8225 15.6043 12.6778 16.3317C12.5331 17.0591 12.1761 17.7273 11.6517 18.2517C11.1273 18.7762 10.459 19.1331 9.73165 19.2779C9.00422 19.4226 8.25 19.3486 7.56478 19.0647C6.87958 18.7809 6.29409 18.3 5.88204 17.6834C5.46998 17.0667 5.24996 16.3417 5.24996 15.6V15.0954C5.24996 14.6812 5.58575 14.3454 5.99996 14.3454C6.41417 14.3454 6.74996 14.6812 6.74996 15.0954V15.6C6.74996 16.0449 6.88183 16.4799 7.12899 16.8499C7.37622 17.2199 7.72786 17.5083 8.139 17.6786C8.55013 17.8489 9.0026 17.8936 9.43905 17.8068C9.87545 17.72 10.2761 17.5055 10.5908 17.1908C10.9054 16.8762 11.1199 16.4755 11.2067 16.0391C11.2936 15.6026 11.2489 15.1502 11.0786 14.739C10.9083 14.3279 10.6198 13.9763 10.2498 13.729C9.87987 13.4819 9.44489 13.35 8.99996 13.35C8.58575 13.35 8.24996 13.0142 8.24996 12.6C8.24996 12.1858 8.58575 11.85 8.99996 11.85Z"
fill={color}
/>
<path
d="M15 11.85C15.4142 11.85 15.75 12.1858 15.75 12.6C15.75 13.0142 15.4142 13.35 15 13.35C14.555 13.35 14.1201 13.4819 13.7501 13.729C13.3801 13.9763 13.0916 14.3279 12.9213 14.739C12.7511 15.1502 12.7064 15.6026 12.7932 16.0391C12.88 16.4755 13.0945 16.8762 13.4091 17.1908C13.7238 17.5055 14.1245 17.72 14.5609 17.8068C14.9973 17.8936 15.4498 17.8489 15.8609 17.6786C16.2721 17.5083 16.6237 17.2199 16.8709 16.8499C17.1181 16.4799 17.25 16.0449 17.25 15.6V15.0954C17.25 14.6812 17.5857 14.3454 18 14.3454C18.4142 14.3454 18.75 14.6812 18.75 15.0954V15.6C18.75 16.3417 18.5299 17.0667 18.1179 17.6834C17.7058 18.3 17.1203 18.7809 16.4351 19.0647C15.7499 19.3486 14.9957 19.4226 14.2683 19.2779C13.5409 19.1331 12.8726 18.7762 12.3482 18.2517C11.8238 17.7273 11.4668 17.0591 11.3221 16.3317C11.1774 15.6043 11.2514 14.85 11.5352 14.1648C11.8191 13.4796 12.2999 12.8941 12.9166 12.4821C13.5333 12.07 14.2583 11.85 15 11.85Z"
fill={color}
/>
<path
d="M11.2498 15.5999V7.8C11.2498 7.20332 11.0129 6.63113 10.591 6.20918C10.169 5.78723 9.59655 5.55 8.99981 5.55C8.40313 5.55004 7.83091 5.78726 7.40899 6.20918C6.98709 6.63113 6.74981 7.2033 6.74981 7.8V8.30464C6.74981 8.62274 6.54921 8.90641 6.2492 9.01216C5.61476 9.23582 5.07998 9.67683 4.73932 10.2569C4.39869 10.837 4.27406 11.5187 4.38775 12.1817C4.5015 12.8448 4.84623 13.4464 5.36078 13.8798C5.87528 14.3132 6.52647 14.5506 7.19915 14.55H7.80011C8.21426 14.5501 8.55011 14.8858 8.55011 15.3C8.55011 15.7142 8.21426 16.0499 7.80011 16.05H7.19989C6.17333 16.0507 5.17951 15.6885 4.39434 15.0272C3.60898 14.3657 3.08297 13.4475 2.90936 12.4355C2.73575 11.4234 2.92586 10.3825 3.44586 9.49702C3.87347 8.76895 4.50162 8.18474 5.24981 7.81026V7.8C5.24981 6.80544 5.64518 5.85153 6.34845 5.14827C7.05166 4.44511 8.00536 4.05004 8.99981 4.05C9.99434 4.05 10.9483 4.44506 11.6515 5.14827C12.3548 5.85153 12.7498 6.80544 12.7498 7.8V15.5999C12.7498 16.0141 12.414 16.3499 11.9998 16.3499C11.5857 16.3498 11.2498 16.0141 11.2498 15.5999Z"
fill={color}
/>
<path
d="M11.2498 7.8C11.2498 6.80544 11.645 5.85153 12.3482 5.14827C13.0515 4.44501 14.0054 4.05 15 4.05C15.9945 4.05 16.9484 4.44501 17.6517 5.14827C18.355 5.85153 18.75 6.80544 18.75 7.8V7.81026C19.4982 8.18475 20.1266 8.76886 20.5543 9.49702C21.0743 10.3825 21.264 11.4234 21.0904 12.4355C20.9168 13.4475 20.3908 14.3657 19.6054 15.0272C18.8202 15.6885 17.8265 16.0504 16.7999 16.0496L16.2 16.05C15.7858 16.05 15.45 15.7142 15.45 15.3C15.45 14.8858 15.7858 14.55 16.2 14.55H16.8006C17.4734 14.5506 18.1248 14.3132 18.6394 13.8798C19.1539 13.4464 19.4983 12.8448 19.612 12.1817C19.7257 11.5187 19.6014 10.837 19.2608 10.2569C18.9201 9.6768 18.3851 9.23581 17.7506 9.01216C17.4506 8.9064 17.25 8.62273 17.25 8.30464V7.8C17.25 7.20327 17.0127 6.63114 16.5908 6.20918C16.1688 5.78723 15.5967 5.55 15 5.55C14.4032 5.55 13.8311 5.78723 13.4091 6.20918C12.9872 6.63114 12.7498 7.20327 12.7498 7.8C12.7498 8.21422 12.4142 8.55 12 8.55C11.5857 8.55 11.2498 8.21422 11.2498 7.8Z"
fill={color}
/>
<path
d="M14.25 8.69993V8.4C14.25 7.98579 14.5857 7.65 15 7.65C15.4142 7.65 15.75 7.98579 15.75 8.4V8.69993C15.75 9.05797 15.8923 9.40147 16.1455 9.65464C16.3986 9.90773 16.7419 10.0501 17.0998 10.0501H17.4001C17.8143 10.0502 18.1501 10.386 18.1501 10.8001C18.15 11.2142 17.8142 11.5501 17.4001 11.5501H17.0998C16.344 11.5501 15.619 11.2496 15.0846 10.7152C14.5501 10.1807 14.25 9.45574 14.25 8.69993Z"
fill={color}
/>
<path
d="M8.25011 8.69993V8.4C8.25011 7.98579 8.58589 7.65 9.00011 7.65C9.41425 7.65008 9.75011 7.98584 9.75011 8.4V8.69993C9.75011 9.4558 9.44962 10.1807 8.91514 10.7152C8.38067 11.2497 7.65575 11.5501 6.89989 11.5501H6.59996C6.18579 11.5501 5.85004 11.2143 5.84996 10.8001C5.84996 10.3859 6.18575 10.0501 6.59996 10.0501H6.89989C7.25793 10.0501 7.60142 9.90782 7.8546 9.65464C8.10777 9.40147 8.25011 9.05797 8.25011 8.69993Z"
fill={color}
/>
</svg>
);
};
export default TranscriptionIcon;

View file

@ -0,0 +1,3 @@
export { default as MicrophoneIcon } from './MicrophoneIcon';
export { default as TranscriptionIcon } from './TranscriptionIcon';
export { default as CancelIcon } from './CancelIcon';

View file

@ -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,

View file

@ -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<ShowOverlayProps> = ({
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const selectedPosition = getSetting("overlay_position") || "bottom";
const selectedPosition = (getSetting("overlay_position") || "bottom") as OverlayPosition;
return (
<SettingContainer
@ -32,7 +33,7 @@ export const ShowOverlay: React.FC<ShowOverlayProps> = ({
<Dropdown
options={overlayOptions}
selectedValue={selectedPosition}
onSelect={(value) => updateSetting("overlay_position", value)}
onSelect={(value) => updateSetting("overlay_position", value as OverlayPosition)}
disabled={isUpdating("overlay_position")}
/>
</SettingContainer>

View file

@ -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<typeof OverlayPositionSchema>;
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(),
});

View file

@ -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 (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.1562 10.2C17.1562 8.83247 16.613 7.52099 15.646 6.554C14.679 5.58702 13.3675 5.04375 12 5.04375C10.6325 5.04375 9.32099 5.58702 8.354 6.554C7.38702 7.52099 6.84375 8.83247 6.84375 10.2C6.84375 11.1586 6.99743 11.7554 7.18689 12.1629C7.37547 12.5685 7.62633 12.848 7.94019 13.1553C8.23392 13.443 8.67357 13.8299 8.99524 14.3488C9.34195 14.9081 9.54381 15.5869 9.54382 16.4999C9.54382 17.1513 9.80245 17.7762 10.2631 18.2369C10.7237 18.6975 11.3486 18.9561 12 18.9561C12.7207 18.9561 13.268 18.6453 13.7494 18.0625C14.0462 17.7033 14.5781 17.6526 14.9374 17.9494C15.2967 18.2461 15.3473 18.7781 15.0505 19.1374C14.3214 20.0201 13.3283 20.6436 12 20.6436C10.901 20.6436 9.84705 20.2071 9.06995 19.43C8.29287 18.6529 7.85632 17.5989 7.85632 16.4999C7.85631 15.8572 7.72032 15.4953 7.56079 15.238C7.37622 14.9402 7.14072 14.7342 6.75952 14.3609C6.39843 14.0073 5.97449 13.5575 5.65686 12.8744C5.34008 12.1931 5.15625 11.3413 5.15625 10.2C5.15625 8.38492 5.87744 6.64434 7.16089 5.36089C8.44434 4.07743 10.1849 3.35625 12 3.35625C13.8151 3.35625 15.5557 4.07743 16.8391 5.36089C18.1226 6.64434 18.8438 8.38492 18.8438 10.2C18.8437 10.666 18.466 11.0437 18 11.0437C17.534 11.0437 17.1563 10.666 17.1562 10.2Z" fill="#FAA2CA"/>
<path d="M14.1562 10.2C14.1562 9.62812 13.9289 9.07984 13.5245 8.67546C13.1454 8.29636 12.6399 8.07275 12.1069 8.04631L12 8.04375C11.4281 8.04375 10.8798 8.27109 10.4755 8.67546C10.0711 9.07984 9.84375 9.62812 9.84375 10.2C9.84375 10.666 9.46599 11.0437 9 11.0437C8.53401 11.0437 8.15625 10.666 8.15625 10.2C8.15625 9.18057 8.56114 8.20282 9.28198 7.48198C10.0028 6.76114 10.9806 6.35625 12 6.35625L12.1904 6.36101C13.1405 6.4081 14.0422 6.80615 14.718 7.48198C15.4389 8.20282 15.8438 9.18057 15.8438 10.2C15.8438 11.4145 15.2126 12.223 14.7751 12.8063C14.3126 13.423 14.0438 13.8146 14.0438 14.4001C14.0438 14.4785 14.0697 14.555 14.1174 14.6172C14.1652 14.6795 14.2321 14.7244 14.3079 14.7447C14.3836 14.7649 14.4639 14.7597 14.5364 14.7297C14.6088 14.6996 14.6693 14.6464 14.7085 14.5784C14.9413 14.1748 15.4573 14.0363 15.861 14.269C16.2646 14.5018 16.4032 15.0178 16.1704 15.4214C15.9456 15.8113 15.5984 16.1163 15.1827 16.2886C14.767 16.4609 14.3057 16.4911 13.871 16.3747C13.4363 16.2582 13.0521 16.0015 12.7782 15.6445C12.5043 15.2874 12.3562 14.8497 12.3563 14.3997C12.3564 13.1854 12.9875 12.377 13.4249 11.7937C13.8875 11.177 14.1562 10.7855 14.1562 10.2Z" fill="#FAA2CA"/>
</svg>
);
return <MicrophoneIcon />;
} else {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.99996 11.85C9.74164 11.85 10.4666 12.07 11.0833 12.4821C11.7 12.8941 12.1809 13.4796 12.4647 14.1648C12.7485 14.85 12.8225 15.6043 12.6778 16.3317C12.5331 17.0591 12.1761 17.7273 11.6517 18.2517C11.1273 18.7762 10.459 19.1331 9.73165 19.2779C9.00422 19.4226 8.25 19.3486 7.56478 19.0647C6.87958 18.7809 6.29409 18.3 5.88204 17.6834C5.46998 17.0667 5.24996 16.3417 5.24996 15.6V15.0954C5.24996 14.6812 5.58575 14.3454 5.99996 14.3454C6.41417 14.3454 6.74996 14.6812 6.74996 15.0954V15.6C6.74996 16.0449 6.88183 16.4799 7.12899 16.8499C7.37622 17.2199 7.72786 17.5083 8.139 17.6786C8.55013 17.8489 9.0026 17.8936 9.43905 17.8068C9.87545 17.72 10.2761 17.5055 10.5908 17.1908C10.9054 16.8762 11.1199 16.4755 11.2067 16.0391C11.2936 15.6026 11.2489 15.1502 11.0786 14.739C10.9083 14.3279 10.6198 13.9763 10.2498 13.729C9.87987 13.4819 9.44489 13.35 8.99996 13.35C8.58575 13.35 8.24996 13.0142 8.24996 12.6C8.24996 12.1858 8.58575 11.85 8.99996 11.85Z" fill="#FAA2CA"/>
<path d="M15 11.85C15.4142 11.85 15.75 12.1858 15.75 12.6C15.75 13.0142 15.4142 13.35 15 13.35C14.555 13.35 14.1201 13.4819 13.7501 13.729C13.3801 13.9763 13.0916 14.3279 12.9213 14.739C12.7511 15.1502 12.7064 15.6026 12.7932 16.0391C12.88 16.4755 13.0945 16.8762 13.4091 17.1908C13.7238 17.5055 14.1245 17.72 14.5609 17.8068C14.9973 17.8936 15.4498 17.8489 15.8609 17.6786C16.2721 17.5083 16.6237 17.2199 16.8709 16.8499C17.1181 16.4799 17.25 16.0449 17.25 15.6V15.0954C17.25 14.6812 17.5857 14.3454 18 14.3454C18.4142 14.3454 18.75 14.6812 18.75 15.0954V15.6C18.75 16.3417 18.5299 17.0667 18.1179 17.6834C17.7058 18.3 17.1203 18.7809 16.4351 19.0647C15.7499 19.3486 14.9957 19.4226 14.2683 19.2779C13.5409 19.1331 12.8726 18.7762 12.3482 18.2517C11.8238 17.7273 11.4668 17.0591 11.3221 16.3317C11.1774 15.6043 11.2514 14.85 11.5352 14.1648C11.8191 13.4796 12.2999 12.8941 12.9166 12.4821C13.5333 12.07 14.2583 11.85 15 11.85Z" fill="#FAA2CA"/>
<path d="M11.2498 15.5999V7.8C11.2498 7.20332 11.0129 6.63113 10.591 6.20918C10.169 5.78723 9.59655 5.55 8.99981 5.55C8.40313 5.55004 7.83091 5.78726 7.40899 6.20918C6.98709 6.63113 6.74981 7.2033 6.74981 7.8V8.30464C6.74981 8.62274 6.54921 8.90641 6.2492 9.01216C5.61476 9.23582 5.07998 9.67683 4.73932 10.2569C4.39869 10.837 4.27406 11.5187 4.38775 12.1817C4.5015 12.8448 4.84623 13.4464 5.36078 13.8798C5.87528 14.3132 6.52647 14.5506 7.19915 14.55H7.80011C8.21426 14.5501 8.55011 14.8858 8.55011 15.3C8.55011 15.7142 8.21426 16.0499 7.80011 16.05H7.19989C6.17333 16.0507 5.17951 15.6885 4.39434 15.0272C3.60898 14.3657 3.08297 13.4475 2.90936 12.4355C2.73575 11.4234 2.92586 10.3825 3.44586 9.49702C3.87347 8.76895 4.50162 8.18474 5.24981 7.81026V7.8C5.24981 6.80544 5.64518 5.85153 6.34845 5.14827C7.05166 4.44511 8.00536 4.05004 8.99981 4.05C9.99434 4.05 10.9483 4.44506 11.6515 5.14827C12.3548 5.85153 12.7498 6.80544 12.7498 7.8V15.5999C12.7498 16.0141 12.414 16.3499 11.9998 16.3499C11.5857 16.3498 11.2498 16.0141 11.2498 15.5999Z" fill="#FAA2CA"/>
<path d="M11.2498 7.8C11.2498 6.80544 11.645 5.85153 12.3482 5.14827C13.0515 4.44501 14.0054 4.05 15 4.05C15.9945 4.05 16.9484 4.44501 17.6517 5.14827C18.355 5.85153 18.75 6.80544 18.75 7.8V7.81026C19.4982 8.18475 20.1266 8.76886 20.5543 9.49702C21.0743 10.3825 21.264 11.4234 21.0904 12.4355C20.9168 13.4475 20.3908 14.3657 19.6054 15.0272C18.8202 15.6885 17.8265 16.0504 16.7999 16.0496L16.2 16.05C15.7858 16.05 15.45 15.7142 15.45 15.3C15.45 14.8858 15.7858 14.55 16.2 14.55H16.8006C17.4734 14.5506 18.1248 14.3132 18.6394 13.8798C19.1539 13.4464 19.4983 12.8448 19.612 12.1817C19.7257 11.5187 19.6014 10.837 19.2608 10.2569C18.9201 9.6768 18.3851 9.23581 17.7506 9.01216C17.4506 8.9064 17.25 8.62273 17.25 8.30464V7.8C17.25 7.20327 17.0127 6.63114 16.5908 6.20918C16.1688 5.78723 15.5967 5.55 15 5.55C14.4032 5.55 13.8311 5.78723 13.4091 6.20918C12.9872 6.63114 12.7498 7.20327 12.7498 7.8C12.7498 8.21422 12.4142 8.55 12 8.55C11.5857 8.55 11.2498 8.21422 11.2498 7.8Z" fill="#FAA2CA"/>
<path d="M14.25 8.69993V8.4C14.25 7.98579 14.5857 7.65 15 7.65C15.4142 7.65 15.75 7.98579 15.75 8.4V8.69993C15.75 9.05797 15.8923 9.40147 16.1455 9.65464C16.3986 9.90773 16.7419 10.0501 17.0998 10.0501H17.4001C17.8143 10.0502 18.1501 10.386 18.1501 10.8001C18.15 11.2142 17.8142 11.5501 17.4001 11.5501H17.0998C16.344 11.5501 15.619 11.2496 15.0846 10.7152C14.5501 10.1807 14.25 9.45574 14.25 8.69993Z" fill="#FAA2CA"/>
<path d="M8.25011 8.69993V8.4C8.25011 7.98579 8.58589 7.65 9.00011 7.65C9.41425 7.65008 9.75011 7.98584 9.75011 8.4V8.69993C9.75011 9.4558 9.44962 10.1807 8.91514 10.7152C8.38067 11.2497 7.65575 11.5501 6.89989 11.5501H6.59996C6.18579 11.5501 5.85004 11.2143 5.84996 10.8001C5.84996 10.3859 6.18575 10.0501 6.59996 10.0501H6.89989C7.25793 10.0501 7.60142 9.90782 7.8546 9.65464C8.10777 9.40147 8.25011 9.05797 8.25011 8.69993Z" fill="#FAA2CA"/>
</svg>
);
return <TranscriptionIcon />;
}
};
return (
<div className={`recording-overlay ${isVisible ? "fade-in" : ""}`}>
<div className="overlay-left">
{getIcon()}
</div>
<div className="overlay-left">{getIcon()}</div>
<div className="overlay-middle">
{state === "recording" && (
@ -111,7 +97,7 @@ const RecordingOverlay: React.FC = () => {
invoke("cancel_operation");
}}
>
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g fill="#faa2ca"><path d="m14.293 8.29297c.3905-.39052 1.0235-.39052 1.414 0s.3905 1.02354 0 1.41406l-5.99998 5.99997c-.39053.3906-1.02354.3906-1.41407 0-.39052-.3905-.39052-1.0235 0-1.414z"/><path d="m8.29295 8.29297c.39053-.39052 1.02354-.39052 1.41407 0l5.99998 6.00003c.3905.3905.3905 1.0235 0 1.414-.3905.3906-1.0235.3906-1.414 0l-6.00005-5.99997c-.39052-.39052-.39052-1.02354 0-1.41406z"/><path d="m20 12c0-4.41828-3.5817-8-8-8-4.41828 0-8 3.58172-8 8 0 4.4183 3.58172 8 8 8 4.4183 0 8-3.5817 8-8zm2 0c0 5.5228-4.4772 10-10 10-5.52285 0-10-4.4772-10-10 0-5.52285 4.47715-10 10-10 5.5228 0 10 4.47715 10 10z" opacity=".4"/></g></svg>
<CancelIcon />
</div>
)}
</div>