stored settings for language, and also overlay is now transcribed (#465)

This commit is contained in:
CJ Pais 2025-12-15 20:01:23 -08:00 committed by GitHub
parent 57629f1374
commit ffd38f4d89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 90 additions and 27 deletions

View file

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

View file

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

View file

@ -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").

View file

@ -252,6 +252,14 @@ async changeAppendTrailingSpaceSetting(enabled: boolean) : Promise<Result<null,
else return { status: "error", error: e as any };
}
},
async changeAppLanguageSetting(language: string) : Promise<Result<null, string>> {
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<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) };
@ -613,7 +621,7 @@ async isLaptop() : Promise<Result<boolean, string>> {
/** 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"

View file

@ -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<AppLanguageSelectorProps> =
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<AppLanguageSelectorProps> =
const handleLanguageChange = (langCode: string) => {
i18n.changeLanguage(langCode);
// Persist to localStorage for next session
localStorage.setItem("handy-app-language", langCode);
updateSetting("app_language", langCode);
};
return (

View file

@ -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<string, unknown> }>(
@ -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;

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "Anwendungssprache",
"description": "Sprache der Handy-Oberfläche ändern"
},
"overlay": {
"transcribing": "Transkribiere..."
}
}

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "Application Language",
"description": "Change the language of the Handy interface"
},
"overlay": {
"transcribing": "Transcribing..."
}
}

View file

@ -399,5 +399,8 @@
"appLanguage": {
"title": "Idioma de la aplicación",
"description": "Cambia el idioma de la interfaz de Handy"
},
"overlay": {
"transcribing": "Transcribiendo..."
}
}

View file

@ -400,5 +400,8 @@
"appLanguage": {
"title": "Langue de l'application",
"description": "Changer la langue de l'interface de Handy"
},
"overlay": {
"transcribing": "Transcription..."
}
}

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "Lingua Applicazione",
"description": "Cambia la lingua dell'interfaccia di Handy"
},
"overlay": {
"transcribing": "Trascrizione..."
}
}

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "アプリケーション言語",
"description": "Handyインターフェースの言語を変更"
},
"overlay": {
"transcribing": "文字起こし中..."
}
}

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "Język aplikacji",
"description": "Zmień język interfejsu Handy"
},
"overlay": {
"transcribing": "Transkrypcja..."
}
}

View file

@ -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..."
}
}

View file

@ -406,5 +406,8 @@
"appLanguage": {
"title": "应用语言",
"description": "更改 Handy 界面的语言"
},
"overlay": {
"transcribing": "正在转录..."
}
}

View file

@ -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<OverlayState>("recording");
const [levels, setLevels] = useState<number[]>(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 = () => {
</div>
)}
{state === "transcribing" && (
<div className="transcribing-text">Transcribing...</div>
<div className="transcribing-text">{t("overlay.transcribing")}</div>
)}
</div>

View file

@ -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(
<React.StrictMode>

View file

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