Add language select to settings

This commit is contained in:
Václav Škorpil 2025-07-28 19:52:37 +02:00
parent 836ba0f7ae
commit b767eca6fb
9 changed files with 247 additions and 2 deletions

View file

@ -7,5 +7,6 @@
"default_binding": "Platform-specific: ctrl+space (Windows/Linux), alt+space (macOS)"
}
},
"push_to_talk": true
"push_to_talk": true,
"selected_language": "auto"
}

View file

@ -180,6 +180,7 @@ pub fn run() {
shortcut::change_ptt_setting,
shortcut::change_audio_feedback_setting,
shortcut::change_translate_to_english_setting,
shortcut::change_selected_language_setting,
trigger_update_check,
commands::models::get_available_models,
commands::models::get_model_info,

View file

@ -178,7 +178,8 @@ impl TranscriptionManager {
// Initialize parameters
let mut params = FullParams::new(SamplingStrategy::default());
params.set_language(Some("auto"));
let language = Some(settings.selected_language.as_str());
params.set_language(language);
params.set_print_special(false);
params.set_print_progress(false);
params.set_print_realtime(false);

View file

@ -28,6 +28,8 @@ pub struct AppSettings {
pub selected_output_device: Option<String>,
#[serde(default = "default_translate_to_english")]
pub translate_to_english: bool,
#[serde(default = "default_selected_language")]
pub selected_language: String,
}
fn default_model() -> String {
@ -47,6 +49,11 @@ fn default_translate_to_english() -> bool {
false
}
fn default_selected_language() -> String {
// Default to auto-detection for backward compatibility
"auto".to_string()
}
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
pub fn get_default_settings() -> AppSettings {
@ -91,6 +98,7 @@ pub fn get_default_settings() -> AppSettings {
selected_microphone: None,
selected_output_device: None,
translate_to_english: false,
selected_language: "auto".to_string(),
}
}

View file

@ -119,6 +119,14 @@ pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Res
Ok(())
}
#[tauri::command]
pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.selected_language = language;
settings::write_settings(&app, settings);
Ok(())
}
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
// Parse shortcut and return error if it fails
let shortcut = match binding.current_binding.parse::<Shortcut>() {

View file

@ -0,0 +1,217 @@
import React, { useState, useRef, useEffect } from "react";
import { SettingContainer } from "../ui/SettingContainer";
import ResetIcon from "../icons/ResetIcon";
import { useSettings } from "../../hooks/useSettings";
interface LanguageSelectorProps {
descriptionMode?: "inline" | "tooltip";
grouped?: boolean;
}
const LANGUAGES = [
{ code: "auto", name: "Auto-detect" },
{ code: "ar", name: "Arabic" },
{ code: "bg", name: "Bulgarian" },
{ code: "zh", name: "Chinese" },
{ code: "hr", name: "Croatian" },
{ code: "cs", name: "Czech" },
{ code: "da", name: "Danish" },
{ code: "nl", name: "Dutch" },
{ code: "en", name: "English" },
{ code: "et", name: "Estonian" },
{ code: "fi", name: "Finnish" },
{ code: "fr", name: "French" },
{ code: "de", name: "German" },
{ code: "el", name: "Greek" },
{ code: "he", name: "Hebrew" },
{ code: "hi", name: "Hindi" },
{ code: "hu", name: "Hungarian" },
{ code: "is", name: "Icelandic" },
{ code: "id", name: "Indonesian" },
{ code: "it", name: "Italian" },
{ code: "ja", name: "Japanese" },
{ code: "ko", name: "Korean" },
{ code: "lv", name: "Latvian" },
{ code: "lt", name: "Lithuanian" },
{ code: "no", name: "Norwegian" },
{ code: "fa", name: "Persian" },
{ code: "pl", name: "Polish" },
{ code: "pt", name: "Portuguese" },
{ code: "ro", name: "Romanian" },
{ code: "ru", name: "Russian" },
{ code: "sr", name: "Serbian" },
{ code: "sk", name: "Slovak" },
{ code: "sl", name: "Slovenian" },
{ code: "es", name: "Spanish" },
{ code: "sv", name: "Swedish" },
{ code: "th", name: "Thai" },
{ code: "tr", name: "Turkish" },
{ code: "uk", name: "Ukrainian" },
{ code: "vi", name: "Vietnamese" },
];
export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const selectedLanguage = getSetting("selected_language") || "auto";
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
const filteredLanguages = LANGUAGES.filter(language =>
language.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const selectedLanguageName = LANGUAGES.find(lang => lang.code === selectedLanguage)?.name || "Auto-detect";
const handleLanguageSelect = async (languageCode: string) => {
await updateSetting("selected_language", languageCode);
setIsOpen(false);
setSearchQuery("");
};
const handleReset = async () => {
await resetSetting("selected_language");
};
const handleToggle = () => {
if (isUpdating("selected_language")) return;
setIsOpen(!isOpen);
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && filteredLanguages.length > 0) {
// Select first filtered language on Enter
handleLanguageSelect(filteredLanguages[0].code);
} else if (event.key === "Escape") {
setIsOpen(false);
setSearchQuery("");
}
};
return (
<SettingContainer
title="Language"
description="Select the language for speech recognition. Auto-detect will automatically determine the language, while selecting a specific language can improve accuracy for that language."
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center space-x-1">
<div className="relative" ref={dropdownRef}>
<button
type="button"
className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-left flex items-center justify-between transition-all duration-150 ${
isUpdating("selected_language")
? "opacity-50 cursor-not-allowed"
: "hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary"
}`}
onClick={handleToggle}
disabled={isUpdating("selected_language")}
>
<span className="truncate">{selectedLanguageName}</span>
<svg
className={`w-4 h-4 ml-2 transition-transform duration-200 ${
isOpen ? "transform rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && !isUpdating("selected_language") && (
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-hidden">
{/* Search input */}
<div className="p-2 border-b border-mid-gray/80">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
placeholder="Search languages..."
className="w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded focus:outline-none focus:ring-1 focus:ring-logo-primary focus:border-logo-primary"
/>
</div>
<div className="max-h-48 overflow-y-auto">
{filteredLanguages.length === 0 ? (
<div className="px-2 py-2 text-sm text-mid-gray text-center">
No languages found
</div>
) : (
filteredLanguages.map((language) => (
<button
key={language.code}
type="button"
className={`w-full px-2 py-1 text-sm text-left hover:bg-logo-primary/10 transition-colors duration-150 ${
selectedLanguage === language.code
? "bg-logo-primary/20 text-logo-primary font-semibold"
: ""
}`}
onClick={() => handleLanguageSelect(language.code)}
>
<div className="flex items-center justify-between">
<span className="truncate">{language.name}</span>
</div>
</button>
))
)}
</div>
</div>
)}
</div>
<button
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}
disabled={isUpdating("selected_language")}
>
<ResetIcon className="" />
</button>
</div>
{isUpdating("selected_language") && (
<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>
);
};

View file

@ -6,6 +6,7 @@ import { AudioFeedback } from "./AudioFeedback";
import { OutputDeviceSelector } from "./OutputDeviceSelector";
import { HandyShortcut } from "./HandyShortcut";
import { TranslateToEnglish } from "./TranslateToEnglish";
import { LanguageSelector } from "./LanguageSelector";
import { SettingsGroup } from "../ui/SettingsGroup";
export const Settings: React.FC = () => {
@ -20,6 +21,7 @@ export const Settings: React.FC = () => {
<PushToTalk descriptionMode="tooltip" grouped={true} />
<AudioFeedback descriptionMode="tooltip" grouped={true} />
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
<LanguageSelector descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>

View file

@ -194,6 +194,11 @@ export const useSettings = (): UseSettingsReturn => {
enabled: value,
});
break;
case "selected_language":
await invoke("change_selected_language_setting", {
language: value,
});
break;
case "bindings":
// Handle bindings separately - they use their own invoke methods
break;
@ -237,6 +242,7 @@ export const useSettings = (): UseSettingsReturn => {
selected_microphone: "Default",
selected_output_device: "Default",
translate_to_english: false,
selected_language: "auto",
};
const defaultValue = defaults[key];

View file

@ -28,6 +28,7 @@ export const SettingsSchema = z.object({
selected_microphone: z.string().nullable().optional(),
selected_output_device: z.string().nullable().optional(),
translate_to_english: z.boolean(),
selected_language: z.string(),
});
export const BindingResponseSchema = z.object({