From 08b5580ca2d73b90f08af1fd778b314ea496f4d2 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 09:41:42 +0530 Subject: [PATCH 1/9] feat(launch): refine recording HUD and language switching UX --- src/components/launch/LaunchWindow.tsx | 278 ++++++++++++++++--------- src/components/ui/select.tsx | 64 +++--- src/contexts/I18nContext.tsx | 102 ++++++++- src/i18n/locales/en/launch.json | 8 +- src/i18n/locales/es/launch.json | 8 +- src/i18n/locales/zh-CN/launch.json | 8 +- 6 files changed, 341 insertions(+), 127 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..79a32d5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,9 +18,8 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; -import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -67,17 +67,24 @@ const hudGroupClasses = const hudIconBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; +const hudAuxIconBtnClasses = + "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + const windowBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; +const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; + export function LaunchWindow() { const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); - const [isMac, setIsMac] = useState(false); - - useEffect(() => { - getIsMac().then(setIsMac); - }, []); + const { + locale, + setLocale, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + } = useI18n(); + const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const { recording, @@ -164,6 +171,8 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -185,6 +194,31 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const onPointerDown = (event: MouseEvent) => { + if (!languageMenuRef.current) return; + if (!languageMenuRef.current.contains(event.target as Node)) { + setIsLanguageMenuOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isLanguageMenuOpen]); + const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -228,25 +262,42 @@ export function LaunchWindow() { }; return ( -
- {/* Language switcher — top-left, beside traffic lights */} -
- - -
+
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && ( @@ -433,104 +484,133 @@ export function LaunchWindow() { {/* Record/Stop group */} {recording && ( - - - + + + + + + + + + )} - {/* Restart recording */} - {recording && ( - - - + {!recording && ( + <> + {/* Open video file */} + + + + + {/* Open project */} + + + + )} - {/* Cancel recording */} - {recording && ( - + {/* Right sidebar controls */} +
+
- - )} - {/* Open video file */} - - - + {isLanguageMenuOpen && ( +
+ {SUPPORTED_LOCALES.map((loc) => ( + + ))} +
+ )} +
- {/* Open project */} - - - - - {/* Window controls */} -
- - + {/* Window controls */} +
+ + +
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 53e21e6..3326ee9 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - - - & { + showScrollButtons?: boolean; + viewportClassName?: string; + } +>( + ( + { + className, + children, + position = "popper", + showScrollButtons = true, + viewportClassName, + ...props + }, + ref, + ) => ( + + - {children} - - - - -)); + {showScrollButtons ? : null} + + {children} + + {showScrollButtons ? : null} + + + ), +); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 0b75212..405d5c3 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -22,8 +22,13 @@ interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; t: (qualifiedKey: string, vars?: TranslateVars) => string; + systemLocaleSuggestion: Locale | null; + acceptSystemLocaleSuggestion: () => void; + dismissSystemLocaleSuggestion: () => void; } +const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; + const I18nContext = createContext(null); export function useI18n(): I18nContextValue { @@ -44,6 +49,35 @@ function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function getSupportedSystemLocale(): Locale | null { + if (typeof navigator === "undefined") return null; + + const candidates = + Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + if (!candidate) continue; + if (isSupportedLocale(candidate)) return candidate; + + const exactMatch = SUPPORTED_LOCALES.find( + (locale) => locale.toLowerCase() === candidate.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = candidate.split("-")[0]?.toLowerCase(); + if (!baseLanguage) continue; + + if (baseLanguage === "zh") return "zh-CN"; + + const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + if (baseMatch) return baseMatch; + } + + return null; +} + function getInitialLocale(): Locale { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); @@ -56,6 +90,15 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); + const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + + const markPromptAsHandled = useCallback(() => { + try { + localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1"); + } catch { + // localStorage may be unavailable + } + }, []); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); @@ -73,6 +116,46 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale; }, [locale]); + useEffect(() => { + let hasStoredLocale = false; + let hasHandledSystemPrompt = false; + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + hasStoredLocale = Boolean(stored && isSupportedLocale(stored)); + hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1"; + } catch { + // localStorage may be unavailable + } + + if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + + const detectedSystemLocale = getSupportedSystemLocale(); + if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { + markPromptAsHandled(); + return; + } + + setSystemLocaleSuggestion(detectedSystemLocale); + }, [markPromptAsHandled, systemLocaleSuggestion]); + + const acceptSystemLocaleSuggestion = useCallback(() => { + if (!systemLocaleSuggestion) return; + setLocale(systemLocaleSuggestion); + setSystemLocaleSuggestion(null); + markPromptAsHandled(); + }, [markPromptAsHandled, setLocale, systemLocaleSuggestion]); + + const dismissSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); + try { + // Persisting default locale avoids showing this prompt again. + localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); + } catch { + // localStorage may be unavailable + } + markPromptAsHandled(); + }, [markPromptAsHandled]); + const t = useCallback( (qualifiedKey: string, vars?: TranslateVars): string => { const dotIndex = qualifiedKey.indexOf("."); @@ -84,7 +167,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { [locale], ); - const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + const value = useMemo( + () => ({ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + }), + [ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + ], + ); return {children}; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..e959a54 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Please select a source to record" }, - "language": "Language" + "language": "Language", + "systemLanguagePrompt": { + "title": "Use your system language?", + "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", + "switch": "Switch to {{language}}", + "keepDefault": "Keep current language" + } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..68919aa 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Por favor selecciona una fuente para grabar" }, - "language": "Idioma" + "language": "Idioma", + "systemLanguagePrompt": { + "title": "¿Usar el idioma del sistema?", + "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", + "switch": "Cambiar a {{language}}", + "keepDefault": "Mantener idioma actual" + } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..a5c2a9d 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "请选择要录制的源" }, - "language": "语言" + "language": "语言", + "systemLanguagePrompt": { + "title": "使用系统语言吗?", + "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", + "switch": "切换到{{language}}", + "keepDefault": "保持当前语言" + } } From 4e43b59b42fbe52690eb0267a3fe2b0980461bd3 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:11:07 +0530 Subject: [PATCH 2/9] fix(launch): polish language menu behavior --- src/components/launch/LaunchWindow.tsx | 103 ++++++++++--------------- src/contexts/I18nContext.tsx | 9 ++- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 79a32d5..a430be0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ -import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useEffect, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -28,6 +28,12 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -171,8 +177,6 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -194,31 +198,6 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); - useEffect(() => { - if (!isLanguageMenuOpen) return; - - const onPointerDown = (event: MouseEvent) => { - if (!languageMenuRef.current) return; - if (!languageMenuRef.current.contains(event.target as Node)) { - setIsLanguageMenuOpen(false); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsLanguageMenuOpen(false); - } - }; - - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [isLanguageMenuOpen]); - const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -557,42 +536,38 @@ export function LaunchWindow() { {/* Right sidebar controls */}
-
- - - {isLanguageMenuOpen && ( -
+ + - ))} -
- )} -
+
+ +
+ + + + + {SUPPORTED_LOCALES.map((loc) => ( + setLocale(loc)} + className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} + > + {getLocaleName(loc)} + {loc === locale ? : null} + + ))} + + {/* Window controls */}
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 405d5c3..f9c5ee5 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -91,6 +92,7 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + const hasRunSystemLocaleCheckRef = useRef(false); const markPromptAsHandled = useCallback(() => { try { @@ -117,6 +119,9 @@ export function I18nProvider({ children }: { children: ReactNode }) { }, [locale]); useEffect(() => { + if (hasRunSystemLocaleCheckRef.current) return; + hasRunSystemLocaleCheckRef.current = true; + let hasStoredLocale = false; let hasHandledSystemPrompt = false; try { @@ -127,7 +132,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { // localStorage may be unavailable } - if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + if (hasStoredLocale || hasHandledSystemPrompt) return; const detectedSystemLocale = getSupportedSystemLocale(); if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { @@ -136,7 +141,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { } setSystemLocaleSuggestion(detectedSystemLocale); - }, [markPromptAsHandled, systemLocaleSuggestion]); + }, [markPromptAsHandled]); const acceptSystemLocaleSuggestion = useCallback(() => { if (!systemLocaleSuggestion) return; From 3d20c67c63dfe7c7ad58c79bcb1242a971bf6da9 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:15:41 +0530 Subject: [PATCH 3/9] fix(i18n): resolve prompt persistence and language menu behavior --- src/components/launch/LaunchWindow.tsx | 8 ++++++-- src/contexts/I18nContext.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a430be0..137b28c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -89,6 +89,7 @@ export function LaunchWindow() { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; @@ -554,12 +555,15 @@ export function LaunchWindow() { side="top" sideOffset={6} collisionPadding={6} - className={`w-36 min-w-0 max-h-none overflow-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} + className={`w-36 min-w-0 max-h-none overflow-y-hidden overflow-x-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} > {SUPPORTED_LOCALES.map((loc) => ( setLocale(loc)} + onSelect={() => { + setLocale(loc); + resolveSystemLocaleSuggestion(); + }} className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} > {getLocaleName(loc)} diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index f9c5ee5..84640ea 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -26,6 +26,7 @@ interface I18nContextValue { systemLocaleSuggestion: Locale | null; acceptSystemLocaleSuggestion: () => void; dismissSystemLocaleSuggestion: () => void; + resolveSystemLocaleSuggestion: () => void; } const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; @@ -152,12 +153,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { const dismissSystemLocaleSuggestion = useCallback(() => { setSystemLocaleSuggestion(null); - try { - // Persisting default locale avoids showing this prompt again. - localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); - } catch { - // localStorage may be unavailable - } + markPromptAsHandled(); + }, [markPromptAsHandled]); + + const resolveSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); markPromptAsHandled(); }, [markPromptAsHandled]); @@ -180,6 +180,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, }), [ locale, @@ -188,6 +189,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, ], ); From 5494acb5bafa303bdc54d521d7cf913d07edfb08 Mon Sep 17 00:00:00 2001 From: Sid <70214527+siddharthvaddem@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:21:02 -0700 Subject: [PATCH 4/9] Merge pull request #365 from AmitwalaH/fix-tutorial-translations fix(i18n): add missing tutorial dialog translation keys --- src/i18n/locales/en/dialogs.json | 13 ++++++++----- src/i18n/locales/es/dialogs.json | 12 +++++++----- src/i18n/locales/zh-CN/dialogs.json | 12 +++++++----- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 66a33c2..a84b5fd 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "How trimming works", "title": "How Trimming Works", "description": "Understanding how to cut out unwanted parts of your video.", - "explanation": "The Trim tool works by defining the segments you want to", - "explanationRemove": "remove", - "explanationCovered": "covered", - "explanationEnd": "by a red trim segment will be cut out when you export.", + "explanationBefore": "The Trim tool works by defining the segments you want to", + "remove": "remove", + "explanationMiddle": " — anything", + "covered": "covered", + "explanationAfter": "by a red trim segment will be cut out when you export.", "visualExample": "Visual Example", "removed": "REMOVED", "kept": "Kept", @@ -39,7 +40,9 @@ "part3": "Part 3", "finalVideo": "Final Video", "step1Title": "1. Add Trim", - "step1Description": "Press T or click the scissors icon to mark a section for removal.", + "step1DescriptionBefore": "Press ", + "step1DescriptionAfter": " or click the scissors icon to mark a section for removal.", + "step2Title": "2. Adjust", "step2Description": "Drag the edges of the red region to cover exactly what you want to cut out." }, diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index acf2a04..f8a5e63 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "Cómo funciona el recorte", "title": "Cómo funciona el recorte", "description": "Aprende a eliminar las partes no deseadas de tu video.", - "explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas", - "explanationRemove": "eliminar", - "explanationCovered": "cubierto", - "explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.", + "explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas", + "remove": "eliminar", + "explanationMiddle": " — cualquier parte", + "covered": "cubierta", + "explanationAfter": "por un segmento rojo será eliminada al exportar.", "visualExample": "Ejemplo visual", "removed": "ELIMINADO", "kept": "Conservado", @@ -39,7 +40,8 @@ "part3": "Parte 3", "finalVideo": "Video final", "step1Title": "1. Agregar recorte", - "step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.", + "step1DescriptionBefore": "Presiona ", + "step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.", "step2Title": "2. Ajustar", "step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar." }, diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 3f181bc..0385b36 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -27,10 +27,11 @@ "triggerLabel": "剪辑功能说明", "title": "剪辑功能说明", "description": "了解如何剪掉视频中不需要的部分。", - "explanation": "剪辑工具通过定义您要", - "explanationRemove": "移除", - "explanationCovered": "覆盖", - "explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。", + "explanationBefore": "剪辑工具通过定义您要", + "remove": "移除", + "explanationMiddle": "——任何被", + "covered": "覆盖", + "explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。", "visualExample": "示例演示", "removed": "已移除", "kept": "保留", @@ -39,7 +40,8 @@ "part3": "第 3 部分", "finalVideo": "最终视频", "step1Title": "1. 添加剪辑", - "step1Description": "按 T 或点击剪刀图标来标记要移除的片段。", + "step1DescriptionBefore": "按", + "step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。", "step2Title": "2. 调整", "step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。" }, From 0c627da22c27968c52fb626bc84a721551fa075d Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 09:41:42 +0530 Subject: [PATCH 5/9] feat(launch): refine recording HUD and language switching UX --- src/components/launch/LaunchWindow.tsx | 278 ++++++++++++++++--------- src/components/ui/select.tsx | 64 +++--- src/contexts/I18nContext.tsx | 102 ++++++++- src/i18n/locales/en/launch.json | 8 +- src/i18n/locales/es/launch.json | 8 +- src/i18n/locales/zh-CN/launch.json | 8 +- 6 files changed, 341 insertions(+), 127 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..79a32d5 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,9 +18,8 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; -import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -67,17 +67,24 @@ const hudGroupClasses = const hudIconBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95"; +const hudAuxIconBtnClasses = + "flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + const windowBtnClasses = "flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; +const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; + export function LaunchWindow() { const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); - const [isMac, setIsMac] = useState(false); - - useEffect(() => { - getIsMac().then(setIsMac); - }, []); + const { + locale, + setLocale, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + } = useI18n(); + const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const { recording, @@ -164,6 +171,8 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -185,6 +194,31 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const onPointerDown = (event: MouseEvent) => { + if (!languageMenuRef.current) return; + if (!languageMenuRef.current.contains(event.target as Node)) { + setIsLanguageMenuOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isLanguageMenuOpen]); + const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -228,25 +262,42 @@ export function LaunchWindow() { }; return ( -
- {/* Language switcher — top-left, beside traffic lights */} -
- - -
+
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && ( @@ -433,104 +484,133 @@ export function LaunchWindow() { {/* Record/Stop group */} {recording && ( - - - + + + + + + + + +
)} - {/* Restart recording */} - {recording && ( - - - + {!recording && ( + <> + {/* Open video file */} + + + + + {/* Open project */} + + + + )} - {/* Cancel recording */} - {recording && ( - + {/* Right sidebar controls */} +
+
- - )} - {/* Open video file */} - - - + {isLanguageMenuOpen && ( +
+ {SUPPORTED_LOCALES.map((loc) => ( + + ))} +
+ )} +
- {/* Open project */} - - - - - {/* Window controls */} -
- - + {/* Window controls */} +
+ + +
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 53e21e6..3326ee9 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = "popper", ...props }, ref) => ( - - - - & { + showScrollButtons?: boolean; + viewportClassName?: string; + } +>( + ( + { + className, + children, + position = "popper", + showScrollButtons = true, + viewportClassName, + ...props + }, + ref, + ) => ( + + - {children} - - - - -)); + {showScrollButtons ? : null} + + {children} + + {showScrollButtons ? : null} + + + ), +); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 0b75212..405d5c3 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -22,8 +22,13 @@ interface I18nContextValue { locale: Locale; setLocale: (locale: Locale) => void; t: (qualifiedKey: string, vars?: TranslateVars) => string; + systemLocaleSuggestion: Locale | null; + acceptSystemLocaleSuggestion: () => void; + dismissSystemLocaleSuggestion: () => void; } +const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; + const I18nContext = createContext(null); export function useI18n(): I18nContextValue { @@ -44,6 +49,35 @@ function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } +function getSupportedSystemLocale(): Locale | null { + if (typeof navigator === "undefined") return null; + + const candidates = + Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + if (!candidate) continue; + if (isSupportedLocale(candidate)) return candidate; + + const exactMatch = SUPPORTED_LOCALES.find( + (locale) => locale.toLowerCase() === candidate.toLowerCase(), + ); + if (exactMatch) return exactMatch; + + const baseLanguage = candidate.split("-")[0]?.toLowerCase(); + if (!baseLanguage) continue; + + if (baseLanguage === "zh") return "zh-CN"; + + const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + if (baseMatch) return baseMatch; + } + + return null; +} + function getInitialLocale(): Locale { try { const stored = localStorage.getItem(LOCALE_STORAGE_KEY); @@ -56,6 +90,15 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); + const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + + const markPromptAsHandled = useCallback(() => { + try { + localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1"); + } catch { + // localStorage may be unavailable + } + }, []); const setLocale = useCallback((newLocale: Locale) => { setLocaleState(newLocale); @@ -73,6 +116,46 @@ export function I18nProvider({ children }: { children: ReactNode }) { document.documentElement.lang = locale; }, [locale]); + useEffect(() => { + let hasStoredLocale = false; + let hasHandledSystemPrompt = false; + try { + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); + hasStoredLocale = Boolean(stored && isSupportedLocale(stored)); + hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1"; + } catch { + // localStorage may be unavailable + } + + if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + + const detectedSystemLocale = getSupportedSystemLocale(); + if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { + markPromptAsHandled(); + return; + } + + setSystemLocaleSuggestion(detectedSystemLocale); + }, [markPromptAsHandled, systemLocaleSuggestion]); + + const acceptSystemLocaleSuggestion = useCallback(() => { + if (!systemLocaleSuggestion) return; + setLocale(systemLocaleSuggestion); + setSystemLocaleSuggestion(null); + markPromptAsHandled(); + }, [markPromptAsHandled, setLocale, systemLocaleSuggestion]); + + const dismissSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); + try { + // Persisting default locale avoids showing this prompt again. + localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); + } catch { + // localStorage may be unavailable + } + markPromptAsHandled(); + }, [markPromptAsHandled]); + const t = useCallback( (qualifiedKey: string, vars?: TranslateVars): string => { const dotIndex = qualifiedKey.indexOf("."); @@ -84,7 +167,24 @@ export function I18nProvider({ children }: { children: ReactNode }) { [locale], ); - const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + const value = useMemo( + () => ({ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + }), + [ + locale, + setLocale, + t, + systemLocaleSuggestion, + acceptSystemLocaleSuggestion, + dismissSystemLocaleSuggestion, + ], + ); return {children}; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..e959a54 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Please select a source to record" }, - "language": "Language" + "language": "Language", + "systemLanguagePrompt": { + "title": "Use your system language?", + "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", + "switch": "Switch to {{language}}", + "keepDefault": "Keep current language" + } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..68919aa 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "Por favor selecciona una fuente para grabar" }, - "language": "Idioma" + "language": "Idioma", + "systemLanguagePrompt": { + "title": "¿Usar el idioma del sistema?", + "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", + "switch": "Cambiar a {{language}}", + "keepDefault": "Mantener idioma actual" + } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..a5c2a9d 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -33,5 +33,11 @@ "recording": { "selectSource": "请选择要录制的源" }, - "language": "语言" + "language": "语言", + "systemLanguagePrompt": { + "title": "使用系统语言吗?", + "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", + "switch": "切换到{{language}}", + "keepDefault": "保持当前语言" + } } From c9c2634db4b6bba20333ac96969f6c16a666706c Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:11:07 +0530 Subject: [PATCH 6/9] fix(launch): polish language menu behavior --- src/components/launch/LaunchWindow.tsx | 103 ++++++++++--------------- src/contexts/I18nContext.tsx | 9 ++- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 79a32d5..a430be0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,5 @@ -import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useEffect, useState } from "react"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -28,6 +28,12 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -171,8 +177,6 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); - const languageMenuRef = useRef(null); useEffect(() => { const checkSelectedSource = async () => { @@ -194,31 +198,6 @@ export function LaunchWindow() { return () => clearInterval(interval); }, []); - useEffect(() => { - if (!isLanguageMenuOpen) return; - - const onPointerDown = (event: MouseEvent) => { - if (!languageMenuRef.current) return; - if (!languageMenuRef.current.contains(event.target as Node)) { - setIsLanguageMenuOpen(false); - } - }; - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsLanguageMenuOpen(false); - } - }; - - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown); - }; - }, [isLanguageMenuOpen]); - const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); @@ -557,42 +536,38 @@ export function LaunchWindow() { {/* Right sidebar controls */}
-
- - - {isLanguageMenuOpen && ( -
+ + - ))} -
- )} -
+
+ +
+ + + + + {SUPPORTED_LOCALES.map((loc) => ( + setLocale(loc)} + className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} + > + {getLocaleName(loc)} + {loc === locale ? : null} + + ))} + + {/* Window controls */}
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 405d5c3..f9c5ee5 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -5,6 +5,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -91,6 +92,7 @@ function getInitialLocale(): Locale { export function I18nProvider({ children }: { children: ReactNode }) { const [locale, setLocaleState] = useState(getInitialLocale); const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null); + const hasRunSystemLocaleCheckRef = useRef(false); const markPromptAsHandled = useCallback(() => { try { @@ -117,6 +119,9 @@ export function I18nProvider({ children }: { children: ReactNode }) { }, [locale]); useEffect(() => { + if (hasRunSystemLocaleCheckRef.current) return; + hasRunSystemLocaleCheckRef.current = true; + let hasStoredLocale = false; let hasHandledSystemPrompt = false; try { @@ -127,7 +132,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { // localStorage may be unavailable } - if (hasStoredLocale || hasHandledSystemPrompt || systemLocaleSuggestion) return; + if (hasStoredLocale || hasHandledSystemPrompt) return; const detectedSystemLocale = getSupportedSystemLocale(); if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) { @@ -136,7 +141,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { } setSystemLocaleSuggestion(detectedSystemLocale); - }, [markPromptAsHandled, systemLocaleSuggestion]); + }, [markPromptAsHandled]); const acceptSystemLocaleSuggestion = useCallback(() => { if (!systemLocaleSuggestion) return; From 97fbb01801ab85f6db072afc841de1bb830e2efa Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 10:15:41 +0530 Subject: [PATCH 7/9] fix(i18n): resolve prompt persistence and language menu behavior --- src/components/launch/LaunchWindow.tsx | 8 ++++++-- src/contexts/I18nContext.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index a430be0..137b28c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -89,6 +89,7 @@ export function LaunchWindow() { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; @@ -554,12 +555,15 @@ export function LaunchWindow() { side="top" sideOffset={6} collisionPadding={6} - className={`w-36 min-w-0 max-h-none overflow-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} + className={`w-36 min-w-0 max-h-none overflow-y-hidden overflow-x-hidden border-white/15 bg-[rgba(24,24,34,0.98)] p-1 text-white shadow-2xl backdrop-blur-xl ${styles.electronNoDrag}`} > {SUPPORTED_LOCALES.map((loc) => ( setLocale(loc)} + onSelect={() => { + setLocale(loc); + resolveSystemLocaleSuggestion(); + }} className={`flex items-center justify-between rounded-sm px-2 py-1.5 text-[11px] transition-colors ${loc === locale ? "text-white" : "text-white/90"} focus:bg-white/10 focus:text-white ${styles.electronNoDrag}`} > {getLocaleName(loc)} diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index f9c5ee5..84640ea 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -26,6 +26,7 @@ interface I18nContextValue { systemLocaleSuggestion: Locale | null; acceptSystemLocaleSuggestion: () => void; dismissSystemLocaleSuggestion: () => void; + resolveSystemLocaleSuggestion: () => void; } const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen"; @@ -152,12 +153,11 @@ export function I18nProvider({ children }: { children: ReactNode }) { const dismissSystemLocaleSuggestion = useCallback(() => { setSystemLocaleSuggestion(null); - try { - // Persisting default locale avoids showing this prompt again. - localStorage.setItem(LOCALE_STORAGE_KEY, DEFAULT_LOCALE); - } catch { - // localStorage may be unavailable - } + markPromptAsHandled(); + }, [markPromptAsHandled]); + + const resolveSystemLocaleSuggestion = useCallback(() => { + setSystemLocaleSuggestion(null); markPromptAsHandled(); }, [markPromptAsHandled]); @@ -180,6 +180,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, }), [ locale, @@ -188,6 +189,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, + resolveSystemLocaleSuggestion, ], ); From e96478e8130b6d0bb59ce3dcd67f25791f62e7a9 Mon Sep 17 00:00:00 2001 From: imAaryash Date: Sun, 12 Apr 2026 04:22:08 +0530 Subject: [PATCH 8/9] Revert "Merge pull request #365 from AmitwalaH/fix-tutorial-translations" This reverts commit 5494acb5bafa303bdc54d521d7cf913d07edfb08. --- src/i18n/locales/en/dialogs.json | 13 +++++-------- src/i18n/locales/es/dialogs.json | 12 +++++------- src/i18n/locales/zh-CN/dialogs.json | 12 +++++------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index a84b5fd..66a33c2 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "How trimming works", "title": "How Trimming Works", "description": "Understanding how to cut out unwanted parts of your video.", - "explanationBefore": "The Trim tool works by defining the segments you want to", - "remove": "remove", - "explanationMiddle": " — anything", - "covered": "covered", - "explanationAfter": "by a red trim segment will be cut out when you export.", + "explanation": "The Trim tool works by defining the segments you want to", + "explanationRemove": "remove", + "explanationCovered": "covered", + "explanationEnd": "by a red trim segment will be cut out when you export.", "visualExample": "Visual Example", "removed": "REMOVED", "kept": "Kept", @@ -40,9 +39,7 @@ "part3": "Part 3", "finalVideo": "Final Video", "step1Title": "1. Add Trim", - "step1DescriptionBefore": "Press ", - "step1DescriptionAfter": " or click the scissors icon to mark a section for removal.", - + "step1Description": "Press T or click the scissors icon to mark a section for removal.", "step2Title": "2. Adjust", "step2Description": "Drag the edges of the red region to cover exactly what you want to cut out." }, diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index f8a5e63..acf2a04 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "Cómo funciona el recorte", "title": "Cómo funciona el recorte", "description": "Aprende a eliminar las partes no deseadas de tu video.", - "explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas", - "remove": "eliminar", - "explanationMiddle": " — cualquier parte", - "covered": "cubierta", - "explanationAfter": "por un segmento rojo será eliminada al exportar.", + "explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas", + "explanationRemove": "eliminar", + "explanationCovered": "cubierto", + "explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.", "visualExample": "Ejemplo visual", "removed": "ELIMINADO", "kept": "Conservado", @@ -40,8 +39,7 @@ "part3": "Parte 3", "finalVideo": "Video final", "step1Title": "1. Agregar recorte", - "step1DescriptionBefore": "Presiona ", - "step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.", + "step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.", "step2Title": "2. Ajustar", "step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar." }, diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 0385b36..3f181bc 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -27,11 +27,10 @@ "triggerLabel": "剪辑功能说明", "title": "剪辑功能说明", "description": "了解如何剪掉视频中不需要的部分。", - "explanationBefore": "剪辑工具通过定义您要", - "remove": "移除", - "explanationMiddle": "——任何被", - "covered": "覆盖", - "explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。", + "explanation": "剪辑工具通过定义您要", + "explanationRemove": "移除", + "explanationCovered": "覆盖", + "explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。", "visualExample": "示例演示", "removed": "已移除", "kept": "保留", @@ -40,8 +39,7 @@ "part3": "第 3 部分", "finalVideo": "最终视频", "step1Title": "1. 添加剪辑", - "step1DescriptionBefore": "按", - "step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。", + "step1Description": "按 T 或点击剪刀图标来标记要移除的片段。", "step2Title": "2. 调整", "step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。" }, From d1c9555464ecc59a70972c95bf60462386847cde Mon Sep 17 00:00:00 2001 From: imAaryash Date: Sun, 12 Apr 2026 05:13:31 +0530 Subject: [PATCH 9/9] feat(i18n): auto-discover valid locales and harden language menu - derive available locales from locale folders with required namespace validation - exclude incomplete locales and report missing namespace files - align system-language suggestion and selectors with discovered locales - improve launch HUD language menu interaction, scrolling, and viewport clipping - make i18n-check discover locale folders automatically --- scripts/i18n-check.mjs | 14 +- src/components/launch/LaunchWindow.module.css | 75 ++++++++ src/components/launch/LaunchWindow.tsx | 175 +++++++++++++----- src/components/ui/dropdown-menu.tsx | 18 +- src/components/video-editor/VideoEditor.tsx | 7 +- src/contexts/I18nContext.tsx | 19 +- src/i18n/config.ts | 3 +- src/i18n/loader.ts | 77 +++++++- 8 files changed, 314 insertions(+), 74 deletions(-) diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index ca73b23..699ae9e 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Validates that all locale translation files have identical key structures. - * Compares zh-CN and es against the en baseline for every namespace. + * Compares all locale folders (except en) against the en baseline for every namespace. * * Usage: node scripts/i18n-check.mjs */ @@ -11,7 +11,6 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "es", "tr", "ko-KR"]; function getKeys(obj, prefix = "") { const keys = []; @@ -34,12 +33,19 @@ const namespaces = fs .filter((f) => f.endsWith(".json")) .map((f) => f.replace(".json", "")); +const compareLocales = fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((locale) => locale !== BASE_LOCALE) + .sort((a, b) => a.localeCompare(b)); + for (const namespace of namespaces) { const basePath = path.join(baseDir, `${namespace}.json`); const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8")); const baseKeys = getKeys(baseData); - for (const locale of COMPARE_LOCALES) { + for (const locale of compareLocales) { const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`); if (!fs.existsSync(localePath)) { @@ -77,6 +83,6 @@ if (hasErrors) { process.exit(1); } else { console.log( - `i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, + `i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, ); } diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index ff68c3d..132fa0a 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -6,3 +6,78 @@ .electronNoDrag { -webkit-app-region: no-drag; } + +.languageMenuScroll { + max-height: 16rem; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.languageMenuScroll::-webkit-scrollbar { + width: 8px; +} + +.languageMenuScroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); + border-radius: 999px; +} + +.languageMenuScroll::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2)); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.languageMenuScroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3)); +} + +.languageMenuContainer { + position: relative; + z-index: 20; +} + +.languageMenuPanel { + position: fixed; + right: 0; + top: 0; + width: 12rem; + padding: 0.375rem; + border-radius: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(14px); + pointer-events: auto; + box-sizing: border-box; +} + +.languageMenuItem { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + font-size: 11px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + border: 0; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.languageMenuItem:hover, +.languageMenuItem:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + outline: none; +} + +.languageMenuItemActive { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 137b28c..2914584 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,6 @@ import { Check, ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,8 +19,7 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,12 +28,6 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -83,6 +77,7 @@ const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-cen export function LaunchWindow() { const t = useScopedT("launch"); + const availableLocales = getAvailableLocales(); const { locale, setLocale, @@ -123,6 +118,18 @@ export function LaunchWindow() { const [isWebcamHovered, setIsWebcamHovered] = useState(false); const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageTriggerRef = useRef(null); + const languageMenuPanelRef = useRef(null); + const [languageMenuStyle, setLanguageMenuStyle] = useState<{ + right: number; + top: number; + maxHeight: number; + }>({ + right: 12, + top: 12, + maxHeight: 240, + }); const { devices: micDevices, @@ -176,6 +183,71 @@ export function LaunchWindow() { }); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const clickedTrigger = languageTriggerRef.current?.contains(target); + const clickedMenu = languageMenuPanelRef.current?.contains(target); + if (!clickedTrigger && !clickedMenu) { + setIsLanguageMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleEscape); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleEscape); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageTriggerRef.current) return; + + const updatePosition = () => { + if (!languageTriggerRef.current) return; + const rect = languageTriggerRef.current.getBoundingClientRect(); + const gap = 8; + const viewportPadding = 8; + const availableHeight = Math.max(80, rect.top - viewportPadding - gap); + const top = Math.max(viewportPadding, rect.top - gap - availableHeight); + + setLanguageMenuStyle({ + right: Math.max(viewportPadding, window.innerWidth - rect.right), + top, + maxHeight: availableHeight, + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return; + const id = requestAnimationFrame(() => { + if (languageMenuPanelRef.current) { + languageMenuPanelRef.current.scrollTop = 0; + } + }); + return () => cancelAnimationFrame(id); + }, [isLanguageMenuOpen]); + const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -537,41 +609,60 @@ export function LaunchWindow() { {/* Right sidebar controls */}
- - - - - - + +
+ + {isLanguageMenuOpen + ? createPortal( +
event.stopPropagation()} > - {getLocaleName(loc)} - {loc === locale ? : null} - - ))} - - + {availableLocales.map((loc) => ( + + ))} +
, + document.body, + ) + : null} {/* Window controls */}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c15187d..f4dd29f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + portalled?: boolean; + } +>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => { + const content = ( - -)); + ); + + if (!portalled) { + return content; + } + + return {content}; +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0321f43..47c5668 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,8 +14,8 @@ import { import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { type Locale } from "@/i18n/config"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { calculateOutputDimensions, type ExportFormat, @@ -154,6 +154,7 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const availableLocales = getAvailableLocales(); const { locale, setLocale } = useI18n(); const nextAnnotationIdRef = useRef(1); @@ -1707,7 +1708,7 @@ export default function VideoEditor() { className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1" style={{ color: "inherit" }} > - {SUPPORTED_LOCALES.map((loc) => ( + {availableLocales.map((loc) => ( diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 84640ea..1056749 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -8,14 +8,8 @@ import { useRef, useState, } from "react"; -import { - DEFAULT_LOCALE, - type I18nNamespace, - LOCALE_STORAGE_KEY, - type Locale, - SUPPORTED_LOCALES, -} from "@/i18n/config"; -import { translate } from "@/i18n/loader"; +import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config"; +import { getAvailableLocales, translate } from "@/i18n/loader"; type TranslateVars = Record; @@ -48,11 +42,12 @@ export function useScopedT(namespace: I18nNamespace) { } function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); + return getAvailableLocales().includes(value); } function getSupportedSystemLocale(): Locale | null { if (typeof navigator === "undefined") return null; + const availableLocales = getAvailableLocales(); const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 @@ -63,7 +58,7 @@ function getSupportedSystemLocale(): Locale | null { if (!candidate) continue; if (isSupportedLocale(candidate)) return candidate; - const exactMatch = SUPPORTED_LOCALES.find( + const exactMatch = availableLocales.find( (locale) => locale.toLowerCase() === candidate.toLowerCase(), ); if (exactMatch) return exactMatch; @@ -71,9 +66,9 @@ function getSupportedSystemLocale(): Locale | null { const baseLanguage = candidate.split("-")[0]?.toLowerCase(); if (!baseLanguage) continue; - if (baseLanguage === "zh") return "zh-CN"; + if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN"; - const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage); if (baseMatch) return baseMatch; } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 0933569..507aa4d 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,5 +1,4 @@ export const DEFAULT_LOCALE = "en" as const; -export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr", "ko-KR"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", @@ -10,7 +9,7 @@ export const I18N_NAMESPACES = [ "timeline", ] as const; -export type Locale = (typeof SUPPORTED_LOCALES)[number]; +export type Locale = string; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; export const LOCALE_STORAGE_KEY = "openscreen-locale"; diff --git a/src/i18n/loader.ts b/src/i18n/loader.ts index 4736db8..36d8eb6 100644 --- a/src/i18n/loader.ts +++ b/src/i18n/loader.ts @@ -1,6 +1,10 @@ -import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config"; +import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config"; type MessageMap = Record; +type LocaleValidationError = { + locale: string; + missingNamespaces: I18nNamespace[]; +}; const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record< string, @@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) { messages[locale][namespace] = mod.default; } +const REQUIRED_NAMESPACES = new Set(I18N_NAMESPACES); + +const localeValidationErrors: LocaleValidationError[] = Object.keys(messages) + .map((locale) => { + const localeMessages = messages[locale] ?? {}; + const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]); + return { + locale, + missingNamespaces, + }; + }) + .filter((entry) => entry.missingNamespaces.length > 0); + +const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale)); + +const availableLocales = Object.keys(messages) + .filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale])) + .filter((locale) => !invalidLocales.has(locale)) + .sort((a, b) => { + if (a === DEFAULT_LOCALE) return -1; + if (b === DEFAULT_LOCALE) return 1; + return a.localeCompare(b); + }); + +if (localeValidationErrors.length > 0) { + console.error("[i18n] Incomplete locale folders were excluded:"); + for (const entry of localeValidationErrors) { + console.error( + `[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`, + ); + } +} + +function hasRequiredNamespaces(localeMessages: Record | undefined): boolean { + if (!localeMessages) return false; + for (const namespace of REQUIRED_NAMESPACES) { + if (!localeMessages[namespace]) return false; + } + return true; +} + +function isAvailableLocale(locale: string): locale is Locale { + return availableLocales.includes(locale); +} + +export function getAvailableLocales(): Locale[] { + if (availableLocales.length === 0) { + return [DEFAULT_LOCALE]; + } + return availableLocales; +} + +export function getLocaleValidationErrors(): LocaleValidationError[] { + return localeValidationErrors; +} + function getMessageValue(obj: unknown, dotPath: string): string | undefined { const keys = dotPath.split("."); let current: unknown = obj; @@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record): strin } export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap { - return messages[locale]?.[namespace] ?? {}; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return messages[resolvedLocale]?.[namespace] ?? {}; } export function getLocaleName(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.name") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale; } export function getLocaleShort(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.short") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale; } export function translate( @@ -52,8 +115,10 @@ export function translate( vars?: Record, ): string { const value = - getMessageValue(messages[locale]?.[namespace], key) ?? - getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); + getMessageValue( + messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace], + key, + ) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); if (value == null) return `${namespace}.${key}`; return interpolate(value, vars);