Merge pull request #362 from imAaryash/detect-system-lang
feat(launch): refine recording HUD and language switching UX
This commit is contained in:
+10
-3
@@ -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
|
||||
*/
|
||||
@@ -34,12 +34,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 +84,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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, Languages } from "lucide-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,9 +19,7 @@ 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 { getLocaleName } from "@/i18n/loader";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
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,26 @@ 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 availableLocales = getAvailableLocales();
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
|
||||
const {
|
||||
recording,
|
||||
@@ -109,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<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
right: number;
|
||||
top: number;
|
||||
maxHeight: number;
|
||||
}>({
|
||||
right: 12,
|
||||
top: 12,
|
||||
maxHeight: 240,
|
||||
});
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
@@ -162,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);
|
||||
|
||||
@@ -228,25 +314,42 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
|
||||
{/* Language switcher — top-left, beside traffic lights */}
|
||||
<div
|
||||
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
<div className={`w-screen h-screen overflow-hidden bg-transparent ${styles.electronDrag}`}>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-white">
|
||||
{t("systemLanguagePrompt.title")}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
|
||||
{t("systemLanguagePrompt.description", {
|
||||
language: suggestedLanguageName,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={dismissSystemLocaleSuggestion}
|
||||
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{t("systemLanguagePrompt.keepDefault")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={acceptSystemLocaleSuggestion}
|
||||
className="h-7 text-xs bg-white text-[#10121b] hover:bg-white/90"
|
||||
>
|
||||
{t("systemLanguagePrompt.switch", {
|
||||
language: suggestedLanguageName,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
@@ -433,104 +536,151 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "animate-record-pulse bg-red-500/10"
|
||||
: "bg-red-500/12 hover:bg-red-500/16"
|
||||
: "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
|
||||
<div className={`flex items-center justify-center ${recording ? "gap-1.5" : ""}`}>
|
||||
{recording
|
||||
? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
|
||||
: getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
|
||||
{recording && (
|
||||
<span
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
|
||||
>
|
||||
{formatTimePadded(elapsedSeconds)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={togglePaused}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!recording && (
|
||||
<>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
ref={languageTriggerRef}
|
||||
type="button"
|
||||
aria-label={t("language")}
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isLanguageMenuOpen
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "no-drag",
|
||||
pointerEvents: "auto",
|
||||
right: `${languageMenuStyle.right}px`,
|
||||
top: `${languageMenuStyle.top}px`,
|
||||
maxHeight: `${languageMenuStyle.maxHeight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={loc === locale}
|
||||
onClick={() => {
|
||||
setLocale(loc);
|
||||
resolveSystemLocaleSuggestion();
|
||||
setIsLanguageMenuOpen(false);
|
||||
}}
|
||||
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
|
||||
>
|
||||
<span className="truncate">{getLocaleName(loc)}</span>
|
||||
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalled?: boolean;
|
||||
}
|
||||
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||
const content = (
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
|
||||
if (!portalled) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
showScrollButtons?: boolean;
|
||||
viewportClassName?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
showScrollButtons = true,
|
||||
viewportClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-1",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
{showScrollButtons ? <SelectScrollUpButton /> : null}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"max-h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
viewportClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
{showScrollButtons ? <SelectScrollDownButton /> : null}
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
|
||||
@@ -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,
|
||||
@@ -155,6 +155,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);
|
||||
@@ -1721,7 +1722,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) => (
|
||||
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
|
||||
+112
-10
@@ -5,16 +5,11 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
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<string, string | number>;
|
||||
|
||||
@@ -22,8 +17,14 @@ interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||
systemLocaleSuggestion: Locale | null;
|
||||
acceptSystemLocaleSuggestion: () => void;
|
||||
dismissSystemLocaleSuggestion: () => void;
|
||||
resolveSystemLocaleSuggestion: () => void;
|
||||
}
|
||||
|
||||
const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen";
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
@@ -41,7 +42,37 @@ 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
|
||||
? navigator.languages
|
||||
: [navigator.language];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if (isSupportedLocale(candidate)) return candidate;
|
||||
|
||||
const exactMatch = availableLocales.find(
|
||||
(locale) => locale.toLowerCase() === candidate.toLowerCase(),
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
||||
if (!baseLanguage) continue;
|
||||
|
||||
if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
|
||||
|
||||
const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
@@ -56,6 +87,16 @@ function getInitialLocale(): Locale {
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||
const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState<Locale | null>(null);
|
||||
const hasRunSystemLocaleCheckRef = useRef(false);
|
||||
|
||||
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 +114,48 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRunSystemLocaleCheckRef.current) return;
|
||||
hasRunSystemLocaleCheckRef.current = true;
|
||||
|
||||
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) return;
|
||||
|
||||
const detectedSystemLocale = getSupportedSystemLocale();
|
||||
if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) {
|
||||
markPromptAsHandled();
|
||||
return;
|
||||
}
|
||||
|
||||
setSystemLocaleSuggestion(detectedSystemLocale);
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const acceptSystemLocaleSuggestion = useCallback(() => {
|
||||
if (!systemLocaleSuggestion) return;
|
||||
setLocale(systemLocaleSuggestion);
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled, setLocale, systemLocaleSuggestion]);
|
||||
|
||||
const dismissSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const resolveSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const t = useCallback(
|
||||
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||
const dotIndex = qualifiedKey.indexOf(".");
|
||||
@@ -84,7 +167,26 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
[locale],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
}),
|
||||
[
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,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";
|
||||
|
||||
+71
-6
@@ -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<string, unknown>;
|
||||
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<string>(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<string, MessageMap> | 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<string, string | number>): 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, string | number>,
|
||||
): 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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "请选择要录制的源"
|
||||
},
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "使用系统语言吗?",
|
||||
"description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?",
|
||||
"switch": "切换到{{language}}",
|
||||
"keepDefault": "保持当前语言"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user