commit
e458e5e8d7
30 changed files with 513 additions and 155 deletions
23
src-tauri/Cargo.lock
generated
23
src-tauri/Cargo.lock
generated
|
|
@ -1105,7 +1105,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.0",
|
"redox_users 0.5.0",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2001,6 +2001,7 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hound",
|
"hound",
|
||||||
"log",
|
"log",
|
||||||
|
"natural",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ort-sys",
|
"ort-sys",
|
||||||
"rdev",
|
"rdev",
|
||||||
|
|
@ -2010,6 +2011,7 @@ dependencies = [
|
||||||
"rustfft",
|
"rustfft",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"strsim",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
|
|
@ -2962,6 +2964,15 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "natural"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65e6b44f8ddc659cde3555e0140d3441ad26cb03a1410774af1f9a19097c1867"
|
||||||
|
dependencies = [
|
||||||
|
"rust-stemmers",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndarray"
|
name = "ndarray"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
|
|
@ -4365,6 +4376,16 @@ dependencies = [
|
||||||
"realfft",
|
"realfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rust-stemmers"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.24"
|
version = "0.1.24"
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
rustfft = "6.4.0"
|
rustfft = "6.4.0"
|
||||||
|
strsim = "0.11.0"
|
||||||
|
natural = "0.5.0"
|
||||||
|
|
||||||
[dependencies.ort-sys]
|
[dependencies.ort-sys]
|
||||||
version = "=2.0.0-rc.9"
|
version = "=2.0.0-rc.9"
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ pub fn run() {
|
||||||
shortcut::change_selected_language_setting,
|
shortcut::change_selected_language_setting,
|
||||||
shortcut::change_overlay_position_setting,
|
shortcut::change_overlay_position_setting,
|
||||||
shortcut::change_debug_mode_setting,
|
shortcut::change_debug_mode_setting,
|
||||||
|
shortcut::update_custom_words,
|
||||||
shortcut::suspend_binding,
|
shortcut::suspend_binding,
|
||||||
shortcut::resume_binding,
|
shortcut::resume_binding,
|
||||||
trigger_update_check,
|
trigger_update_check,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use crate::managers::model::ModelManager;
|
use crate::managers::model::ModelManager;
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use natural::phonetics::soundex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use strsim::levenshtein;
|
||||||
use tauri::{App, AppHandle, Emitter, Manager};
|
use tauri::{App, AppHandle, Emitter, Manager};
|
||||||
use whisper_rs::install_whisper_log_trampoline;
|
use whisper_rs::install_whisper_log_trampoline;
|
||||||
use whisper_rs::{
|
use whisper_rs::{
|
||||||
|
|
@ -25,6 +27,114 @@ pub struct TranscriptionManager {
|
||||||
current_model_id: Mutex<Option<String>>,
|
current_model_id: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_custom_words(text: &str, custom_words: &[String]) -> String {
|
||||||
|
if custom_words.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute lowercase versions to avoid repeated allocations
|
||||||
|
let custom_words_lower: Vec<String> = custom_words.iter().map(|w| w.to_lowercase()).collect();
|
||||||
|
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
let mut corrected_words = Vec::new();
|
||||||
|
|
||||||
|
for word in words {
|
||||||
|
let cleaned_word = word
|
||||||
|
.trim_matches(|c: char| !c.is_alphabetic())
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if cleaned_word.is_empty() {
|
||||||
|
corrected_words.push(word.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip extremely long words to avoid performance issues
|
||||||
|
if cleaned_word.len() > 50 {
|
||||||
|
corrected_words.push(word.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best_match: Option<&String> = None;
|
||||||
|
let mut best_score = f64::MAX;
|
||||||
|
|
||||||
|
for (i, custom_word_lower) in custom_words_lower.iter().enumerate() {
|
||||||
|
// Skip if lengths are too different (optimization)
|
||||||
|
let len_diff = (cleaned_word.len() as i32 - custom_word_lower.len() as i32).abs();
|
||||||
|
if len_diff > 5 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Levenshtein distance (normalized by length)
|
||||||
|
let levenshtein_dist = levenshtein(&cleaned_word, custom_word_lower);
|
||||||
|
let max_len = cleaned_word.len().max(custom_word_lower.len()) as f64;
|
||||||
|
let levenshtein_score = if max_len > 0.0 {
|
||||||
|
levenshtein_dist as f64 / max_len
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate phonetic similarity using Soundex
|
||||||
|
let phonetic_match = soundex(&cleaned_word, custom_word_lower);
|
||||||
|
|
||||||
|
// Combine scores: favor phonetic matches, but also consider string similarity
|
||||||
|
let combined_score = if phonetic_match {
|
||||||
|
levenshtein_score * 0.3 // Give significant boost to phonetic matches
|
||||||
|
} else {
|
||||||
|
levenshtein_score
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept if the score is good enough (threshold: 0.4 for good matches)
|
||||||
|
if combined_score < 0.4 && combined_score < best_score {
|
||||||
|
best_match = Some(&custom_words[i]);
|
||||||
|
best_score = combined_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(replacement) = best_match {
|
||||||
|
// Preserve the original case pattern as much as possible
|
||||||
|
let corrected = if word.chars().all(|c| c.is_uppercase()) {
|
||||||
|
replacement.to_uppercase()
|
||||||
|
} else if word.chars().next().map_or(false, |c| c.is_uppercase()) {
|
||||||
|
let mut chars: Vec<char> = replacement.chars().collect();
|
||||||
|
if let Some(first_char) = chars.get_mut(0) {
|
||||||
|
*first_char = first_char.to_uppercase().next().unwrap_or(*first_char);
|
||||||
|
}
|
||||||
|
chars.into_iter().collect()
|
||||||
|
} else {
|
||||||
|
replacement.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve punctuation from original word - optimized version
|
||||||
|
let prefix_end = word.chars().take_while(|c| !c.is_alphabetic()).count();
|
||||||
|
let suffix_start = word
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.take_while(|(_, c)| !c.is_alphabetic())
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let original_prefix = if prefix_end > 0 {
|
||||||
|
&word[..prefix_end]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let original_suffix = if suffix_start > 0 {
|
||||||
|
&word[word.len() - suffix_start..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
corrected_words.push(format!(
|
||||||
|
"{}{}{}",
|
||||||
|
original_prefix, corrected, original_suffix
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
corrected_words.push(word.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
corrected_words.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
impl TranscriptionManager {
|
impl TranscriptionManager {
|
||||||
pub fn new(app: &App, model_manager: Arc<ModelManager>) -> Result<Self> {
|
pub fn new(app: &App, model_manager: Arc<ModelManager>) -> Result<Self> {
|
||||||
let app_handle = app.app_handle().clone();
|
let app_handle = app.app_handle().clone();
|
||||||
|
|
@ -207,6 +317,13 @@ impl TranscriptionManager {
|
||||||
result.push_str(&segment);
|
result.push_str(&segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply word correction if custom words are configured
|
||||||
|
let corrected_result = if !settings.custom_words.is_empty() {
|
||||||
|
apply_custom_words(&result, &settings.custom_words)
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
let et = std::time::Instant::now();
|
let et = std::time::Instant::now();
|
||||||
let translation_note = if settings.translate_to_english {
|
let translation_note = if settings.translate_to_english {
|
||||||
" (translated)"
|
" (translated)"
|
||||||
|
|
@ -215,6 +332,6 @@ impl TranscriptionManager {
|
||||||
};
|
};
|
||||||
println!("\ntook {}ms{}", (et - st).as_millis(), translation_note);
|
println!("\ntook {}ms{}", (et - st).as_millis(), translation_note);
|
||||||
|
|
||||||
Ok(result.trim().to_string())
|
Ok(corrected_result.trim().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ pub struct AppSettings {
|
||||||
pub overlay_position: OverlayPosition,
|
pub overlay_position: OverlayPosition,
|
||||||
#[serde(default = "default_debug_mode")]
|
#[serde(default = "default_debug_mode")]
|
||||||
pub debug_mode: bool,
|
pub debug_mode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_words: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_model() -> String {
|
fn default_model() -> String {
|
||||||
|
|
@ -104,6 +106,7 @@ pub fn get_default_settings() -> AppSettings {
|
||||||
selected_language: "auto".to_string(),
|
selected_language: "auto".to_string(),
|
||||||
overlay_position: OverlayPosition::Bottom,
|
overlay_position: OverlayPosition::Bottom,
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
|
custom_words: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,10 @@ pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Resu
|
||||||
};
|
};
|
||||||
settings.overlay_position = parsed;
|
settings.overlay_position = parsed;
|
||||||
settings::write_settings(&app, settings);
|
settings::write_settings(&app, settings);
|
||||||
|
|
||||||
// Update overlay position without recreating window
|
// Update overlay position without recreating window
|
||||||
crate::utils::update_overlay_position(&app);
|
crate::utils::update_overlay_position(&app);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +163,14 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {
|
||||||
|
let mut settings = settings::get_settings(&app);
|
||||||
|
settings.custom_words = words;
|
||||||
|
settings::write_settings(&app, settings);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Determine whether a shortcut string contains at least one non-modifier key.
|
/// Determine whether a shortcut string contains at least one non-modifier key.
|
||||||
/// We allow single non-modifier keys (e.g. "f5" or "space") but disallow
|
/// We allow single non-modifier keys (e.g. "f5" or "space") but disallow
|
||||||
/// modifier-only combos (e.g. "ctrl" or "ctrl+shift").
|
/// modifier-only combos (e.g. "ctrl" or "ctrl+shift").
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
{
|
{
|
||||||
"title": "Handy",
|
"title": "Handy",
|
||||||
"width": 540,
|
"width": 540,
|
||||||
"height": 700,
|
"height": 730,
|
||||||
"minWidth": 540,
|
"minWidth": 540,
|
||||||
"minHeight": 700,
|
"minHeight": 730,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"maximizable": false
|
"maximizable": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Typography */
|
/* Typography */
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
|
|
|
||||||
10
src/App.tsx
10
src/App.tsx
|
|
@ -1,12 +1,12 @@
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
||||||
import Footer from "./components/footer";
|
import Footer from "./components/footer";
|
||||||
import HandyTextLogo from "./components/icons/HandyTextLogo";
|
import HandyTextLogo from "./components/icons/HandyTextLogo";
|
||||||
import Onboarding from "./components/onboarding";
|
import Onboarding from "./components/onboarding";
|
||||||
import { Settings } from "./components/settings/Settings";
|
import { Settings } from "./components/settings/Settings";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||||
|
|
@ -34,8 +34,8 @@ function App() {
|
||||||
if (showOnboarding) {
|
if (showOnboarding) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col w-full">
|
<div className="min-h-screen flex flex-col w-full">
|
||||||
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
|
<div className="flex flex-col items-center p-4 gap-4 flex-1">
|
||||||
<HandyTextLogo width={240} />
|
<HandyTextLogo width={200} />
|
||||||
<Onboarding onModelSelected={handleModelSelected} />
|
<Onboarding onModelSelected={handleModelSelected} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,8 +45,8 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col w-full">
|
<div className="min-h-screen flex flex-col w-full">
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
|
<div className="flex flex-col items-center p-4 gap-4 flex-1">
|
||||||
<HandyTextLogo width={240} />
|
<HandyTextLogo width={200} />
|
||||||
<AccessibilityPermissions />
|
<AccessibilityPermissions />
|
||||||
<Settings />
|
<Settings />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,35 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const ResetIcon = ({ className = "" }: { className?: string }) => {
|
interface ResetIconProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResetIcon: React.FC<ResetIconProps> = ({
|
||||||
|
width = 20,
|
||||||
|
height = 20,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
height="20"
|
width={width}
|
||||||
|
height={height}
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
width="20"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
<path d="M6 6c0 .55-.45 1-1 1H1c-.55 0-1-.45-1-1V2c0-.55.45-1 1-1s1 .45 1 1v2.05C3.82 1.6 6.71 0 10 0c5.52 0 10 4.48 10 10s-4.48 10-10 10S0 15.52 0 10c0-.55.45-1 1-1s1 .45 1 1c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8C7.47 2 5.23 3.17 3.76 5H5c.55 0 1 .45 1 1" />
|
<g
|
||||||
|
stroke={"currentColor"}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="m13.5 8.5h3v-3" />
|
||||||
|
<path d="m13.775 14c-.7863.7419-1.7737 1.2356-2.8389 1.4196-1.06527.1839-2.16109.0498-3.15057-.3855s-1.82875-1.1525-2.41293-2.0621c-.58419-.9095-.88739-1.9711-.87172-3.05197.01567-1.08089.34952-2.13319.95982-3.02543.61031-.89224 1.47001-1.58485 2.4717-1.99129s2.1009-.50868 3.1604-.29396c1.0595.21473 2.0322.7369 2.7965 1.50127l2.6107 2.38938" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface AlwaysOnMicrophoneProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
|
export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -26,4 +26,4 @@ export const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = ({
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface AudioFeedbackProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
|
export const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -26,4 +26,4 @@ export const AudioFeedback: React.FC<AudioFeedbackProps> = ({
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
115
src/components/settings/CustomWords.tsx
Normal file
115
src/components/settings/CustomWords.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { Input } from "../ui/Input";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
|
|
||||||
|
interface CustomWordsProps {
|
||||||
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
grouped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomWords: React.FC<CustomWordsProps> = React.memo(
|
||||||
|
({ descriptionMode = "tooltip", grouped = false }) => {
|
||||||
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
const [newWord, setNewWord] = useState("");
|
||||||
|
const customWords = getSetting("custom_words") || [];
|
||||||
|
|
||||||
|
const handleAddWord = () => {
|
||||||
|
const trimmedWord = newWord.trim();
|
||||||
|
const sanitizedWord = trimmedWord.replace(/[<>"'&]/g, "");
|
||||||
|
if (
|
||||||
|
sanitizedWord &&
|
||||||
|
!sanitizedWord.includes(" ") &&
|
||||||
|
sanitizedWord.length <= 50 &&
|
||||||
|
!customWords.includes(sanitizedWord)
|
||||||
|
) {
|
||||||
|
updateSetting("custom_words", [...customWords, sanitizedWord]);
|
||||||
|
setNewWord("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveWord = (wordToRemove: string) => {
|
||||||
|
updateSetting(
|
||||||
|
"custom_words",
|
||||||
|
customWords.filter((word) => word !== wordToRemove),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddWord();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingContainer
|
||||||
|
title="Custom Words"
|
||||||
|
description="Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list."
|
||||||
|
descriptionMode={descriptionMode}
|
||||||
|
grouped={grouped}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="max-w-40"
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyPress}
|
||||||
|
placeholder="Add a word"
|
||||||
|
variant="compact"
|
||||||
|
disabled={isUpdating("custom_words")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddWord}
|
||||||
|
disabled={
|
||||||
|
!newWord.trim() ||
|
||||||
|
newWord.includes(" ") ||
|
||||||
|
newWord.trim().length > 50 ||
|
||||||
|
isUpdating("custom_words")
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingContainer>
|
||||||
|
{customWords.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`px-4 p-2 ${grouped ? "" : "rounded-lg border border-mid-gray/20"} flex flex-wrap gap-1`}
|
||||||
|
>
|
||||||
|
{customWords.map((word) => (
|
||||||
|
<Button
|
||||||
|
key={word}
|
||||||
|
onClick={() => handleRemoveWord(word)}
|
||||||
|
disabled={isUpdating("custom_words")}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-1 cursor-pointer"
|
||||||
|
aria-label={`Remove ${word}`}
|
||||||
|
>
|
||||||
|
<span>{word}</span>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
normalizeKey,
|
normalizeKey,
|
||||||
type OSType,
|
type OSType,
|
||||||
} from "../../lib/utils/keyboard";
|
} from "../../lib/utils/keyboard";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
@ -69,10 +69,11 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
// Only add event listeners when we're in editing mode
|
// Only add event listeners when we're in editing mode
|
||||||
if (editingShortcutId === null) return;
|
if (editingShortcutId === null) return;
|
||||||
|
|
||||||
console.log("keyPressed", keyPressed);
|
let cleanup = false;
|
||||||
|
|
||||||
// Keyboard event listeners
|
// Keyboard event listeners
|
||||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
|
if (cleanup) return;
|
||||||
if (e.repeat) return; // ignore auto-repeat
|
if (e.repeat) return; // ignore auto-repeat
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
// Cancel recording and restore original binding
|
// Cancel recording and restore original binding
|
||||||
|
|
@ -103,8 +104,6 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
const rawKey = getKeyName(e, osType);
|
const rawKey = getKeyName(e, osType);
|
||||||
const key = normalizeKey(rawKey);
|
const key = normalizeKey(rawKey);
|
||||||
|
|
||||||
console.log("You pressed", rawKey, "normalized to", key);
|
|
||||||
|
|
||||||
if (!keyPressed.includes(key)) {
|
if (!keyPressed.includes(key)) {
|
||||||
setKeyPressed((prev) => [...prev, key]);
|
setKeyPressed((prev) => [...prev, key]);
|
||||||
// Also add to recorded keys if not already there
|
// Also add to recorded keys if not already there
|
||||||
|
|
@ -115,6 +114,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = async (e: KeyboardEvent) => {
|
const handleKeyUp = async (e: KeyboardEvent) => {
|
||||||
|
if (cleanup) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Get the key with OS-specific naming and normalize it
|
// Get the key with OS-specific naming and normalize it
|
||||||
|
|
@ -166,6 +166,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
|
|
||||||
// Add click outside handler
|
// Add click outside handler
|
||||||
const handleClickOutside = async (e: MouseEvent) => {
|
const handleClickOutside = async (e: MouseEvent) => {
|
||||||
|
if (cleanup) return;
|
||||||
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
const activeElement = shortcutRefs.current.get(editingShortcutId);
|
||||||
if (activeElement && !activeElement.contains(e.target as Node)) {
|
if (activeElement && !activeElement.contains(e.target as Node)) {
|
||||||
// Cancel shortcut recording and restore original binding
|
// Cancel shortcut recording and restore original binding
|
||||||
|
|
@ -196,6 +197,7 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
window.addEventListener("click", handleClickOutside);
|
window.addEventListener("click", handleClickOutside);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cleanup = true;
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
window.removeEventListener("click", handleClickOutside);
|
window.removeEventListener("click", handleClickOutside);
|
||||||
|
|
@ -299,13 +301,10 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({
|
||||||
{formatKeyCombination(primaryBinding.current_binding, osType)}
|
{formatKeyCombination(primaryBinding.current_binding, osType)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<ResetButton
|
||||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
|
||||||
onClick={() => resetBinding(primaryId)}
|
onClick={() => resetBinding(primaryId)}
|
||||||
disabled={isUpdating(`binding_${primaryId}`)}
|
disabled={isUpdating(`binding_${primaryId}`)}
|
||||||
>
|
/>
|
||||||
<ResetIcon className="" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,14 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import { LANGUAGES } from "../../lib/constants/languages";
|
||||||
|
|
||||||
interface LanguageSelectorProps {
|
interface LanguageSelectorProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{ code: "auto", name: "Auto" },
|
|
||||||
{ code: "en", name: "English" },
|
|
||||||
{ code: "zh", name: "Chinese" },
|
|
||||||
{ code: "hi", name: "Hindi" },
|
|
||||||
{ code: "es", name: "Spanish" },
|
|
||||||
{ code: "fr", name: "French" },
|
|
||||||
{ code: "ar", name: "Arabic" },
|
|
||||||
{ code: "pt", name: "Portuguese" },
|
|
||||||
{ code: "ru", name: "Russian" },
|
|
||||||
{ code: "ja", name: "Japanese" },
|
|
||||||
{ code: "de", name: "German" },
|
|
||||||
{ code: "ko", name: "Korean" },
|
|
||||||
{ code: "it", name: "Italian" },
|
|
||||||
{ code: "tr", name: "Turkish" },
|
|
||||||
{ code: "vi", name: "Vietnamese" },
|
|
||||||
{ code: "pl", name: "Polish" },
|
|
||||||
{ code: "nl", name: "Dutch" },
|
|
||||||
{ code: "uk", name: "Ukrainian" },
|
|
||||||
{ code: "fa", name: "Persian" },
|
|
||||||
{ code: "th", name: "Thai" },
|
|
||||||
{ code: "ro", name: "Romanian" },
|
|
||||||
{ code: "el", name: "Greek" },
|
|
||||||
{ code: "cs", name: "Czech" },
|
|
||||||
{ code: "sv", name: "Swedish" },
|
|
||||||
{ code: "hu", name: "Hungarian" },
|
|
||||||
{ code: "he", name: "Hebrew" },
|
|
||||||
{ code: "da", name: "Danish" },
|
|
||||||
{ code: "fi", name: "Finnish" },
|
|
||||||
{ code: "no", name: "Norwegian" },
|
|
||||||
{ code: "sk", name: "Slovak" },
|
|
||||||
{ code: "bg", name: "Bulgarian" },
|
|
||||||
{ code: "hr", name: "Croatian" },
|
|
||||||
{ code: "lt", name: "Lithuanian" },
|
|
||||||
{ code: "sl", name: "Slovenian" },
|
|
||||||
{ code: "lv", name: "Latvian" },
|
|
||||||
{ code: "et", name: "Estonian" },
|
|
||||||
{ code: "sr", name: "Serbian" },
|
|
||||||
{ code: "is", name: "Icelandic" },
|
|
||||||
{ code: "id", name: "Indonesian" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
|
|
@ -85,12 +44,15 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const filteredLanguages = LANGUAGES.filter((language) =>
|
const filteredLanguages = useMemo(
|
||||||
language.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
() => LANGUAGES.filter((language) =>
|
||||||
|
language.label.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
),
|
||||||
|
[searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedLanguageName =
|
const selectedLanguageName =
|
||||||
LANGUAGES.find((lang) => lang.code === selectedLanguage)?.name || "Auto";
|
LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label || "Auto";
|
||||||
|
|
||||||
const handleLanguageSelect = async (languageCode: string) => {
|
const handleLanguageSelect = async (languageCode: string) => {
|
||||||
await updateSetting("selected_language", languageCode);
|
await updateSetting("selected_language", languageCode);
|
||||||
|
|
@ -114,7 +76,7 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === "Enter" && filteredLanguages.length > 0) {
|
if (event.key === "Enter" && filteredLanguages.length > 0) {
|
||||||
// Select first filtered language on Enter
|
// Select first filtered language on Enter
|
||||||
handleLanguageSelect(filteredLanguages[0].code);
|
handleLanguageSelect(filteredLanguages[0].value);
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
@ -181,17 +143,17 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
) : (
|
) : (
|
||||||
filteredLanguages.map((language) => (
|
filteredLanguages.map((language) => (
|
||||||
<button
|
<button
|
||||||
key={language.code}
|
key={language.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
||||||
selectedLanguage === language.code
|
selectedLanguage === language.value
|
||||||
? "bg-logo-primary/20 text-logo-primary font-semibold"
|
? "bg-logo-primary/20 text-logo-primary font-semibold"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleLanguageSelect(language.code)}
|
onClick={() => handleLanguageSelect(language.value)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="truncate">{language.name}</span>
|
<span className="truncate">{language.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
|
|
@ -200,13 +162,10 @@ export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ResetButton
|
||||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={isUpdating("selected_language")}
|
disabled={isUpdating("selected_language")}
|
||||||
>
|
/>
|
||||||
<ResetIcon className="" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{isUpdating("selected_language") && (
|
{isUpdating("selected_language") && (
|
||||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
// Simple refresh icon component
|
|
||||||
const RefreshIcon: React.FC<{ className?: string }> = ({ className = "" }) => (
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 ${className}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface MicrophoneSelectorProps {
|
interface MicrophoneSelectorProps {
|
||||||
descriptionMode?: "inline" | "tooltip";
|
descriptionMode?: "inline" | "tooltip";
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -41,18 +23,15 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
||||||
refreshAudioDevices,
|
refreshAudioDevices,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
|
|
||||||
const selectedMicrophone = getSetting("selected_microphone") || "Default";
|
const selectedMicrophone = getSetting("selected_microphone") === "default" ? "Default" : getSetting("selected_microphone") || "Default";
|
||||||
|
|
||||||
|
|
||||||
const handleMicrophoneSelect = async (deviceName: string) => {
|
const handleMicrophoneSelect = async (deviceName: string) => {
|
||||||
await updateSetting("selected_microphone", deviceName);
|
await updateSetting("selected_microphone", deviceName);
|
||||||
console.log(
|
|
||||||
`Microphone changed to: ${audioDevices.find((d) => d.name === deviceName)?.name}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
await resetSetting("selected_microphone");
|
await resetSetting("selected_microphone");
|
||||||
console.log("Microphone reset to default");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const microphoneOptions = audioDevices.map(device => ({
|
const microphoneOptions = audioDevices.map(device => ({
|
||||||
|
|
@ -72,23 +51,15 @@ export const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = ({
|
||||||
options={microphoneOptions}
|
options={microphoneOptions}
|
||||||
selectedValue={selectedMicrophone}
|
selectedValue={selectedMicrophone}
|
||||||
onSelect={handleMicrophoneSelect}
|
onSelect={handleMicrophoneSelect}
|
||||||
placeholder={isLoading ? "Loading..." : "Select microphone..."}
|
placeholder={isLoading || audioDevices.length === 0 ? "Loading..." : "Select microphone..."}
|
||||||
disabled={isUpdating("selected_microphone") || isLoading}
|
disabled={isUpdating("selected_microphone") || isLoading || audioDevices.length === 0}
|
||||||
onRefresh={refreshAudioDevices}
|
onRefresh={refreshAudioDevices}
|
||||||
/>
|
/>
|
||||||
<button
|
<ResetButton
|
||||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={isUpdating("selected_microphone") || isLoading}
|
disabled={isUpdating("selected_microphone") || isLoading}
|
||||||
>
|
/>
|
||||||
<ResetIcon className="" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{isUpdating("selected_microphone") && (
|
|
||||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
|
||||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { SettingContainer } from "../ui/SettingContainer";
|
import { SettingContainer } from "../ui/SettingContainer";
|
||||||
import ResetIcon from "../icons/ResetIcon";
|
import { ResetButton } from "../ui/ResetButton";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
interface OutputDeviceSelectorProps {
|
interface OutputDeviceSelectorProps {
|
||||||
|
|
@ -9,7 +9,7 @@ interface OutputDeviceSelectorProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -23,19 +23,14 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
||||||
refreshOutputDevices,
|
refreshOutputDevices,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
|
|
||||||
const selectedOutputDevice =
|
const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default";
|
||||||
getSetting("selected_output_device") || "Default";
|
|
||||||
|
|
||||||
const handleOutputDeviceSelect = async (deviceName: string) => {
|
const handleOutputDeviceSelect = async (deviceName: string) => {
|
||||||
await updateSetting("selected_output_device", deviceName);
|
await updateSetting("selected_output_device", deviceName);
|
||||||
console.log(
|
|
||||||
`Output device changed to: ${outputDevices.find((d) => d.name === deviceName)?.name}`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
await resetSetting("selected_output_device");
|
await resetSetting("selected_output_device");
|
||||||
console.log("Output device reset to default");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const outputDeviceOptions = outputDevices.map(device => ({
|
const outputDeviceOptions = outputDevices.map(device => ({
|
||||||
|
|
@ -55,23 +50,15 @@ export const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> = ({
|
||||||
options={outputDeviceOptions}
|
options={outputDeviceOptions}
|
||||||
selectedValue={selectedOutputDevice}
|
selectedValue={selectedOutputDevice}
|
||||||
onSelect={handleOutputDeviceSelect}
|
onSelect={handleOutputDeviceSelect}
|
||||||
placeholder={isLoading ? "Loading..." : "Select output device..."}
|
placeholder={isLoading || outputDevices.length === 0 ? "Loading..." : "Select output device..."}
|
||||||
disabled={isUpdating("selected_output_device") || isLoading}
|
disabled={isUpdating("selected_output_device") || isLoading || outputDevices.length === 0}
|
||||||
onRefresh={refreshOutputDevices}
|
onRefresh={refreshOutputDevices}
|
||||||
/>
|
/>
|
||||||
<button
|
<ResetButton
|
||||||
className="px-2 py-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:scale-95 rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150"
|
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={isUpdating("selected_output_device") || isLoading}
|
disabled={isUpdating("selected_output_device") || isLoading}
|
||||||
>
|
/>
|
||||||
<ResetIcon className="" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{isUpdating("selected_output_device") && (
|
|
||||||
<div className="absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center">
|
|
||||||
<div className="w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface PushToTalkProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PushToTalk: React.FC<PushToTalkProps> = ({
|
export const PushToTalk: React.FC<PushToTalkProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -26,4 +26,4 @@ export const PushToTalk: React.FC<PushToTalkProps> = ({
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ShowOverlay } from "./ShowOverlay";
|
||||||
import { HandyShortcut } from "./HandyShortcut";
|
import { HandyShortcut } from "./HandyShortcut";
|
||||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||||
import { LanguageSelector } from "./LanguageSelector";
|
import { LanguageSelector } from "./LanguageSelector";
|
||||||
|
import { CustomWords } from "./CustomWords";
|
||||||
import { SettingsGroup } from "../ui/SettingsGroup";
|
import { SettingsGroup } from "../ui/SettingsGroup";
|
||||||
import { DebugSettings } from "./debug";
|
import { DebugSettings } from "./debug";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
@ -54,6 +55,7 @@ export const Settings: React.FC = () => {
|
||||||
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
||||||
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
||||||
|
<CustomWords descriptionMode="tooltip" grouped />
|
||||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const overlayOptions = [
|
||||||
{ value: "top", label: "Top" },
|
{ value: "top", label: "Top" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ShowOverlay: React.FC<ShowOverlayProps> = ({
|
export const ShowOverlay: React.FC<ShowOverlayProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -41,4 +41,4 @@ export const ShowOverlay: React.FC<ShowOverlayProps> = ({
|
||||||
/>
|
/>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface TranslateToEnglishProps {
|
||||||
grouped?: boolean;
|
grouped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = ({
|
export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(({
|
||||||
descriptionMode = "tooltip",
|
descriptionMode = "tooltip",
|
||||||
grouped = false,
|
grouped = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -26,4 +26,4 @@ export const TranslateToEnglish: React.FC<TranslateToEnglishProps> = ({
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ export { AudioFeedback } from "./AudioFeedback";
|
||||||
export { ShowOverlay } from "./ShowOverlay";
|
export { ShowOverlay } from "./ShowOverlay";
|
||||||
export { HandyShortcut } from "./HandyShortcut";
|
export { HandyShortcut } from "./HandyShortcut";
|
||||||
export { TranslateToEnglish } from "./TranslateToEnglish";
|
export { TranslateToEnglish } from "./TranslateToEnglish";
|
||||||
|
export { CustomWords } from "./CustomWords";
|
||||||
|
|
|
||||||
41
src/components/ui/Button.tsx
Normal file
41
src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses =
|
||||||
|
"font-medium rounded focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer";
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
"text-white bg-background-ui hover:bg-background-ui/90 focus:ring-1 focus:ring-background-ui",
|
||||||
|
secondary: "bg-mid-gray/10 hover:bg-background-ui/30 focus:outline-none",
|
||||||
|
danger:
|
||||||
|
"text-white bg-red-600 hover:bg-red-700 focus:ring-1 focus:ring-red-500",
|
||||||
|
ghost: "text-current hover:bg-mid-gray/10 focus:bg-mid-gray/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-2 py-1 text-xs",
|
||||||
|
md: "px-4 py-[5px] text-sm",
|
||||||
|
lg: "px-4 py-2 text-base",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export interface DropdownOption {
|
export interface DropdownOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -95,7 +95,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
type="button"
|
type="button"
|
||||||
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
|
||||||
selectedValue === option.value
|
selectedValue === option.value
|
||||||
? "bg-logo-primary/20 text-logo-primary font-semibold"
|
? "bg-logo-primary/20 font-semibold"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelect(option.value)}
|
onClick={() => handleSelect(option.value)}
|
||||||
|
|
|
||||||
25
src/components/ui/Input.tsx
Normal file
25
src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
variant?: "default" | "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input: React.FC<InputProps> = ({
|
||||||
|
className = "",
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses = "px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded text-left flex items-center justify-between transition-all duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/20 focus:border-logo-primary";
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: "px-3 py-2",
|
||||||
|
compact: "px-2 py-1"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/components/ui/ResetButton.tsx
Normal file
20
src/components/ui/ResetButton.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
import ResetIcon from "../icons/ResetIcon";
|
||||||
|
|
||||||
|
interface ResetButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResetButton: React.FC<ResetButtonProps> = React.memo(
|
||||||
|
({ onClick, disabled = false, className = "" }) => (
|
||||||
|
<button
|
||||||
|
className={`p-1 hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] rounded fill-text hover:cursor-pointer hover:border-logo-primary border border-transparent transition-all duration-150 text-text/80 ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ResetIcon />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface SettingContainerProps {
|
interface SettingContainerProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -200,11 +200,16 @@ export const useSettings = (): UseSettingsReturn => {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "overlay_position":
|
case "overlay_position":
|
||||||
await invoke("change_overlay_position_setting", { position: value });
|
await invoke("change_overlay_position_setting", {
|
||||||
|
position: value,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "debug_mode":
|
case "debug_mode":
|
||||||
await invoke("change_debug_mode_setting", { enabled: value });
|
await invoke("change_debug_mode_setting", { enabled: value });
|
||||||
break;
|
break;
|
||||||
|
case "custom_words":
|
||||||
|
await invoke("update_custom_words", { words: value });
|
||||||
|
break;
|
||||||
case "bindings":
|
case "bindings":
|
||||||
// Handle bindings separately - they use their own invoke methods
|
// Handle bindings separately - they use their own invoke methods
|
||||||
break;
|
break;
|
||||||
|
|
@ -251,6 +256,7 @@ export const useSettings = (): UseSettingsReturn => {
|
||||||
selected_language: "auto",
|
selected_language: "auto",
|
||||||
overlay_position: "bottom",
|
overlay_position: "bottom",
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
|
custom_words: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValue = defaults[key];
|
const defaultValue = defaults[key];
|
||||||
|
|
|
||||||
59
src/lib/constants/languages.ts
Normal file
59
src/lib/constants/languages.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
export interface Language {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LANGUAGES: Language[] = [
|
||||||
|
{ value: "auto", label: "Auto Detect" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "zh", label: "Chinese" },
|
||||||
|
{ value: "de", label: "German" },
|
||||||
|
{ value: "es", label: "Spanish" },
|
||||||
|
{ value: "ru", label: "Russian" },
|
||||||
|
{ value: "ko", label: "Korean" },
|
||||||
|
{ value: "fr", label: "French" },
|
||||||
|
{ value: "ja", label: "Japanese" },
|
||||||
|
{ value: "pt", label: "Portuguese" },
|
||||||
|
{ value: "tr", label: "Turkish" },
|
||||||
|
{ value: "pl", label: "Polish" },
|
||||||
|
{ value: "ca", label: "Catalan" },
|
||||||
|
{ value: "nl", label: "Dutch" },
|
||||||
|
{ value: "ar", label: "Arabic" },
|
||||||
|
{ value: "sv", label: "Swedish" },
|
||||||
|
{ value: "it", label: "Italian" },
|
||||||
|
{ value: "id", label: "Indonesian" },
|
||||||
|
{ value: "hi", label: "Hindi" },
|
||||||
|
{ value: "fi", label: "Finnish" },
|
||||||
|
{ value: "vi", label: "Vietnamese" },
|
||||||
|
{ value: "he", label: "Hebrew" },
|
||||||
|
{ value: "uk", label: "Ukrainian" },
|
||||||
|
{ value: "el", label: "Greek" },
|
||||||
|
{ value: "ms", label: "Malay" },
|
||||||
|
{ value: "cs", label: "Czech" },
|
||||||
|
{ value: "ro", label: "Romanian" },
|
||||||
|
{ value: "da", label: "Danish" },
|
||||||
|
{ value: "hu", label: "Hungarian" },
|
||||||
|
{ value: "ta", label: "Tamil" },
|
||||||
|
{ value: "no", label: "Norwegian" },
|
||||||
|
{ value: "th", label: "Thai" },
|
||||||
|
{ value: "ur", label: "Urdu" },
|
||||||
|
{ value: "hr", label: "Croatian" },
|
||||||
|
{ value: "bg", label: "Bulgarian" },
|
||||||
|
{ value: "lt", label: "Lithuanian" },
|
||||||
|
{ value: "la", label: "Latin" },
|
||||||
|
{ value: "mi", label: "Maori" },
|
||||||
|
{ value: "ml", label: "Malayalam" },
|
||||||
|
{ value: "cy", label: "Welsh" },
|
||||||
|
{ value: "sk", label: "Slovak" },
|
||||||
|
{ value: "te", label: "Telugu" },
|
||||||
|
{ value: "fa", label: "Persian" },
|
||||||
|
{ value: "lv", label: "Latvian" },
|
||||||
|
{ value: "bn", label: "Bengali" },
|
||||||
|
{ value: "sr", label: "Serbian" },
|
||||||
|
{ value: "az", label: "Azerbaijani" },
|
||||||
|
{ value: "sl", label: "Slovenian" },
|
||||||
|
{ value: "kn", label: "Kannada" },
|
||||||
|
{ value: "et", label: "Estonian" },
|
||||||
|
{ value: "mk", label: "Macedonian" },
|
||||||
|
{ value: "br", label: "Breton" },
|
||||||
|
];
|
||||||
|
|
@ -34,6 +34,7 @@ export const SettingsSchema = z.object({
|
||||||
selected_language: z.string(),
|
selected_language: z.string(),
|
||||||
overlay_position: OverlayPositionSchema,
|
overlay_position: OverlayPositionSchema,
|
||||||
debug_mode: z.boolean(),
|
debug_mode: z.boolean(),
|
||||||
|
custom_words: z.array(z.string()).optional().default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BindingResponseSchema = z.object({
|
export const BindingResponseSchema = z.object({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue