From ffd38f4d89dee390be09aa195e6a5205d2538d19 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Mon, 15 Dec 2025 20:01:23 -0800 Subject: [PATCH] stored settings for language, and also overlay is now transcribed (#465) --- src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 7 +++ src-tauri/src/shortcut.rs | 10 +++++ src/bindings.ts | 10 ++++- .../settings/AppLanguageSelector.tsx | 8 ++-- src/i18n/index.ts | 43 ++++++++++--------- src/i18n/locales/de/translation.json | 3 ++ src/i18n/locales/en/translation.json | 3 ++ src/i18n/locales/es/translation.json | 3 ++ src/i18n/locales/fr/translation.json | 3 ++ src/i18n/locales/it/translation.json | 3 ++ src/i18n/locales/ja/translation.json | 3 ++ src/i18n/locales/pl/translation.json | 3 ++ src/i18n/locales/vi/translation.json | 3 ++ src/i18n/locales/zh/translation.json | 3 ++ src/overlay/RecordingOverlay.tsx | 9 +++- src/overlay/main.tsx | 1 + src/stores/settingsStore.ts | 1 + 18 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9e113d..abf6672 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -261,6 +261,7 @@ pub fn run() { shortcut::resume_binding, shortcut::change_mute_while_recording_setting, shortcut::change_append_trailing_space_setting, + shortcut::change_app_language_setting, shortcut::change_update_checks_setting, trigger_update_check, commands::cancel_operation, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c2b98bd..cc98d98 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -291,6 +291,8 @@ pub struct AppSettings { pub mute_while_recording: bool, #[serde(default)] pub append_trailing_space: bool, + #[serde(default = "default_app_language")] + pub app_language: String, } fn default_model() -> String { @@ -360,6 +362,10 @@ fn default_post_process_enabled() -> bool { false } +fn default_app_language() -> String { + "en".to_string() +} + fn default_post_process_provider_id() -> String { "openai".to_string() } @@ -554,6 +560,7 @@ pub fn get_default_settings() -> AppSettings { post_process_selected_prompt_id: None, mute_while_recording: false, append_trailing_space: false, + app_language: default_app_language(), } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index 288815c..fa855ba 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -717,6 +717,16 @@ pub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Re Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.app_language = language; + settings::write_settings(&app, settings); + + Ok(()) +} + /// 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 /// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). diff --git a/src/bindings.ts b/src/bindings.ts index cf72c49..194ac5b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -252,6 +252,14 @@ async changeAppendTrailingSpaceSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_app_language_setting", { language }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeUpdateChecksSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; @@ -613,7 +621,7 @@ async isLaptop() : Promise> { /** user-defined types **/ -export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean } +export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: string; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string } export type AudioDevice = { index: string; name: string; is_default: boolean } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" diff --git a/src/components/settings/AppLanguageSelector.tsx b/src/components/settings/AppLanguageSelector.tsx index fd219bb..3c41aa1 100644 --- a/src/components/settings/AppLanguageSelector.tsx +++ b/src/components/settings/AppLanguageSelector.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n"; +import { useSettings } from "@/hooks/useSettings"; interface AppLanguageSelectorProps { descriptionMode?: "inline" | "tooltip"; @@ -12,8 +13,10 @@ interface AppLanguageSelectorProps { export const AppLanguageSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t, i18n } = useTranslation(); + const { settings, updateSetting } = useSettings(); - const currentLanguage = i18n.language as SupportedLanguageCode; + const currentLanguage = (settings?.app_language || + i18n.language) as SupportedLanguageCode; const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, @@ -22,8 +25,7 @@ export const AppLanguageSelector: React.FC = const handleLanguageChange = (langCode: string) => { i18n.changeLanguage(langCode); - // Persist to localStorage for next session - localStorage.setItem("handy-app-language", langCode); + updateSetting("app_language", langCode); }; return ( diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2f30908..1c828cd 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,6 +2,7 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import { locale } from "@tauri-apps/plugin-os"; import { LANGUAGE_METADATA } from "./languages"; +import { commands } from "@/bindings"; // Auto-discover translation files using Vite's glob import const localeModules = import.meta.glob<{ default: Record }>( @@ -55,17 +56,11 @@ const getSupportedLanguage = ( return supported ? supported.code : null; }; -// Get saved language from localStorage -const getSavedLanguage = (): SupportedLanguageCode | null => { - const savedLang = localStorage.getItem("handy-app-language"); - return getSupportedLanguage(savedLang); -}; - -// Initialize i18n with saved language or English as default -// System locale detection happens async after init +// Initialize i18n with English as default +// Language will be synced from settings after init i18n.use(initReactI18next).init({ resources, - lng: getSavedLanguage() || "en", + lng: "en", fallbackLng: "en", interpolation: { escapeValue: false, // React already escapes values @@ -75,23 +70,29 @@ i18n.use(initReactI18next).init({ }, }); -// After init, check system locale if no saved preference -const initSystemLocale = async () => { - // Skip if user has explicitly set a language - if (getSavedLanguage()) return; - +// Sync language from app settings +export const syncLanguageFromSettings = async () => { try { - const systemLocale = await locale(); - const supported = getSupportedLanguage(systemLocale); - if (supported && supported !== i18n.language) { - await i18n.changeLanguage(supported); + const result = await commands.getAppSettings(); + if (result.status === "ok" && result.data.app_language) { + const supported = getSupportedLanguage(result.data.app_language); + if (supported && supported !== i18n.language) { + await i18n.changeLanguage(supported); + } + } else { + // Fall back to system locale detection if no saved preference + const systemLocale = await locale(); + const supported = getSupportedLanguage(systemLocale); + if (supported && supported !== i18n.language) { + await i18n.changeLanguage(supported); + } } } catch (e) { - console.warn("Failed to get system locale:", e); + console.warn("Failed to sync language from settings:", e); } }; -// Run async locale detection -initSystemLocale(); +// Run language sync on init +syncLanguageFromSettings(); export default i18n; diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index a44d654..50c417d 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "Anwendungssprache", "description": "Sprache der Handy-Oberfläche ändern" + }, + "overlay": { + "transcribing": "Transkribiere..." } } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 6e5f92c..a6a7683 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "Application Language", "description": "Change the language of the Handy interface" + }, + "overlay": { + "transcribing": "Transcribing..." } } diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 4c72d3c..3d520d0 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -399,5 +399,8 @@ "appLanguage": { "title": "Idioma de la aplicación", "description": "Cambia el idioma de la interfaz de Handy" + }, + "overlay": { + "transcribing": "Transcribiendo..." } } diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 6dae3a6..e37f022 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -400,5 +400,8 @@ "appLanguage": { "title": "Langue de l'application", "description": "Changer la langue de l'interface de Handy" + }, + "overlay": { + "transcribing": "Transcription..." } } diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index d7ab564..a22c44f 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "Lingua Applicazione", "description": "Cambia la lingua dell'interfaccia di Handy" + }, + "overlay": { + "transcribing": "Trascrizione..." } } diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index ee8fa33..311b814 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "アプリケーション言語", "description": "Handyインターフェースの言語を変更" + }, + "overlay": { + "transcribing": "文字起こし中..." } } diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 8bc88c4..7ff9c93 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "Język aplikacji", "description": "Zmień język interfejsu Handy" + }, + "overlay": { + "transcribing": "Transkrypcja..." } } diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 4ab2946..6bca206 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -400,5 +400,8 @@ "appLanguage": { "title": "Ngôn ngữ ứng dụng", "description": "Thay đổi ngôn ngữ giao diện của Handy" + }, + "overlay": { + "transcribing": "Đang chuyển đổi..." } } diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 358a947..245860b 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -406,5 +406,8 @@ "appLanguage": { "title": "应用语言", "description": "更改 Handy 界面的语言" + }, + "overlay": { + "transcribing": "正在转录..." } } diff --git a/src/overlay/RecordingOverlay.tsx b/src/overlay/RecordingOverlay.tsx index 1b841c1..0270491 100644 --- a/src/overlay/RecordingOverlay.tsx +++ b/src/overlay/RecordingOverlay.tsx @@ -1,5 +1,6 @@ import { listen } from "@tauri-apps/api/event"; import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { MicrophoneIcon, TranscriptionIcon, @@ -7,10 +8,12 @@ import { } from "../components/icons"; import "./RecordingOverlay.css"; import { commands } from "@/bindings"; +import { syncLanguageFromSettings } from "@/i18n"; type OverlayState = "recording" | "transcribing"; const RecordingOverlay: React.FC = () => { + const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [state, setState] = useState("recording"); const [levels, setLevels] = useState(Array(16).fill(0)); @@ -19,7 +22,9 @@ const RecordingOverlay: React.FC = () => { useEffect(() => { const setupEventListeners = async () => { // Listen for show-overlay event from Rust - const unlistenShow = await listen("show-overlay", (event) => { + const unlistenShow = await listen("show-overlay", async (event) => { + // Sync language from settings each time overlay is shown + await syncLanguageFromSettings(); const overlayState = event.payload as OverlayState; setState(overlayState); setIsVisible(true); @@ -84,7 +89,7 @@ const RecordingOverlay: React.FC = () => { )} {state === "transcribing" && ( -
Transcribing...
+
{t("overlay.transcribing")}
)} diff --git a/src/overlay/main.tsx b/src/overlay/main.tsx index 76053f3..0157417 100644 --- a/src/overlay/main.tsx +++ b/src/overlay/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import RecordingOverlay from "./RecordingOverlay"; +import "@/i18n"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 67d3c4f..fdf98b3 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -124,6 +124,7 @@ const settingUpdaters: { append_trailing_space: (value) => commands.changeAppendTrailingSpaceSetting(value as boolean), log_level: (value) => commands.setLogLevel(value as any), + app_language: (value) => commands.changeAppLanguageSetting(value as string), }; export const useSettingsStore = create()(