From 03b5523049b0f5a7c4ff165f94eee33cda161a53 Mon Sep 17 00:00:00 2001 From: OrangeChange <95136820+OrangeChange@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:49:03 +0800 Subject: [PATCH 1/4] feat: add selectable system audio output and live level meters --- electron/electron-env.d.ts | 17 + electron/ipc/audioOutputMonitor.test.ts | 82 +++++ electron/ipc/audioOutputMonitor.ts | 209 +++++++++++ electron/ipc/handlers.ts | 2 + electron/ipc/register/recording.ts | 6 + electron/ipc/register/settings.ts | 39 ++ .../recordingPreferencesStore.test.ts | 18 + .../ipc/settings/recordingPreferencesStore.ts | 2 + electron/ipc/types.ts | 2 + electron/native/wgc-capture/CMakeLists.txt | 1 + .../wgc-capture/src/audio_level_monitor.cpp | 338 ++++++++++++++++++ .../wgc-capture/src/audio_level_monitor.h | 6 + electron/native/wgc-capture/src/main.cpp | 78 +++- .../wgc-capture/src/wasapi_loopback.cpp | 54 ++- .../native/wgc-capture/src/wasapi_loopback.h | 8 +- electron/native/windows-capture/src/main.cpp | 73 +++- .../windows-capture/src/wasapi_loopback.cpp | 57 ++- .../windows-capture/src/wasapi_loopback.h | 8 +- electron/preload.ts | 17 + scripts/native-audio-monitor-source.test.mjs | 24 ++ src/components/launch/LaunchWindow.tsx | 71 +++- src/components/launch/popovers/MicPopover.tsx | 23 +- .../launch/popovers/PopoverScaffold.tsx | 6 +- .../popovers/SystemAudioPopover.test.ts | 9 + .../launch/popovers/SystemAudioPopover.tsx | 110 ++++++ src/hooks/audioOutputDevices.test.ts | 138 +++++++ src/hooks/audioOutputDevices.ts | 234 ++++++++++++ src/hooks/useAudioOutputLevels.test.ts | 50 +++ src/hooks/useAudioOutputLevels.ts | 98 +++++ src/hooks/useScreenRecorder.ts | 54 ++- src/i18n/locales/de/launch.json | 4 + src/i18n/locales/en/launch.json | 4 + src/i18n/locales/es/launch.json | 4 + src/i18n/locales/fr/launch.json | 4 + src/i18n/locales/it/launch.json | 4 + src/i18n/locales/ko/launch.json | 4 + src/i18n/locales/nl/launch.json | 4 + src/i18n/locales/pt-BR/launch.json | 4 + src/i18n/locales/ru/launch.json | 4 + src/i18n/locales/zh-CN/launch.json | 4 + src/i18n/locales/zh-TW/launch.json | 4 + vitest.config.ts | 1 + 42 files changed, 1843 insertions(+), 36 deletions(-) create mode 100644 electron/ipc/audioOutputMonitor.test.ts create mode 100644 electron/ipc/audioOutputMonitor.ts create mode 100644 electron/native/wgc-capture/src/audio_level_monitor.cpp create mode 100644 electron/native/wgc-capture/src/audio_level_monitor.h create mode 100644 scripts/native-audio-monitor-source.test.mjs create mode 100644 src/components/launch/popovers/SystemAudioPopover.test.ts create mode 100644 src/components/launch/popovers/SystemAudioPopover.tsx create mode 100644 src/hooks/audioOutputDevices.test.ts create mode 100644 src/hooks/audioOutputDevices.ts create mode 100644 src/hooks/useAudioOutputLevels.test.ts create mode 100644 src/hooks/useAudioOutputLevels.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1a926718f..4f6f49e19 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -247,6 +247,8 @@ interface Window { source: ProcessedDesktopSource, options?: { capturesSystemAudio?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; @@ -909,9 +911,22 @@ interface Window { microphoneEnabled: boolean; microphoneDeviceId?: string; systemAudioEnabled: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; webcamEnabled: boolean; webcamDeviceId?: string; }>; + getNativeAudioOutputDevices: () => Promise>; + startAudioOutputLevelMonitor: () => Promise<{ success: boolean; error?: string }>; + stopAudioOutputLevelMonitor: () => Promise<{ success: boolean }>; + onAudioOutputLevel: ( + callback: (event: { + deviceId: string; + rms: number; + peak: number; + level: number; + }) => void, + ) => () => void; getRecordingAudioLabConfig: () => Promise<{ browserMicrophoneProfile: string; requestedBrowserMicrophoneProfile: string | null; @@ -920,6 +935,8 @@ interface Window { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; webcamEnabled?: boolean; webcamDeviceId?: string; }) => Promise<{ success: boolean; error?: string }>; diff --git a/electron/ipc/audioOutputMonitor.test.ts b/electron/ipc/audioOutputMonitor.test.ts new file mode 100644 index 000000000..a1435b6e7 --- /dev/null +++ b/electron/ipc/audioOutputMonitor.test.ts @@ -0,0 +1,82 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createAudioOutputLevelMonitorManager, + parseAudioOutputLevelLine, + splitAudioOutputMonitorLines, +} from "./audioOutputMonitor"; + +class FakeMonitorProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + killed = false; + + kill() { + this.killed = true; + this.emit("close", null); + return true; + } +} + +describe("audio output level protocol", () => { + it("parses and normalizes RMS and peak values", () => { + expect(parseAudioOutputLevelLine("AUDIO_LEVEL\tdev-1\t0.2\t0.4")).toEqual({ + deviceId: "dev-1", + rms: 0.2, + peak: 0.4, + level: 40, + }); + expect(parseAudioOutputLevelLine("AUDIO_LEVEL\tdev-1\t2\t-1")?.level).toBe(100); + expect(parseAudioOutputLevelLine("AUDIO_LEVEL\t\tbad\t0")).toBeNull(); + }); + + it("keeps a trailing partial line for the next stdout chunk", () => { + const result = splitAudioOutputMonitorLines( + "AUDIO_LEVEL\tdev\t0.1\t0.2\nAUDIO_", + ); + + expect(result.events).toEqual([ + { deviceId: "dev", rms: 0.1, peak: 0.2, level: 20 }, + ]); + expect(result.remainder).toBe("AUDIO_"); + }); +}); + +describe("audio output level monitor lifecycle", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("starts one helper, broadcasts events, and writes stop only once", async () => { + const process = new FakeMonitorProcess(); + const spawn = vi.fn(() => process); + const broadcasts: unknown[] = []; + const manager = createAudioOutputLevelMonitorManager({ + isWindows: () => true, + getHelperPath: () => "helper.exe", + access: async () => undefined, + spawn, + broadcast: (event) => broadcasts.push(event), + }); + + expect(await manager.start()).toEqual({ success: true }); + expect(await manager.start()).toEqual({ success: true }); + expect(spawn).toHaveBeenCalledTimes(1); + + process.stdout.write("AUDIO_LEVEL\tdev-1\t0.2\t0.4\n"); + await new Promise((resolve) => setImmediate(resolve)); + expect(broadcasts).toEqual([ + { deviceId: "dev-1", rms: 0.2, peak: 0.4, level: 40 }, + ]); + + const write = vi.spyOn(process.stdin, "write"); + const stopPromise = manager.stop(); + await manager.stop(); + process.emit("close", 0); + await stopPromise; + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("stop\n"); + }); +}); diff --git a/electron/ipc/audioOutputMonitor.ts b/electron/ipc/audioOutputMonitor.ts new file mode 100644 index 000000000..9b681823a --- /dev/null +++ b/electron/ipc/audioOutputMonitor.ts @@ -0,0 +1,209 @@ +import { spawn, type SpawnOptions } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import { BrowserWindow, ipcMain } from "electron"; +import { getWindowsCaptureExePath } from "./paths/binaries"; + +export type AudioOutputLevelEvent = { + deviceId: string; + rms: number; + peak: number; + level: number; +}; + +type MonitorChildProcess = { + stdin: { write: (chunk: string) => unknown }; + stdout: { on: (event: "data", listener: (chunk: Buffer | string) => void) => unknown }; + stderr: { on: (event: "data", listener: (chunk: Buffer | string) => void) => unknown }; + once: { + (event: "close", listener: (code: number | null) => void): unknown; + (event: "error", listener: (error: Error) => void): unknown; + }; + kill: () => unknown; +}; + +type AudioOutputMonitorDependencies = { + isWindows?: () => boolean; + getHelperPath?: () => string; + access?: (path: string, mode: number) => Promise; + spawn?: ( + helperPath: string, + args: string[], + options: SpawnOptions & { windowsHide: boolean }, + ) => MonitorChildProcess; + broadcast?: (event: AudioOutputLevelEvent) => void; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +export function parseAudioOutputLevelLine(line: string): AudioOutputLevelEvent | null { + const fields = line.split("\t"); + if (fields.length !== 4 || fields[0] !== "AUDIO_LEVEL") { + return null; + } + + const deviceId = fields[1]?.trim(); + const rms = Number(fields[2]); + const peak = Number(fields[3]); + if ( + !deviceId || + !Number.isFinite(rms) || + !Number.isFinite(peak) + ) { + return null; + } + + const normalizedRms = clamp(rms, 0, 1); + const normalizedPeak = clamp(peak, 0, 1); + return { + deviceId, + rms: normalizedRms, + peak: normalizedPeak, + level: clamp(Math.max(normalizedRms * 200, normalizedPeak * 100), 0, 100), + }; +} + +export function splitAudioOutputMonitorLines(input: string): { + events: AudioOutputLevelEvent[]; + remainder: string; +} { + const lines = input.split(/\r?\n/u); + const remainder = lines.pop() ?? ""; + const events = lines + .map((line) => parseAudioOutputLevelLine(line)) + .filter((event): event is AudioOutputLevelEvent => event !== null); + return { events, remainder }; +} + +function defaultBroadcast(event: AudioOutputLevelEvent) { + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.isDestroyed()) { + window.webContents.send("audio-output-level", event); + } + }); +} + +export function createAudioOutputLevelMonitorManager( + dependencies: AudioOutputMonitorDependencies = {}, +) { + const isWindows = dependencies.isWindows ?? (() => process.platform === "win32"); + const getHelperPath = dependencies.getHelperPath ?? getWindowsCaptureExePath; + const access = dependencies.access ?? ((path, mode) => fs.access(path, mode)); + const spawnMonitor = + dependencies.spawn ?? + ((helperPath, args, options) => + spawn(helperPath, args, options) as unknown as MonitorChildProcess); + const broadcast = dependencies.broadcast ?? defaultBroadcast; + + let monitorProcess: MonitorChildProcess | null = null; + let outputBuffer = ""; + let stopping: Promise<{ success: boolean }> | null = null; + + const clearProcess = (processToClear: MonitorChildProcess) => { + if (monitorProcess !== processToClear) return; + monitorProcess = null; + outputBuffer = ""; + }; + + const stop = async (): Promise<{ success: boolean }> => { + if (stopping) return stopping; + const current = monitorProcess; + if (!current) return { success: true }; + + stopping = new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + try { + current.kill(); + } catch { + // The process may already have exited. + } + finish(); + }, 1000); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearProcess(current); + resolve({ success: true }); + }; + + current.once("close", () => finish()); + current.once("error", () => finish()); + try { + current.stdin.write("stop\n"); + } catch { + try { + current.kill(); + } catch { + // Ignore a process that has already exited. + } + finish(); + } + }); + + try { + return await stopping; + } finally { + stopping = null; + } + }; + + const start = async (): Promise<{ success: boolean; error?: string }> => { + if (stopping) await stopping; + if (monitorProcess) return { success: true }; + if (!isWindows()) return { success: false, error: "System audio level monitoring is Windows-only" }; + + const helperPath = getHelperPath(); + try { + await access(helperPath, fsConstants.F_OK); + } catch { + console.warn("Windows audio output level monitor helper missing:", helperPath); + return { success: false, error: "Audio output level monitor helper is unavailable" }; + } + + let child: MonitorChildProcess; + try { + child = spawnMonitor(helperPath, ["--monitor-audio-outputs"], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + console.warn("Failed to spawn audio output level monitor:", error); + return { success: false, error: String(error) }; + } + + monitorProcess = child; + outputBuffer = ""; + child.stdout.on("data", (chunk) => { + outputBuffer += chunk.toString(); + const result = splitAudioOutputMonitorLines(outputBuffer); + outputBuffer = result.remainder; + result.events.forEach(broadcast); + }); + child.stderr.on("data", () => { + // Drain stderr so helper diagnostics cannot block stdout telemetry. + }); + child.once("error", (error) => { + console.warn("Audio output level monitor process error:", error); + clearProcess(child); + }); + child.once("close", () => clearProcess(child)); + + return { success: true }; + }; + + return { start, stop }; +} + +const defaultMonitorManager = createAudioOutputLevelMonitorManager(); +let handlersRegistered = false; + +export function registerAudioOutputMonitorHandlers() { + if (handlersRegistered) return; + handlersRegistered = true; + ipcMain.handle("start-audio-output-level-monitor", () => defaultMonitorManager.start()); + ipcMain.handle("stop-audio-output-level-monitor", () => defaultMonitorManager.stop()); +} + diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 2a0f998eb..85613fe56 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -8,6 +8,7 @@ import { registerProjectHandlers } from "./register/project"; import { registerRecordingHandlers } from "./register/recording"; import { registerSettingsHandlers } from "./register/settings"; import { registerSourceHandlers } from "./register/sources"; +import { registerAudioOutputMonitorHandlers } from "./audioOutputMonitor"; import { selectedSource, setNativeScreenRecordingActive, @@ -71,4 +72,5 @@ export function registerIpcHandlers( registerCaptionHandlers(); registerProjectHandlers(); registerSettingsHandlers(); + registerAudioOutputMonitorHandlers(); } diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 2c50e3976..217425b6b 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -518,6 +518,12 @@ export function registerRecordingHandlers( ); config.captureSystemAudio = true; config.audioOutputPath = tempSystemAudioPath; + if (options.systemAudioDeviceId) { + config.systemAudioDeviceId = options.systemAudioDeviceId; + } + if (options.systemAudioDeviceName) { + config.systemAudioDeviceName = options.systemAudioDeviceName; + } setWindowsSystemAudioPath(systemAudioPath); } else { setWindowsSystemAudioPath(null); diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index 93aee8783..e04d25f7f 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,9 +1,12 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; +import { promisify } from "node:util"; import { app, ipcMain } from "electron"; import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore"; import { hideCursor } from "../../cursorHider"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; +import { getWindowsCaptureExePath } from "../paths/binaries"; import { createRecordingPreferencesStore, type RecordingPreferencesPatch, @@ -23,6 +26,7 @@ import { parseJsonWithByteOrderMark } from "../utils"; const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed"; const recordingPreferencesStore = createRecordingPreferencesStore(RECORDINGS_SETTINGS_FILE); +const execFileAsync = promisify(execFile); const BROWSER_MICROPHONE_PROFILES = new Set([ "processed", "no-agc", @@ -131,6 +135,14 @@ export function registerSettingsHandlers() { ? parsed.microphoneDeviceId : undefined, systemAudioEnabled: parsed.systemAudioEnabled === true, + systemAudioDeviceId: + typeof parsed.systemAudioDeviceId === "string" + ? parsed.systemAudioDeviceId + : undefined, + systemAudioDeviceName: + typeof parsed.systemAudioDeviceName === "string" + ? parsed.systemAudioDeviceName + : undefined, webcamEnabled: parsed.webcamEnabled === true, webcamDeviceId: typeof parsed.webcamDeviceId === "string" ? parsed.webcamDeviceId : undefined, @@ -141,6 +153,8 @@ export function registerSettingsHandlers() { microphoneEnabled: false, microphoneDeviceId: undefined, systemAudioEnabled: false, + systemAudioDeviceId: undefined, + systemAudioDeviceName: undefined, webcamEnabled: false, webcamDeviceId: undefined, }; @@ -151,6 +165,31 @@ export function registerSettingsHandlers() { return getBrowserMicrophoneProfileFromEnv(); }); + ipcMain.handle("get-native-audio-output-devices", async () => { + if (process.platform !== "win32") { + return []; + } + + try { + const { stdout } = await execFileAsync( + getWindowsCaptureExePath(), + ["--list-audio-outputs"], + { timeout: 5000, windowsHide: true, maxBuffer: 1024 * 1024 }, + ); + return stdout + .split(/\r?\n/u) + .filter((line) => line.startsWith("AUDIO_OUTPUT\t")) + .map((line) => { + const [, deviceId, ...labelParts] = line.split("\t"); + return { deviceId, label: labelParts.join("\t") }; + }) + .filter((device) => device.deviceId && device.label); + } catch (error) { + console.warn("Failed to enumerate native audio output devices:", error); + return []; + } + }); + ipcMain.handle("set-recording-preferences", async (_, prefs: RecordingPreferencesPatch) => { try { await recordingPreferencesStore.update(prefs); diff --git a/electron/ipc/settings/recordingPreferencesStore.test.ts b/electron/ipc/settings/recordingPreferencesStore.test.ts index 9caaeaae8..5b80bd2a9 100644 --- a/electron/ipc/settings/recordingPreferencesStore.test.ts +++ b/electron/ipc/settings/recordingPreferencesStore.test.ts @@ -43,4 +43,22 @@ describe("recording preferences store", () => { webcamDeviceId: "preferred-camera", }); }); + + it("persists the selected system audio output alongside the enabled state", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preferences-")); + temporaryDirectories.push(directory); + const store = createRecordingPreferencesStore(path.join(directory, "recording.json")); + + await store.update({ + systemAudioEnabled: true, + systemAudioDeviceId: "speaker-id", + systemAudioDeviceName: "Speakers (USB Audio)", + }); + + await expect(store.read()).resolves.toEqual({ + systemAudioEnabled: true, + systemAudioDeviceId: "speaker-id", + systemAudioDeviceName: "Speakers (USB Audio)", + }); + }); }); diff --git a/electron/ipc/settings/recordingPreferencesStore.ts b/electron/ipc/settings/recordingPreferencesStore.ts index 26eced169..9573ff0cf 100644 --- a/electron/ipc/settings/recordingPreferencesStore.ts +++ b/electron/ipc/settings/recordingPreferencesStore.ts @@ -5,6 +5,8 @@ export interface RecordingPreferencesPatch { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; webcamEnabled?: boolean; webcamDeviceId?: string; } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..e6476c23a 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -10,6 +10,8 @@ export type SelectedSource = { export type NativeMacRecordingOptions = { capturesSystemAudio?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 15b0859a5..cd2dcaf00 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -6,6 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(wgc-capture src/main.cpp + src/audio_level_monitor.cpp src/wgc_session.cpp src/mf_encoder.cpp src/monitor_utils.cpp diff --git a/electron/native/wgc-capture/src/audio_level_monitor.cpp b/electron/native/wgc-capture/src/audio_level_monitor.cpp new file mode 100644 index 000000000..273a9d0a0 --- /dev/null +++ b/electron/native/wgc-capture/src/audio_level_monitor.cpp @@ -0,0 +1,338 @@ +#include "audio_level_monitor.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct LevelAccumulator { + long double sumSquares = 0.0L; + float peak = 0.0f; + uint64_t sampleCount = 0; +}; + +struct OutputMonitorDevice { + std::string id; + IMMDevice* device = nullptr; + IAudioClient* audioClient = nullptr; + IAudioCaptureClient* captureClient = nullptr; + WAVEFORMATEX* mixFormat = nullptr; + bool isDefault = false; +}; + +std::string wideToUtf8(const std::wstring& value) { + if (value.empty()) return ""; + const int size = WideCharToMultiByte( + CP_UTF8, + 0, + value.c_str(), + static_cast(value.size()), + nullptr, + 0, + nullptr, + nullptr); + if (size <= 0) return ""; + + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, + 0, + value.c_str(), + static_cast(value.size()), + result.data(), + size, + nullptr, + nullptr); + return result; +} + +bool isFloatFormat(const WAVEFORMATEX* format) { + if (!format) return false; + if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) return false; + return reinterpret_cast(format)->SubFormat == + KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; +} + +bool isPcmFormat(const WAVEFORMATEX* format) { + if (!format) return false; + if (format->wFormatTag == WAVE_FORMAT_PCM) return true; + if (format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) return false; + return reinterpret_cast(format)->SubFormat == + KSDATAFORMAT_SUBTYPE_PCM; +} + +float clampSample(float sample) { + return std::clamp(sample, -1.0f, 1.0f); +} + +int16_t readInt16(const BYTE* sample) { + int16_t value = 0; + std::memcpy(&value, sample, sizeof(value)); + return value; +} + +int32_t readInt32(const BYTE* sample) { + int32_t value = 0; + std::memcpy(&value, sample, sizeof(value)); + return value; +} + +float readPcmSample(const BYTE* sample, WORD bitsPerSample) { + switch (bitsPerSample) { + case 8: + return (static_cast(*sample) - 128.0f) / 128.0f; + case 16: + return static_cast(readInt16(sample)) / 32768.0f; + case 24: { + int32_t value = static_cast(sample[0]) | + (static_cast(sample[1]) << 8) | + (static_cast(sample[2]) << 16); + if ((value & 0x800000) != 0) value |= ~0xFFFFFF; + return static_cast(value) / 8388608.0f; + } + case 32: + return static_cast(readInt32(sample)) / 2147483648.0f; + default: + return 0.0f; + } +} + +void accumulatePacket( + const OutputMonitorDevice& monitor, + const BYTE* data, + UINT32 frameCount, + DWORD flags, + LevelAccumulator& result) { + if (!monitor.mixFormat || frameCount == 0) return; + if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) return; + + const WORD channels = monitor.mixFormat->nChannels; + const WORD bitsPerSample = monitor.mixFormat->wBitsPerSample; + const WORD sourceBlockAlign = monitor.mixFormat->nBlockAlign; + const WORD sourceBytesPerSample = channels > 0 + ? static_cast(sourceBlockAlign / channels) + : 0; + if (channels == 0 || sourceBytesPerSample == 0) return; + + const bool floatSamples = isFloatFormat(monitor.mixFormat) && + bitsPerSample == 32 && sourceBytesPerSample >= 4; + const bool pcmSamples = isPcmFormat(monitor.mixFormat) && + (bitsPerSample == 8 || bitsPerSample == 16 || bitsPerSample == 24 || bitsPerSample == 32) && + sourceBytesPerSample >= ((bitsPerSample + 7) / 8); + if (!floatSamples && !pcmSamples) return; + + for (UINT32 frame = 0; frame < frameCount; frame++) { + const BYTE* frameData = data + static_cast(frame) * sourceBlockAlign; + for (WORD channel = 0; channel < channels; channel++) { + const BYTE* sampleData = frameData + static_cast(channel) * sourceBytesPerSample; + float sample = 0.0f; + if (floatSamples) { + std::memcpy(&sample, sampleData, sizeof(sample)); + } else { + sample = readPcmSample(sampleData, bitsPerSample); + } + sample = clampSample(sample); + const float magnitude = std::fabs(sample); + result.sumSquares += static_cast(sample) * sample; + result.peak = (std::max)(result.peak, magnitude); + result.sampleCount++; + } + } +} + +void releaseMonitorDevice(OutputMonitorDevice& monitor) { + if (monitor.captureClient) { + monitor.captureClient->Release(); + monitor.captureClient = nullptr; + } + if (monitor.audioClient) { + monitor.audioClient->Stop(); + monitor.audioClient->Release(); + monitor.audioClient = nullptr; + } + if (monitor.mixFormat) { + CoTaskMemFree(monitor.mixFormat); + monitor.mixFormat = nullptr; + } + if (monitor.device) { + monitor.device->Release(); + monitor.device = nullptr; + } +} + +bool initializeMonitorDevice(OutputMonitorDevice& monitor) { + if (!monitor.device) return false; + + HRESULT hr = monitor.device->Activate( + __uuidof(IAudioClient), + CLSCTX_ALL, + nullptr, + reinterpret_cast(&monitor.audioClient)); + if (FAILED(hr)) return false; + + hr = monitor.audioClient->GetMixFormat(&monitor.mixFormat); + if (FAILED(hr) || !monitor.mixFormat) return false; + + hr = monitor.audioClient->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, + 200000, + 0, + monitor.mixFormat, + nullptr); + if (FAILED(hr)) return false; + + hr = monitor.audioClient->GetService( + __uuidof(IAudioCaptureClient), + reinterpret_cast(&monitor.captureClient)); + if (FAILED(hr) || !monitor.captureClient) return false; + + hr = monitor.audioClient->Start(); + return SUCCEEDED(hr); +} + +void drainMonitorDevice(OutputMonitorDevice& monitor, LevelAccumulator& result) { + if (!monitor.captureClient) return; + + UINT32 packetLength = 0; + HRESULT hr = monitor.captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) return; + + while (packetLength > 0) { + BYTE* data = nullptr; + UINT32 frameCount = 0; + DWORD flags = 0; + UINT64 devicePosition = 0; + UINT64 qpcPosition = 0; + hr = monitor.captureClient->GetBuffer( + &data, + &frameCount, + &flags, + &devicePosition, + &qpcPosition); + if (FAILED(hr)) return; + + accumulatePacket(monitor, data, frameCount, flags, result); + monitor.captureClient->ReleaseBuffer(frameCount); + + hr = monitor.captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) return; + } +} + +void writeLevel(const std::string& deviceId, const LevelAccumulator& result) { + const float rms = result.sampleCount > 0 + ? static_cast(std::sqrt(result.sumSquares / result.sampleCount)) + : 0.0f; + const float peak = std::clamp(result.peak, 0.0f, 1.0f); + std::cout << "AUDIO_LEVEL\t" << deviceId << "\t" + << std::fixed << std::setprecision(6) + << std::clamp(rms, 0.0f, 1.0f) << "\t" << peak << "\n"; +} + +std::string getDeviceId(IMMDevice* device) { + if (!device) return ""; + LPWSTR rawId = nullptr; + if (FAILED(device->GetId(&rawId)) || !rawId) return ""; + const std::string id = wideToUtf8(rawId); + CoTaskMemFree(rawId); + return id; +} + +} // namespace + +int runAudioOutputLevelMonitor() { + HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + const bool shouldUninitialize = SUCCEEDED(initHr); + + IMMDeviceEnumerator* enumerator = nullptr; + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), + nullptr, + CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), + reinterpret_cast(&enumerator)); + if (FAILED(hr) || !enumerator) { + if (shouldUninitialize) CoUninitialize(); + return 1; + } + + std::string defaultDeviceId; + IMMDevice* defaultDevice = nullptr; + if (SUCCEEDED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &defaultDevice))) { + defaultDeviceId = getDeviceId(defaultDevice); + defaultDevice->Release(); + } + + std::vector monitors; + IMMDeviceCollection* collection = nullptr; + hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (SUCCEEDED(hr) && collection) { + UINT count = 0; + collection->GetCount(&count); + for (UINT index = 0; index < count; index++) { + IMMDevice* device = nullptr; + if (FAILED(collection->Item(index, &device)) || !device) continue; + + OutputMonitorDevice monitor; + monitor.device = device; + monitor.id = getDeviceId(device); + monitor.isDefault = !defaultDeviceId.empty() && monitor.id == defaultDeviceId; + if (monitor.id.empty() || !initializeMonitorDevice(monitor)) { + releaseMonitorDevice(monitor); + continue; + } + monitors.push_back(monitor); + } + collection->Release(); + } + enumerator->Release(); + + std::atomic stopRequested{false}; + std::thread stdinThread([&stopRequested]() { + std::string line; + while (std::getline(std::cin, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ' || line.back() == '\t')) { + line.pop_back(); + } + if (line == "stop") { + stopRequested.store(true); + return; + } + } + stopRequested.store(true); + }); + + while (!stopRequested.load()) { + for (OutputMonitorDevice& monitor : monitors) { + LevelAccumulator result; + drainMonitorDevice(monitor, result); + writeLevel(monitor.id, result); + if (monitor.isDefault) writeLevel("default", result); + } + std::cout.flush(); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + if (stdinThread.joinable()) stdinThread.join(); + for (OutputMonitorDevice& monitor : monitors) releaseMonitorDevice(monitor); + if (shouldUninitialize) CoUninitialize(); + return 0; +} diff --git a/electron/native/wgc-capture/src/audio_level_monitor.h b/electron/native/wgc-capture/src/audio_level_monitor.h new file mode 100644 index 000000000..e6d68fdbb --- /dev/null +++ b/electron/native/wgc-capture/src/audio_level_monitor.h @@ -0,0 +1,6 @@ +#pragma once + +// Runs the Windows-only WASAPI loopback level monitor protocol. +// The caller owns the process lifetime; this function returns after stdin +// receives "stop" or closes. +int runAudioOutputLevelMonitor(); diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 68a408efe..02ef01d72 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -1,10 +1,13 @@ #include "wgc_session.h" #include "mf_encoder.h" #include "monitor_utils.h" +#include "audio_level_monitor.h" #include "wasapi_loopback.h" #include #include +#include +#include #include #include @@ -32,11 +35,71 @@ static void reportMicrophoneCaptureUnavailable() { std::cout.flush(); } +static std::string wideToUtf8(const std::wstring& value) { + if (value.empty()) return ""; + const int size = WideCharToMultiByte( + CP_UTF8, 0, value.c_str(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) return ""; + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, value.c_str(), static_cast(value.size()), + result.data(), size, nullptr, nullptr); + return result; +} + +static int listAudioOutputs() { + HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + const bool shouldUninitialize = SUCCEEDED(initHr); + IMMDeviceEnumerator* enumerator = nullptr; + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)); + if (FAILED(hr) || !enumerator) { + if (shouldUninitialize) CoUninitialize(); + return 1; + } + + IMMDeviceCollection* collection = nullptr; + hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (SUCCEEDED(hr) && collection) { + UINT count = 0; + collection->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* device = nullptr; + if (FAILED(collection->Item(i, &device)) || !device) continue; + + LPWSTR id = nullptr; + IPropertyStore* store = nullptr; + PROPVARIANT value; + PropVariantInit(&value); + const bool gotId = SUCCEEDED(device->GetId(&id)); + const bool gotStore = SUCCEEDED(device->OpenPropertyStore(STGM_READ, &store)) && store; + const bool gotName = gotStore && + SUCCEEDED(store->GetValue(PKEY_Device_FriendlyName, &value)) && + value.vt == VT_LPWSTR && value.pwszVal; + if (gotId && gotName) { + std::cout << "AUDIO_OUTPUT\t" << wideToUtf8(id) << "\t" + << wideToUtf8(value.pwszVal) << std::endl; + } + PropVariantClear(&value); + if (store) store->Release(); + if (id) CoTaskMemFree(id); + device->Release(); + } + collection->Release(); + } + enumerator->Release(); + if (shouldUninitialize) CoUninitialize(); + return SUCCEEDED(hr) ? 0 : 1; +} + struct CaptureConfig { int64_t displayId = 0; int64_t windowHandle = 0; std::string outputPath; std::string audioOutputPath; + std::string systemAudioDeviceId; + std::string systemAudioDeviceName; std::string micOutputPath; std::string micDeviceId; std::string micDeviceName; @@ -127,6 +190,8 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { if (height > 0) config.height = height; config.audioOutputPath = findString("audioOutputPath"); + config.systemAudioDeviceId = findString("systemAudioDeviceId"); + config.systemAudioDeviceName = findString("systemAudioDeviceName"); config.micOutputPath = findString("micOutputPath"); config.micDeviceId = findString("micDeviceId"); config.micDeviceName = findString("micDeviceName"); @@ -290,6 +355,14 @@ int main(int argc, char* argv[]) { return 1; } + if (std::string(argv[1]) == "--list-audio-outputs") { + return listAudioOutputs(); + } + + if (std::string(argv[1]) == "--monitor-audio-outputs") { + return runAudioOutputLevelMonitor(); + } + // Keep Win32 monitor/window rectangles in physical pixels. Without // per-monitor awareness, mixed-DPI desktops can report virtualized bounds // that do not line up with the WGC monitor texture. @@ -385,7 +458,10 @@ int main(int argc, char* argv[]) { bool micInitialized = false; if (config.captureSystemAudio && !config.audioOutputPath.empty()) { - audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + audioInitialized = loopback.initializeLoopback( + config.audioOutputPath, + config.systemAudioDeviceId, + config.systemAudioDeviceName); if (!audioInitialized) { std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; } diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index 240264d8c..e71a7207c 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -151,7 +151,41 @@ IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName return matchingDevice; } -bool WasapiCapture::initializeLoopback(const std::string& outputPath) { +IMMDevice* WasapiCapture::findRenderDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + IMMDevice* matchingDevice = nullptr; + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + if (FAILED(collection->Item(i, &dev)) || !dev) continue; + + const std::wstring candidateName = getDeviceFriendlyName(dev); + if (deviceNamesMatch(candidateName, targetName)) { + if (matchingDevice) { + matchingDevice->Release(); + dev->Release(); + collection->Release(); + return nullptr; + } + matchingDevice = dev; + continue; + } + dev->Release(); + } + + collection->Release(); + return matchingDevice; +} + +bool WasapiCapture::initializeLoopback( + const std::string& outputPath, + const std::string& deviceId, + const std::string& deviceName) { outputPath_ = outputPath; streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; @@ -160,7 +194,23 @@ bool WasapiCapture::initializeLoopback(const std::string& outputPath) { IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); if (FAILED(hr)) return false; - hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (!deviceId.empty() && deviceId != "default") { + const std::wstring requestedId = utf8ToWide(deviceId); + hr = enumerator_->GetDevice(requestedId.c_str(), &device_); + if (FAILED(hr)) device_ = nullptr; + } + if (!device_ && !deviceName.empty() && deviceId != "default") { + device_ = findRenderDeviceByName(utf8ToWide(deviceName)); + if (device_) hr = S_OK; + } + if (!device_) { + const bool wantedSpecificDevice = + deviceId != "default" && (!deviceId.empty() || !deviceName.empty()); + if (wantedSpecificDevice) { + std::cerr << "WARNING: Requested system audio output unavailable; using default" << std::endl; + } + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + } if (FAILED(hr)) return false; return initializeCommon(); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index ab3cd7a6c..52e9b47d6 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -13,7 +13,10 @@ class WasapiCapture { WasapiCapture(); ~WasapiCapture(); - bool initializeLoopback(const std::string& outputPath); + bool initializeLoopback( + const std::string& outputPath, + const std::string& deviceId = "", + const std::string& deviceName = ""); bool initializeMic( const std::string& outputPath, const std::string& deviceId = "", @@ -43,7 +46,8 @@ class WasapiCapture { void writePcmFrames(const int16_t* samples, UINT32 frameCount, WORD channels); void writeSilenceFrames(uint64_t frameCount, WORD channels); uint32_t boundaryFadeInFrameCount() const; - IMMDevice* findCaptureDeviceByName(const std::wstring& name); + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + IMMDevice* findRenderDeviceByName(const std::wstring& name); std::string outputPath_; std::thread thread_; diff --git a/electron/native/windows-capture/src/main.cpp b/electron/native/windows-capture/src/main.cpp index 2aac86a71..eabd7c5fb 100644 --- a/electron/native/windows-capture/src/main.cpp +++ b/electron/native/windows-capture/src/main.cpp @@ -3,6 +3,8 @@ #include "monitor_utils.h" #include "wasapi_loopback.h" +#include +#include #include #include #include @@ -20,11 +22,71 @@ static std::atomic g_accumulatedPausedHns{0}; static std::mutex g_stopMutex; static std::condition_variable g_stopCv; +static std::string wideToUtf8(const std::wstring& value) { + if (value.empty()) return ""; + const int size = WideCharToMultiByte( + CP_UTF8, 0, value.c_str(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) return ""; + std::string result(size, '\0'); + WideCharToMultiByte( + CP_UTF8, 0, value.c_str(), static_cast(value.size()), + result.data(), size, nullptr, nullptr); + return result; +} + +static int listAudioOutputs() { + HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + const bool shouldUninitialize = SUCCEEDED(initHr); + IMMDeviceEnumerator* enumerator = nullptr; + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)); + if (FAILED(hr) || !enumerator) { + if (shouldUninitialize) CoUninitialize(); + return 1; + } + + IMMDeviceCollection* collection = nullptr; + hr = enumerator->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (SUCCEEDED(hr) && collection) { + UINT count = 0; + collection->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* device = nullptr; + if (FAILED(collection->Item(i, &device)) || !device) continue; + + LPWSTR id = nullptr; + IPropertyStore* store = nullptr; + PROPVARIANT value; + PropVariantInit(&value); + const bool gotId = SUCCEEDED(device->GetId(&id)); + const bool gotStore = SUCCEEDED(device->OpenPropertyStore(STGM_READ, &store)) && store; + const bool gotName = gotStore && + SUCCEEDED(store->GetValue(PKEY_Device_FriendlyName, &value)) && + value.vt == VT_LPWSTR && value.pwszVal; + if (gotId && gotName) { + std::cout << "AUDIO_OUTPUT\t" << wideToUtf8(id) << "\t" + << wideToUtf8(value.pwszVal) << std::endl; + } + PropVariantClear(&value); + if (store) store->Release(); + if (id) CoTaskMemFree(id); + device->Release(); + } + collection->Release(); + } + enumerator->Release(); + if (shouldUninitialize) CoUninitialize(); + return SUCCEEDED(hr) ? 0 : 1; +} + struct CaptureConfig { int64_t displayId = 0; int64_t windowHandle = 0; std::string outputPath; std::string audioOutputPath; + std::string systemAudioDeviceId; + std::string systemAudioDeviceName; std::string micOutputPath; std::string micDeviceName; int fps = 60; @@ -109,6 +171,8 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { if (height > 0) config.height = height; config.audioOutputPath = findString("audioOutputPath"); + config.systemAudioDeviceId = findString("systemAudioDeviceId"); + config.systemAudioDeviceName = findString("systemAudioDeviceName"); config.micOutputPath = findString("micOutputPath"); config.micDeviceName = findString("micDeviceName"); @@ -173,6 +237,10 @@ int main(int argc, char* argv[]) { return 1; } + if (std::string(argv[1]) == "--list-audio-outputs") { + return listAudioOutputs(); + } + CaptureConfig config; if (!parseSimpleJson(argv[1], config)) { std::cerr << "ERROR: Failed to parse config JSON" << std::endl; @@ -258,7 +326,10 @@ int main(int argc, char* argv[]) { bool micInitialized = false; if (config.captureSystemAudio && !config.audioOutputPath.empty()) { - audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + audioInitialized = loopback.initializeLoopback( + config.audioOutputPath, + config.systemAudioDeviceId, + config.systemAudioDeviceName); if (!audioInitialized) { std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; } diff --git a/electron/native/windows-capture/src/wasapi_loopback.cpp b/electron/native/windows-capture/src/wasapi_loopback.cpp index 8ae15ad1f..71056de79 100644 --- a/electron/native/windows-capture/src/wasapi_loopback.cpp +++ b/electron/native/windows-capture/src/wasapi_loopback.cpp @@ -61,7 +61,45 @@ IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName return nullptr; } -bool WasapiCapture::initializeLoopback(const std::string& outputPath) { +IMMDevice* WasapiCapture::findRenderDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + if (FAILED(collection->Item(i, &dev)) || !dev) continue; + + IPropertyStore* store = nullptr; + if (FAILED(dev->OpenPropertyStore(STGM_READ, &store)) || !store) { + dev->Release(); + continue; + } + PROPVARIANT pv; + PropVariantInit(&pv); + store->GetValue(PKEY_Device_FriendlyName, &pv); + std::wstring name = pv.pwszVal ? pv.pwszVal : L""; + PropVariantClear(&pv); + store->Release(); + + if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { + collection->Release(); + return dev; + } + dev->Release(); + } + + collection->Release(); + return nullptr; +} + +bool WasapiCapture::initializeLoopback( + const std::string& outputPath, + const std::string& deviceId, + const std::string& deviceName) { outputPath_ = outputPath; streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; @@ -70,7 +108,22 @@ bool WasapiCapture::initializeLoopback(const std::string& outputPath) { IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); if (FAILED(hr)) return false; - hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (!deviceId.empty() && deviceId != "default") { + hr = enumerator_->GetDevice(utf8ToWide(deviceId).c_str(), &device_); + if (FAILED(hr)) device_ = nullptr; + } + if (!device_ && !deviceName.empty() && deviceId != "default") { + device_ = findRenderDeviceByName(utf8ToWide(deviceName)); + if (device_) hr = S_OK; + } + if (!device_) { + const bool wantedSpecificDevice = + deviceId != "default" && (!deviceId.empty() || !deviceName.empty()); + if (wantedSpecificDevice) { + std::cerr << "WARNING: Requested system audio output unavailable; using default" << std::endl; + } + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + } if (FAILED(hr)) return false; return initializeCommon(); diff --git a/electron/native/windows-capture/src/wasapi_loopback.h b/electron/native/windows-capture/src/wasapi_loopback.h index a4facf139..7719fcb63 100644 --- a/electron/native/windows-capture/src/wasapi_loopback.h +++ b/electron/native/windows-capture/src/wasapi_loopback.h @@ -13,7 +13,10 @@ class WasapiCapture { WasapiCapture(); ~WasapiCapture(); - bool initializeLoopback(const std::string& outputPath); + bool initializeLoopback( + const std::string& outputPath, + const std::string& deviceId = "", + const std::string& deviceName = ""); bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); bool start(); bool pause(); @@ -24,7 +27,8 @@ class WasapiCapture { bool initializeCommon(); void captureThread(); bool writeWavHeader(HANDLE file, DWORD dataSize); - IMMDevice* findCaptureDeviceByName(const std::wstring& name); + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + IMMDevice* findRenderDeviceByName(const std::wstring& name); std::string outputPath_; std::thread thread_; diff --git a/electron/preload.ts b/electron/preload.ts index 990ee7a8f..723ae7bdd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -533,6 +533,8 @@ contextBridge.exposeInMainWorld("electronAPI", { source: ProcessedDesktopSource, options?: { capturesSystemAudio?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; capturesMicrophone?: boolean; microphoneDeviceId?: string; microphoneLabel?: string; @@ -1001,11 +1003,26 @@ contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("app:getVersion"), getAnnouncements: () => ipcRenderer.invoke("announcements:get"), getRecordingPreferences: () => ipcRenderer.invoke("get-recording-preferences"), + getNativeAudioOutputDevices: () => ipcRenderer.invoke("get-native-audio-output-devices"), + startAudioOutputLevelMonitor: () => ipcRenderer.invoke("start-audio-output-level-monitor"), + stopAudioOutputLevelMonitor: () => ipcRenderer.invoke("stop-audio-output-level-monitor"), + onAudioOutputLevel: ( + callback: (event: import("./ipc/audioOutputMonitor").AudioOutputLevelEvent) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: import("./ipc/audioOutputMonitor").AudioOutputLevelEvent, + ) => callback(payload); + ipcRenderer.on("audio-output-level", listener); + return () => ipcRenderer.removeListener("audio-output-level", listener); + }, getRecordingAudioLabConfig: () => ipcRenderer.invoke("get-recording-audio-lab-config"), setRecordingPreferences: (prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + systemAudioDeviceId?: string; + systemAudioDeviceName?: string; webcamEnabled?: boolean; webcamDeviceId?: string; }) => ipcRenderer.invoke("set-recording-preferences", prefs), diff --git a/scripts/native-audio-monitor-source.test.mjs b/scripts/native-audio-monitor-source.test.mjs new file mode 100644 index 000000000..297a86cd9 --- /dev/null +++ b/scripts/native-audio-monitor-source.test.mjs @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +describe("native audio output monitor wiring", () => { + it("builds the monitor source and exposes its command-line mode", () => { + const cmake = fs.readFileSync( + path.join(repoRoot, "electron/native/wgc-capture/CMakeLists.txt"), + "utf8", + ); + const main = fs.readFileSync( + path.join(repoRoot, "electron/native/wgc-capture/src/main.cpp"), + "utf8", + ); + + expect(cmake).toContain("src/audio_level_monitor.cpp"); + expect(main).toContain('#include "audio_level_monitor.h"'); + expect(main).toContain('std::string(argv[1]) == "--monitor-audio-outputs"'); + expect(main).toContain("runAudioOutputLevelMonitor()"); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 66cbe608b..5cf36f4cd 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -6,6 +6,8 @@ import { MicrophoneSlashIcon, MinusIcon, MonitorIcon, + SpeakerHighIcon, + SpeakerXIcon, TimerIcon, VideoCameraIcon, VideoCameraSlashIcon, @@ -16,6 +18,7 @@ import { useEffect, useRef } from "react"; import { RxDragHandleDots2 } from "react-icons/rx"; import { Separator } from "@/components/ui/separator"; import { useScopedT } from "../../contexts/I18nContext"; +import { useAudioOutputDevices } from "../../hooks/audioOutputDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; @@ -40,6 +43,7 @@ import { MicPopover } from "./popovers/MicPopover"; import { MorePopover } from "./popovers/MorePopover"; import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; +import { SystemAudioPopover } from "./popovers/SystemAudioPopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; import { RecordingControls } from "./RecordingControls"; @@ -72,6 +76,9 @@ function LaunchWindowContent() { setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, + systemAudioDeviceId, + systemAudioDeviceName, + setSystemAudioDevice, webcamEnabled, setWebcamEnabled, webcamDeviceId, @@ -106,6 +113,16 @@ function LaunchWindowContent() { selectedDeviceId: selectedVideoDeviceId, setSelectedDeviceId: setSelectedVideoDeviceId, } = useVideoDevices(webcamEnabled || openId === "webcam"); + const { + devices: audioOutputDevices, + selectedDeviceId: selectedAudioOutputDeviceId, + selectedDevice: selectedAudioOutputDevice, + setSelectedDeviceId: setSelectedAudioOutputDeviceId, + } = useAudioOutputDevices( + systemAudioEnabled || openId === "system-audio", + systemAudioDeviceId, + systemAudioDeviceName, + ); const { hudOverlayMousePassthroughSupported, @@ -132,6 +149,25 @@ function LaunchWindowContent() { } }, [selectedVideoDeviceId, setWebcamDeviceId]); + useEffect(() => { + if ( + systemAudioDeviceId && + audioOutputDevices.length > 0 && + selectedAudioOutputDeviceId !== systemAudioDeviceId + ) { + setSystemAudioDevice( + selectedAudioOutputDeviceId === "default" ? undefined : selectedAudioOutputDeviceId, + selectedAudioOutputDevice.label, + ); + } + }, [ + audioOutputDevices.length, + selectedAudioOutputDevice, + selectedAudioOutputDeviceId, + setSystemAudioDevice, + systemAudioDeviceId, + ]); + const { showFloatingWebcamPreview, setShowFloatingWebcamPreview, @@ -256,8 +292,6 @@ function LaunchWindowContent() { setSystemAudioEnabled(!systemAudioEnabled)} microphoneEnabled={microphoneEnabled} onDisableMicrophone={() => setMicrophoneEnabled(false)} devices={devices} @@ -289,6 +323,39 @@ function LaunchWindowContent() { } /> + setSystemAudioEnabled(!systemAudioEnabled)} + devices={audioOutputDevices} + selectedDeviceId={selectedAudioOutputDeviceId} + onSelectDevice={(device) => { + setSelectedAudioOutputDeviceId(device.deviceId); + setSystemAudioDevice(device.deviceId, device.label); + setSystemAudioEnabled(true); + }} + trigger={ + + } + /> + void; microphoneEnabled: boolean; onDisableMicrophone: () => void; devices: DeviceOption[]; @@ -52,17 +48,6 @@ export function MicPopover({ align="start" >
{t("recording.microphone")}
- : - } - selected={systemAudioEnabled} - onClick={onToggleSystemAudio} - > - {systemAudioEnabled - ? t("recording.disableSystemAudio") - : t("recording.enableSystemAudio")} - {microphoneEnabled && ( } diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index 3be3dfccc..4a36e12c8 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -1,12 +1,12 @@ import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react"; import type { ReactElement, ReactNode } from "react"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; -import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; import styles from "../LaunchWindow.module.css"; import "../launchTheme.css"; -import type { DeviceOption } from "./launchPopoverTypes"; import { useHudInteraction } from "../contexts/HudInteractionContext"; +import type { DeviceOption } from "./launchPopoverTypes"; export function DropdownItem({ onClick, @@ -28,7 +28,7 @@ export function DropdownItem({ onClick={onClick} > {icon} - {children} + {children} {trailing} ); diff --git a/src/components/launch/popovers/SystemAudioPopover.test.ts b/src/components/launch/popovers/SystemAudioPopover.test.ts new file mode 100644 index 000000000..e0f6e7400 --- /dev/null +++ b/src/components/launch/popovers/SystemAudioPopover.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { getSystemAudioLevel } from "./SystemAudioPopover"; + +describe("system audio popover meter lookup", () => { + it("returns the device level and falls back to zero", () => { + expect(getSystemAudioLevel({ "speaker-1": 72 }, "speaker-1")).toBe(72); + expect(getSystemAudioLevel({ "speaker-1": 72 }, "speaker-2")).toBe(0); + }); +}); diff --git a/src/components/launch/popovers/SystemAudioPopover.tsx b/src/components/launch/popovers/SystemAudioPopover.tsx new file mode 100644 index 000000000..712641dd9 --- /dev/null +++ b/src/components/launch/popovers/SystemAudioPopover.tsx @@ -0,0 +1,110 @@ +import { SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { AudioOutputDevice } from "@/hooks/audioOutputDevices"; +import { useAudioOutputLevels } from "@/hooks/useAudioOutputLevels"; +import styles from "../LaunchWindow.module.css"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; + +const POPOVER_ID = "system-audio"; + +export function getSystemAudioLevel(levels: Record, deviceId: string) { + return levels[deviceId] ?? 0; +} + +export function SystemAudioPopover({ + trigger, + disabled, + deviceSelectionSupported = true, + systemAudioEnabled, + onToggleSystemAudio, + devices, + selectedDeviceId, + onSelectDevice, +}: { + trigger: ReactElement; + disabled?: boolean; + deviceSelectionSupported?: boolean; + systemAudioEnabled: boolean; + onToggleSystemAudio: () => void; + devices: AudioOutputDevice[]; + selectedDeviceId?: string; + onSelectDevice: (device: AudioOutputDevice) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + const levels = useAudioOutputLevels({ + enabled: open && deviceSelectionSupported, + deviceIds: devices.map((device) => device.deviceId), + }); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + if (disabled) { + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="start" + > +
{t("recording.systemAudio", "System audio")}
+ : + } + selected={systemAudioEnabled} + onClick={onToggleSystemAudio} + > + {systemAudioEnabled + ? t("recording.turnOffSystemAudio", "Turn Off System Audio") + : t("recording.enableSystemAudio")} + + {deviceSelectionSupported && !systemAudioEnabled && ( +
+ {t("recording.selectAudioOutputToEnable", "Select an output to enable")} +
+ )} + {deviceSelectionSupported && + devices.map((device) => ( + + ) : ( + + ) + } + selected={selectedDeviceId === device.deviceId} + onClick={() => { + onSelectDevice(device); + requestClose(POPOVER_ID); + }} + trailing={ + + } + > + {device.label} + + ))} + {deviceSelectionSupported && devices.length === 0 && ( +
+ {t("recording.noAudioOutputsFound", "No audio outputs found")} +
+ )} +
+ ); +} diff --git a/src/hooks/audioOutputDevices.test.ts b/src/hooks/audioOutputDevices.test.ts new file mode 100644 index 000000000..206101e43 --- /dev/null +++ b/src/hooks/audioOutputDevices.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + type AudioOutputDevice, + enrichNativeAudioOutputLabels, + getDefaultAudioOutputLabel, + resolveAudioOutputDeviceSelection, +} from "./audioOutputDevices"; + +const devices: AudioOutputDevice[] = [ + { deviceId: "default", label: "系统默认设备", groupId: "default" }, + { deviceId: "speakers-1", label: "扬声器 (Realtek)", groupId: "group-1" }, + { deviceId: "headphones-1", label: "耳机 (USB Audio)", groupId: "group-2" }, +]; + +describe("resolveAudioOutputDeviceSelection", () => { + it("keeps a preferred output device when it is still present", () => { + expect( + resolveAudioOutputDeviceSelection(devices, "headphones-1", "耳机 (USB Audio)"), + ).toEqual({ deviceId: "headphones-1", label: "耳机 (USB Audio)" }); + }); + + it("falls back to the matching label when a browser device id changed", () => { + const currentDevices = [ + { deviceId: "new-headphones-id", label: "耳机 (USB Audio)", groupId: "group-2" }, + devices[0], + ]; + + expect( + resolveAudioOutputDeviceSelection(currentDevices, "headphones-1", "耳机 (USB Audio)"), + ).toEqual({ deviceId: "new-headphones-id", label: "耳机 (USB Audio)" }); + }); + + it("uses the default output when no preferred device is available", () => { + expect(resolveAudioOutputDeviceSelection(devices, "missing", "已拔出设备")).toEqual({ + deviceId: "default", + label: "系统默认设备", + }); + }); + + it("returns the first output when the platform does not expose a default entry", () => { + const physicalOnly = devices.slice(1); + expect(resolveAudioOutputDeviceSelection(physicalOnly, undefined, undefined)).toEqual({ + deviceId: "speakers-1", + label: "扬声器 (Realtek)", + }); + }); + + it("returns the default selection when no outputs are exposed", () => { + expect(resolveAudioOutputDeviceSelection([], "missing", "已拔出设备")).toEqual({ + deviceId: "default", + label: "Default output", + }); + }); +}); + +describe("enrichNativeAudioOutputLabels", () => { + it("uses the browser label with the USB vendor and product ID", () => { + const nativeDevices = [{ deviceId: "native-yeti", label: "扬声器 (Yeti Nano)" }]; + const browserDevices = [ + { + deviceId: "browser-yeti", + label: "Default - 扬声器 (Yeti Nano) (046d:0acf)", + groupId: "", + }, + ]; + + expect(enrichNativeAudioOutputLabels(nativeDevices, browserDevices)).toEqual([ + { deviceId: "native-yeti", label: "扬声器 (Yeti Nano) (046d:0acf)" }, + ]); + }); + + it("keeps the native label when no matching USB label is available", () => { + const nativeDevices = [{ deviceId: "native-realtek", label: "扬声器 (Realtek(R) Audio)" }]; + const browserDevices = [ + { + deviceId: "browser-other", + label: "扬声器 (Yeti Nano) (046d:0acf)", + groupId: "", + }, + { + deviceId: "browser-realtek", + label: "扬声器 (Realtek(R) Audio)", + groupId: "", + }, + ]; + + expect(enrichNativeAudioOutputLabels(nativeDevices, browserDevices)).toEqual([ + { deviceId: "native-realtek", label: "扬声器 (Realtek(R) Audio)" }, + ]); + }); + + it("does not confuse similarly named output devices", () => { + const nativeDevices = [{ deviceId: "native-yeti", label: "扬声器 (Yeti Nano)" }]; + const browserDevices = [ + { + deviceId: "browser-yeti-pro", + label: "扬声器 (Yeti Nano Pro) (046d:0ad0)", + groupId: "", + }, + ]; + + expect(enrichNativeAudioOutputLabels(nativeDevices, browserDevices)).toEqual([ + { deviceId: "native-yeti", label: "扬声器 (Yeti Nano)" }, + ]); + }); +}); + +describe("getDefaultAudioOutputLabel", () => { + it("keeps the Default role and enriches the selected output label", () => { + const browserDevices = [ + { + deviceId: "default", + label: "Default - 扬声器 (Yeti Nano) (046D:0ACF)", + groupId: "", + }, + ]; + + expect(getDefaultAudioOutputLabel(browserDevices)).toBe( + "Default - 扬声器 (Yeti Nano) (046d:0acf)", + ); + }); + + it("falls back to the generic label when the browser exposes no default output", () => { + expect( + getDefaultAudioOutputLabel([ + { deviceId: "speaker-1", label: "扬声器 (Yeti Nano)", groupId: "" }, + ]), + ).toBe("Default output"); + }); + + it("does not treat the blank-label placeholder as a real default name", () => { + expect( + getDefaultAudioOutputLabel([ + { deviceId: "default", label: "Output default", groupId: "" }, + ]), + ).toBe("Default output"); + }); +}); diff --git a/src/hooks/audioOutputDevices.ts b/src/hooks/audioOutputDevices.ts new file mode 100644 index 000000000..2a20830e9 --- /dev/null +++ b/src/hooks/audioOutputDevices.ts @@ -0,0 +1,234 @@ +import { useEffect, useState } from "react"; + +export interface AudioOutputDevice { + deviceId: string; + label: string; + groupId: string; +} + +export interface AudioOutputDeviceSelection { + deviceId: string; + label: string; +} + +export interface NativeAudioOutputDevice { + deviceId: string; + label: string; +} + +const DEFAULT_OUTPUT_DEVICE: AudioOutputDevice = { + deviceId: "default", + label: "Default output", + groupId: "", +}; + +export function resolveAudioOutputDeviceSelection( + devices: AudioOutputDevice[], + preferredDeviceId?: string, + preferredLabel?: string, +): AudioOutputDeviceSelection { + if (preferredDeviceId) { + const exactMatch = devices.find((device) => device.deviceId === preferredDeviceId); + if (exactMatch) { + return { deviceId: exactMatch.deviceId, label: exactMatch.label }; + } + } + + if (preferredLabel) { + const labelMatch = devices.find((device) => device.label === preferredLabel); + if (labelMatch) { + return { deviceId: labelMatch.deviceId, label: labelMatch.label }; + } + } + + const defaultDevice = devices.find((device) => device.deviceId === "default"); + if (defaultDevice) { + return { deviceId: defaultDevice.deviceId, label: defaultDevice.label }; + } + + const firstDevice = devices[0]; + return firstDevice + ? { deviceId: firstDevice.deviceId, label: firstDevice.label } + : { deviceId: DEFAULT_OUTPUT_DEVICE.deviceId, label: DEFAULT_OUTPUT_DEVICE.label }; +} + +function mapAudioOutputDevices(mediaDevices: MediaDeviceInfo[]): AudioOutputDevice[] { + return mediaDevices + .filter((device) => device.kind === "audiooutput") + .map((device) => ({ + deviceId: device.deviceId, + label: device.label || `Output ${device.deviceId.slice(0, 8)}`, + groupId: device.groupId, + })); +} + +const AUDIO_OUTPUT_ROLE_PREFIX = /^(?:default|communications)\s*-\s*/i; +const AUDIO_OUTPUT_USB_ID_SUFFIX = /\s+\(([0-9a-f]{4}):([0-9a-f]{4})\)$/i; + +function normalizeBrowserAudioOutputLabel(label: string): string | null { + const withoutRolePrefix = label.trim().replace(AUDIO_OUTPUT_ROLE_PREFIX, ""); + const usbIdMatch = withoutRolePrefix.match(AUDIO_OUTPUT_USB_ID_SUFFIX); + if (!usbIdMatch || usbIdMatch.index === undefined) { + return null; + } + + const baseLabel = withoutRolePrefix.slice(0, usbIdMatch.index).trim(); + return `${baseLabel} (${usbIdMatch[1].toLowerCase()}:${usbIdMatch[2].toLowerCase()})`; +} + +export function getDefaultAudioOutputLabel(browserDevices: AudioOutputDevice[]): string { + const browserDefaultDevice = browserDevices.find( + (device) => device.deviceId === "default" || /^default\s*-\s*/i.test(device.label), + ); + if (!browserDefaultDevice) { + return DEFAULT_OUTPUT_DEVICE.label; + } + + const rawLabel = browserDefaultDevice.label.trim(); + if (!rawLabel || /^output\s+default$/i.test(rawLabel)) { + return DEFAULT_OUTPUT_DEVICE.label; + } + + const normalizedLabel = + normalizeBrowserAudioOutputLabel(browserDefaultDevice.label) ?? + browserDefaultDevice.label.trim().replace(AUDIO_OUTPUT_ROLE_PREFIX, "").trim(); + return `Default - ${normalizedLabel}`; +} + +export function enrichNativeAudioOutputLabels( + nativeDevices: NativeAudioOutputDevice[], + browserDevices: AudioOutputDevice[], +): NativeAudioOutputDevice[] { + return nativeDevices.map((nativeDevice) => { + const nativeLabel = nativeDevice.label.trim(); + const matchingBrowserDevice = browserDevices.find((browserDevice) => { + const enrichedLabel = normalizeBrowserAudioOutputLabel(browserDevice.label); + if (!enrichedLabel) { + return false; + } + + const usbIdStart = enrichedLabel.lastIndexOf(" ("); + return enrichedLabel.slice(0, usbIdStart).trim() === nativeLabel; + }); + + if (!matchingBrowserDevice) { + return nativeDevice; + } + + return { + ...nativeDevice, + label: + normalizeBrowserAudioOutputLabel(matchingBrowserDevice.label) ?? nativeDevice.label, + }; + }); +} + +function mapNativeAudioOutputDevices( + devices: NativeAudioOutputDevice[], + browserDevices: AudioOutputDevice[], +): AudioOutputDevice[] { + return enrichNativeAudioOutputLabels(devices, browserDevices).map((device) => ({ + deviceId: device.deviceId, + label: device.label, + groupId: "", + })); +} + +export function useAudioOutputDevices( + enabled: boolean = true, + preferredDeviceId?: string, + preferredLabel?: string, +) { + const [devices, setDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceId] = useState("default"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled || !navigator.mediaDevices) { + return; + } + + let mounted = true; + const loadDevices = async () => { + try { + setIsLoading(true); + setError(null); + const browserOutputs = mapAudioOutputDevices( + await navigator.mediaDevices.enumerateDevices(), + ); + let outputs = browserOutputs; + try { + const nativeOutputs = await window.electronAPI?.getNativeAudioOutputDevices?.(); + if (nativeOutputs && nativeOutputs.length > 0) { + outputs = mapNativeAudioOutputDevices(nativeOutputs, browserOutputs); + } + } catch { + // Browser enumeration remains a usable fallback when the native helper is unavailable. + } + + if (!outputs.some((device) => device.deviceId === "default")) { + outputs = [ + { + ...DEFAULT_OUTPUT_DEVICE, + label: getDefaultAudioOutputLabel(browserOutputs), + }, + ...outputs, + ]; + } + + if (!mounted) { + return; + } + + setDevices(outputs); + setSelectedDeviceId((currentDeviceId) => { + const selection = resolveAudioOutputDeviceSelection( + outputs, + preferredDeviceId ?? currentDeviceId, + preferredLabel, + ); + return selection.deviceId; + }); + setIsLoading(false); + } catch (loadError) { + if (!mounted) { + return; + } + const message = + loadError instanceof Error + ? loadError.message + : "Failed to enumerate audio output devices"; + setError(message); + setIsLoading(false); + console.error("Error loading audio output devices:", loadError); + } + }; + + void loadDevices(); + const handleDeviceChange = () => { + void loadDevices(); + }; + navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + + return () => { + mounted = false; + navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + }; + }, [enabled, preferredDeviceId, preferredLabel]); + + const selectedDevice = resolveAudioOutputDeviceSelection( + devices, + selectedDeviceId, + preferredLabel, + ); + + return { + devices, + selectedDeviceId: selectedDevice.deviceId, + selectedDevice, + setSelectedDeviceId, + isLoading, + error, + }; +} diff --git a/src/hooks/useAudioOutputLevels.test.ts b/src/hooks/useAudioOutputLevels.test.ts new file mode 100644 index 000000000..897ec7ae0 --- /dev/null +++ b/src/hooks/useAudioOutputLevels.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + mergeAudioOutputLevel, + normalizeAudioOutputLevelEvent, +} from "./useAudioOutputLevels"; + +describe("system audio level mapping", () => { + it("normalizes a valid event under its exact device ID", () => { + const event = normalizeAudioOutputLevelEvent({ + deviceId: "speaker-1", + rms: 0.2, + peak: 0.4, + level: 40, + }); + + expect(event).toEqual({ + deviceId: "speaker-1", + rms: 0.2, + peak: 0.4, + level: 40, + }); + expect(mergeAudioOutputLevel({}, event!)).toEqual({ "speaker-1": 40 }); + }); + + it("rejects malformed events and clamps out-of-range levels", () => { + expect(normalizeAudioOutputLevelEvent({ deviceId: "", level: 40 })).toBeNull(); + expect(normalizeAudioOutputLevelEvent({ deviceId: "speaker-1", level: Number.NaN })).toBeNull(); + expect( + normalizeAudioOutputLevelEvent({ + deviceId: "speaker-1", + rms: 0, + peak: 0, + level: 150, + }), + ).toMatchObject({ deviceId: "speaker-1", level: 100 }); + }); + + it("does not mutate previous device entries", () => { + const previous = { "speaker-1": 20 }; + const next = mergeAudioOutputLevel(previous, { + deviceId: "speaker-2", + rms: 0.1, + peak: 0.1, + level: 10, + }); + + expect(previous).toEqual({ "speaker-1": 20 }); + expect(next).toEqual({ "speaker-1": 20, "speaker-2": 10 }); + }); +}); diff --git a/src/hooks/useAudioOutputLevels.ts b/src/hooks/useAudioOutputLevels.ts new file mode 100644 index 000000000..f566f84cf --- /dev/null +++ b/src/hooks/useAudioOutputLevels.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; + +export interface AudioOutputLevelEvent { + deviceId: string; + rms: number; + peak: number; + level: number; +} + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +export function normalizeAudioOutputLevelEvent(event: unknown): AudioOutputLevelEvent | null { + if (!event || typeof event !== "object") return null; + const candidate = event as Partial; + if ( + typeof candidate.deviceId !== "string" || + candidate.deviceId.length === 0 || + typeof candidate.rms !== "number" || + !Number.isFinite(candidate.rms) || + typeof candidate.peak !== "number" || + !Number.isFinite(candidate.peak) || + typeof candidate.level !== "number" || + !Number.isFinite(candidate.level) + ) { + return null; + } + + return { + deviceId: candidate.deviceId, + rms: clamp(candidate.rms, 0, 1), + peak: clamp(candidate.peak, 0, 1), + level: clamp(candidate.level, 0, 100), + }; +} + +export function mergeAudioOutputLevel( + levels: Record, + event: AudioOutputLevelEvent, +): Record { + return { ...levels, [event.deviceId]: event.level }; +} + +export function useAudioOutputLevels(options: { + enabled: boolean; + deviceIds: string[]; +}): Record { + const [levels, setLevels] = useState>({}); + const deviceFingerprint = options.deviceIds.join("\u0000"); + + useEffect(() => { + let mounted = true; + setLevels({}); + + const api = + typeof window !== "undefined" && window.electronAPI + ? window.electronAPI + : null; + if ( + !options.enabled || + !api?.startAudioOutputLevelMonitor || + !api.stopAudioOutputLevelMonitor || + !api.onAudioOutputLevel + ) { + return () => { + mounted = false; + setLevels({}); + }; + } + + const unsubscribe = api.onAudioOutputLevel((payload) => { + if (!mounted) return; + const event = normalizeAudioOutputLevelEvent(payload); + if (!event) return; + setLevels((currentLevels) => mergeAudioOutputLevel(currentLevels, event)); + }); + + const monitorDeviceFingerprint = deviceFingerprint; + void api.startAudioOutputLevelMonitor().then((result) => { + if (!result.success) { + console.warn( + "System audio level monitor unavailable:", + result.error, + monitorDeviceFingerprint, + ); + } + }); + + return () => { + mounted = false; + unsubscribe(); + void api.stopAudioOutputLevelMonitor(); + setLevels({}); + }; + }, [deviceFingerprint, options.enabled]); + + return levels; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 459652415..7fe6d5890 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -143,6 +143,9 @@ type UseScreenRecorderReturn = { setMicrophoneDeviceId: (deviceId: string | undefined) => void; systemAudioEnabled: boolean; setSystemAudioEnabled: (enabled: boolean) => void; + systemAudioDeviceId: string | undefined; + systemAudioDeviceName: string | undefined; + setSystemAudioDevice: (deviceId: string | undefined, deviceName?: string) => void; webcamEnabled: boolean; setWebcamEnabled: (enabled: boolean) => void; webcamDeviceId: string | undefined; @@ -383,6 +386,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); + const [systemAudioDeviceId, setSystemAudioDeviceId] = useState(undefined); + const [systemAudioDeviceName, setSystemAudioDeviceName] = useState( + undefined, + ); const [webcamEnabled, setWebcamEnabled] = useState(false); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); const [countdownDelay, setCountdownDelayState] = useState(3); @@ -1200,12 +1207,26 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } + let systemAudioLabel: string | undefined = systemAudioDeviceName; + if ((useNativeMacScreenCapture || useNativeWindowsCapture) && systemAudioEnabled) { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const output = devices.find( + (d) => d.deviceId === systemAudioDeviceId && d.kind === "audiooutput", + ); + systemAudioLabel = output?.label || systemAudioLabel; + } catch { + // Native capture falls back to the default output when the selected endpoint is unavailable. + } + } + return { platform, selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel, + systemAudioLabel, }; }, [ logNativeCaptureDiagnostics, @@ -1214,6 +1235,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { preparePermissions, prepareWebcamRecorder, resetRecordingClock, + systemAudioDeviceId, + systemAudioDeviceName, + systemAudioEnabled, ]); const discardActiveNativeCapture = useCallback(async () => { @@ -1533,6 +1557,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneDeviceId(result.microphoneDeviceId); } setSystemAudioEnabled(result.systemAudioEnabled); + setSystemAudioDeviceId(result.systemAudioDeviceId); + setSystemAudioDeviceName(result.systemAudioDeviceName); setWebcamEnabled(result.webcamEnabled); if (result.webcamDeviceId) { setWebcamDeviceId(result.webcamDeviceId); @@ -1556,6 +1582,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled }); }, []); + const persistSystemAudioDevice = useCallback( + (deviceId: string | undefined, deviceName?: string) => { + const normalizedDeviceId = deviceId && deviceId !== "default" ? deviceId : undefined; + const normalizedDeviceName = normalizedDeviceId ? deviceName : undefined; + setSystemAudioDeviceId(normalizedDeviceId); + setSystemAudioDeviceName(normalizedDeviceName); + void window.electronAPI.setRecordingPreferences({ + systemAudioDeviceId: normalizedDeviceId, + systemAudioDeviceName: normalizedDeviceName, + }); + }, + [], + ); + const persistWebcamEnabled = useCallback((enabled: boolean) => { setWebcamEnabled(enabled); void window.electronAPI.setRecordingPreferences({ webcamEnabled: enabled }); @@ -1688,8 +1728,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } = - preparedStart; + const { + selectedSource, + useNativeMacScreenCapture, + useNativeWindowsCapture, + micLabel, + systemAudioLabel, + } = preparedStart; const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { @@ -1715,6 +1760,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { selectedSource, { capturesSystemAudio: systemAudioEnabled, + systemAudioDeviceId, + systemAudioDeviceName: systemAudioLabel, capturesMicrophone: microphoneEnabled, microphoneDeviceId, microphoneLabel: micLabel, @@ -2440,6 +2487,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneDeviceId: persistMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled: persistSystemAudioEnabled, + systemAudioDeviceId, + systemAudioDeviceName, + setSystemAudioDevice: persistSystemAudioDevice, webcamEnabled, setWebcamEnabled: persistWebcamEnabled, webcamDeviceId, diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index bae512379..2381f206c 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -25,9 +25,13 @@ "window": "Fenster", "noSourcesFound": "Keine Quellen gefunden", "microphone": "Mikrofon", + "systemAudio": "Systemaudio", "turnOffMicrophone": "Mikrofon ausschalten", + "turnOffSystemAudio": "System-Audio ausschalten", "selectMicToEnable": "Mikrofon zum Aktivieren auswählen", + "selectAudioOutputToEnable": "Audioausgabe zum Aktivieren auswählen", "noMicrophonesFound": "Keine Mikrofone gefunden", + "noAudioOutputsFound": "Keine Audioausgabegeräte gefunden", "webcam": "Webcam", "turnOffWebcam": "Webcam ausschalten", "hideFloatingWebcamPreview": "Schwebende Vorschau ausblenden", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index d4f7aba94..3752b06b8 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -25,9 +25,13 @@ "window": "Window", "noSourcesFound": "No sources found", "microphone": "Microphone", + "systemAudio": "System audio", "turnOffMicrophone": "Turn Off Microphone", + "turnOffSystemAudio": "Turn Off System Audio", "selectMicToEnable": "Select a microphone to enable", + "selectAudioOutputToEnable": "Select an audio output to enable", "noMicrophonesFound": "No microphones found", + "noAudioOutputsFound": "No audio outputs found", "webcam": "Webcam", "turnOffWebcam": "Turn Off Webcam", "hideFloatingWebcamPreview": "Hide Floating Preview", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 4edc2d9c6..43ca3e91e 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -25,9 +25,13 @@ "window": "Ventana", "noSourcesFound": "No se encontraron fuentes", "microphone": "Micrófono", + "systemAudio": "Audio del sistema", "turnOffMicrophone": "Desactivar micrófono", + "turnOffSystemAudio": "Desactivar audio del sistema", "selectMicToEnable": "Selecciona un micrófono para activar", + "selectAudioOutputToEnable": "Selecciona una salida de audio para activarla", "noMicrophonesFound": "No se encontraron micrófonos", + "noAudioOutputsFound": "No se encontraron salidas de audio", "webcam": "Cámara", "turnOffWebcam": "Desactivar cámara", "hideFloatingWebcamPreview": "Ocultar vista flotante", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 2969355fa..083426afa 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -25,9 +25,13 @@ "window": "Fenêtre", "noSourcesFound": "Aucune source trouvée", "microphone": "Microphone", + "systemAudio": "Audio système", "turnOffMicrophone": "Désactiver le microphone", + "turnOffSystemAudio": "Désactiver l’audio système", "selectMicToEnable": "Sélectionnez un microphone à activer", + "selectAudioOutputToEnable": "Sélectionnez une sortie audio à activer", "noMicrophonesFound": "Aucun microphone trouvé", + "noAudioOutputsFound": "Aucune sortie audio trouvée", "webcam": "Webcam", "turnOffWebcam": "Désactiver la webcam", "hideFloatingWebcamPreview": "Masquer l’aperçu flottant", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 0dbdbea34..487ba2499 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -25,9 +25,13 @@ "window": "Finestra", "noSourcesFound": "Nessuna sorgente trovata", "microphone": "Microfono", + "systemAudio": "Audio di sistema", "turnOffMicrophone": "Spegni microfono", + "turnOffSystemAudio": "Disabilita audio di sistema", "selectMicToEnable": "Seleziona un microfono da abilitare", + "selectAudioOutputToEnable": "Seleziona un’uscita audio da abilitare", "noMicrophonesFound": "Nessun microfono trovato", + "noAudioOutputsFound": "Nessuna uscita audio trovata", "webcam": "Webcam", "turnOffWebcam": "Spegni webcam", "hideFloatingWebcamPreview": "Nascondi anteprima fluttuante", diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 2e4683a56..ba6cbeff0 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -25,9 +25,13 @@ "window": "창", "noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다", "microphone": "마이크", + "systemAudio": "시스템 오디오", "turnOffMicrophone": "마이크 끄기", + "turnOffSystemAudio": "시스템 오디오 끄기", "selectMicToEnable": "사용할 마이크를 선택하세요", + "selectAudioOutputToEnable": "활성화할 오디오 출력 선택", "noMicrophonesFound": "마이크를 찾을 수 없습니다", + "noAudioOutputsFound": "오디오 출력을 찾을 수 없습니다", "webcam": "웹캠", "turnOffWebcam": "웹캠 끄기", "hideFloatingWebcamPreview": "플로팅 미리보기 숨기기", diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index 774c7335a..13841229c 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -25,9 +25,13 @@ "window": "Venster", "noSourcesFound": "Geen bronnen gevonden", "microphone": "Microfoon", + "systemAudio": "Systeemaudio", "turnOffMicrophone": "Microfoon uitschakelen", + "turnOffSystemAudio": "Systeemaudio uitschakelen", "selectMicToEnable": "Selecteer een microfoon om in te schakelen", + "selectAudioOutputToEnable": "Selecteer een audio-uitgang om in te schakelen", "noMicrophonesFound": "Geen microfoons gevonden", + "noAudioOutputsFound": "Geen audio-uitgangen gevonden", "webcam": "Webcam", "turnOffWebcam": "Webcam uitschakelen", "hideFloatingWebcamPreview": "Zwevende voorbeeldweergave verbergen", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index cc8c35c7e..cf2c7ef69 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -25,9 +25,13 @@ "window": "Janela", "noSourcesFound": "Nenhuma fonte encontrada", "microphone": "Microfone", + "systemAudio": "Áudio do sistema", "turnOffMicrophone": "Desligar microfone", + "turnOffSystemAudio": "Desativar áudio do sistema", "selectMicToEnable": "Selecione um microfone para ativar", + "selectAudioOutputToEnable": "Selecione uma saída de áudio para ativar", "noMicrophonesFound": "Nenhum microfone encontrado", + "noAudioOutputsFound": "Nenhuma saída de áudio encontrada", "webcam": "Webcam", "turnOffWebcam": "Desligar webcam", "hideFloatingWebcamPreview": "Ocultar prévia flutuante", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 47531cfa1..f430c761d 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -25,9 +25,13 @@ "window": "Окно", "noSourcesFound": "Источники изображения не найдены", "microphone": "Микрофон", + "systemAudio": "Системный звук", "turnOffMicrophone": "Выключить микрофон", + "turnOffSystemAudio": "Отключить системный звук", "selectMicToEnable": "Выберите микрофон для записи звука", + "selectAudioOutputToEnable": "Выберите аудиовыход для включения", "noMicrophonesFound": "Нет доступных микрофонов", + "noAudioOutputsFound": "Аудиовыходы не найдены", "webcam": "Веб-камера", "turnOffWebcam": "Выключить веб-камеру", "hideFloatingWebcamPreview": "Скрыть предпросмотр", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 2b88c6d70..1d85c91e9 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -25,9 +25,13 @@ "window": "窗口", "noSourcesFound": "未找到源", "microphone": "麦克风", + "systemAudio": "系统音频", "turnOffMicrophone": "关闭麦克风", + "turnOffSystemAudio": "关闭系统音频", "selectMicToEnable": "选择一个麦克风以启用", + "selectAudioOutputToEnable": "选择一个系统音频输出设备以启用", "noMicrophonesFound": "未找到麦克风", + "noAudioOutputsFound": "未找到系统音频输出设备", "webcam": "摄像头", "turnOffWebcam": "关闭摄像头", "hideFloatingWebcamPreview": "隐藏悬浮预览", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 9d44d3218..aa279e49c 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -25,9 +25,13 @@ "window": "視窗", "noSourcesFound": "找不到可錄製的來源", "microphone": "麥克風", + "systemAudio": "系統音訊", "turnOffMicrophone": "關閉麥克風", + "turnOffSystemAudio": "停用系統音訊", "selectMicToEnable": "選擇要啟用的麥克風", + "selectAudioOutputToEnable": "選擇要啟用的音訊輸出", "noMicrophonesFound": "找不到麥克風", + "noAudioOutputsFound": "找不到音訊輸出裝置", "webcam": "網路攝影機", "turnOffWebcam": "關閉網路攝影機", "hideFloatingWebcamPreview": "隱藏浮動預覽", diff --git a/vitest.config.ts b/vitest.config.ts index 0ff0492ff..aaa14b848 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ include: [ "src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", "electron/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", + "scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", ], }, resolve: { From 070799fe6e71a4c20d9a65dc6aafa48c144089fa Mon Sep 17 00:00:00 2001 From: OrangeChange <95136820+OrangeChange@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:54:15 +0800 Subject: [PATCH 2/4] fix(launch): keep system audio control visible while recording --- src/components/launch/LaunchWindow.tsx | 1 + src/components/launch/RecordingControls.tsx | 24 ++++++- .../launch/RecordingControls.ui.test.tsx | 65 +++++++++++++++++++ .../launch/popovers/SystemAudioPopover.tsx | 1 - .../popovers/SystemAudioPopover.ui.test.tsx | 48 ++++++++++++++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/components/launch/RecordingControls.ui.test.tsx create mode 100644 src/components/launch/popovers/SystemAudioPopover.ui.test.tsx diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 5cf36f4cd..14ca0dc11 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -247,6 +247,7 @@ function LaunchWindowContent() { setMicrophoneEnabled(!microphoneEnabled)} onPauseResume={paused ? resumeRecording : pauseRecording} diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx index d7ed41a75..3803c3d05 100644 --- a/src/components/launch/RecordingControls.tsx +++ b/src/components/launch/RecordingControls.tsx @@ -4,17 +4,20 @@ import { MinusIcon, PauseIcon, PlayIcon, + SpeakerHighIcon, + SpeakerXIcon, XIcon, } from "@phosphor-icons/react"; import { useMemo } from "react"; -import { useScopedT } from "@/contexts/I18nContext"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { useScopedT } from "@/contexts/I18nContext"; import styles from "./LaunchWindow.module.css"; interface RecordingControlsProps { paused: boolean; microphoneEnabled: boolean; + systemAudioEnabled: boolean; elapsed: number; onToggleMicrophone: () => void; onPauseResume: () => void; @@ -27,6 +30,7 @@ interface RecordingControlsProps { export const RecordingControls = ({ paused, microphoneEnabled, + systemAudioEnabled, elapsed, onToggleMicrophone, onPauseResume, @@ -83,6 +87,23 @@ export const RecordingControls = ({ + + + + + ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () => , +})); + +vi.mock("@phosphor-icons/react", () => ({ + MicrophoneIcon: () => , + MicrophoneSlashIcon: () => , + SpeakerHighIcon: () => , + SpeakerXIcon: () => , + MinusIcon: () => , + PauseIcon: () => , + PlayIcon: () => , + XIcon: () => , +})); + +describe("RecordingControls audio indicators", () => { + it("keeps a disabled system-audio indicator beside the microphone while recording", () => { + const html = renderToStaticMarkup( + undefined} + onPauseResume={() => undefined} + onStopRecording={() => undefined} + onHideHud={() => undefined} + onCancelRecording={() => undefined} + formatTime={(seconds) => `${seconds}s`} + />, + ); + + expect(html).toContain('data-testid="microphone-icon"'); + expect(html).toContain('data-testid="speaker-high-icon"'); + expect(html.match(/disabled=""/g)).toHaveLength(2); + }); +}); diff --git a/src/components/launch/popovers/SystemAudioPopover.tsx b/src/components/launch/popovers/SystemAudioPopover.tsx index 712641dd9..5c1015a97 100644 --- a/src/components/launch/popovers/SystemAudioPopover.tsx +++ b/src/components/launch/popovers/SystemAudioPopover.tsx @@ -62,7 +62,6 @@ export function SystemAudioPopover({ icon={ systemAudioEnabled ? : } - selected={systemAudioEnabled} onClick={onToggleSystemAudio} > {systemAudioEnabled diff --git a/src/components/launch/popovers/SystemAudioPopover.ui.test.tsx b/src/components/launch/popovers/SystemAudioPopover.ui.test.tsx new file mode 100644 index 000000000..3c0b7bdbd --- /dev/null +++ b/src/components/launch/popovers/SystemAudioPopover.ui.test.tsx @@ -0,0 +1,48 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { SystemAudioPopover } from "./SystemAudioPopover"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); + +vi.mock("@/hooks/useAudioOutputLevels", () => ({ + useAudioOutputLevels: () => ({}), +})); + +vi.mock("./LaunchPopoverCoordinator", () => ({ + useLaunchPopoverCoordinator: () => ({ + isOpen: () => true, + requestOpen: vi.fn(), + requestClose: vi.fn(), + }), +})); + +vi.mock("./PopoverScaffold", () => ({ + DropdownItem: ({ children, selected }: { children: string; selected?: boolean }) => ( + + ), + HudPopover: ({ children }: { children: unknown }) =>
{children}
, +})); + +describe("SystemAudioPopover UI states", () => { + it("does not highlight the turn-off row when system audio is enabled", () => { + const html = renderToStaticMarkup( + } + systemAudioEnabled + onToggleSystemAudio={() => undefined} + devices={[]} + onSelectDevice={() => undefined} + />, + ); + + expect(html).toContain('data-testid="dropdown-item" data-selected="false"'); + }); +}); From 8d90d0b929b20b7da1fe058be83d4f7dba0671d8 Mon Sep 17 00:00:00 2001 From: OrangeChange <95136820+OrangeChange@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:20:31 +0800 Subject: [PATCH 3/4] feat(i18n): localize UI and explain system audio lock --- .../zh-CN-localization-changes.md | 99 +++++++++ electron/electron-env.d.ts | 1 + electron/ipc/register/captions.ts | 33 +-- electron/ipc/register/export.ts | 15 +- electron/ipc/register/project.ts | 23 ++- electron/ipc/register/settings.ts | 7 + electron/main.ts | 10 +- electron/nativeDialogLocale.test.ts | 47 +++++ electron/nativeDialogLocale.ts | 188 +++++++++++++++++ electron/preload.ts | 3 + electron/updater.ts | 65 +++--- src/components/launch/LaunchWindow.tsx | 6 +- src/components/launch/RecordingControls.tsx | 4 +- .../launch/RecordingControls.ui.test.tsx | 2 + src/components/launch/SourceSelector.tsx | 22 +- src/components/launch/UpdateToastWindow.tsx | 6 +- .../launch/popovers/MorePopover.tsx | 21 +- src/components/launch/sourceLabel.test.ts | 31 +++ src/components/launch/sourceLabel.ts | 35 ++++ src/components/ui/dialog.tsx | 42 ++-- .../video-editor/AnnotationOverlay.tsx | 8 +- .../video-editor/AnnotationSettingsPanel.tsx | 19 +- .../video-editor/ExtensionManager.tsx | 14 +- .../video-editor/KeyboardShortcutsHelp.tsx | 15 +- .../video-editor/ProjectBrowserDialog.tsx | 32 +-- src/components/video-editor/SettingsPanel.tsx | 60 ++++-- .../video-editor/ShortcutsConfigDialog.tsx | 28 ++- src/components/video-editor/TutorialHelp.tsx | 22 +- .../audio/useSourceAudioFallback.ts | 11 +- .../video-editor/audio/useVideoEditorAudio.ts | 4 + .../captions/useAutoCaptionController.ts | 49 ++++- .../export/exportRunnerSupport.ts | 63 ++++-- .../export/useEditorExportController.ts | 2 + .../export/useExportDialogActions.ts | 63 ++++-- .../video-editor/export/useExportRunner.ts | 80 ++++++-- .../hooks/useAnnotationRegionCommands.ts | 4 +- .../hooks/useTimelineEditingController.ts | 16 +- .../video-editor/layout/EditorExportMenu.tsx | 19 +- .../layout/EditorPreviewPanel.tsx | 16 +- .../video-editor/layout/EditorShell.tsx | 6 +- .../project/useEditorProjectController.ts | 2 + .../project/useProjectOpenActions.ts | 49 ++++- .../project/useProjectSaveActions.ts | 44 +++- src/components/video-editor/timeline/Item.tsx | 20 +- .../video-editor/timeline/TimelineEditor.tsx | 8 +- .../components/toolbar/TimelineToolbar.tsx | 30 ++- .../hooks/actions/useTimelineAudioActions.ts | 26 ++- .../actions/useTimelineCaptionActions.ts | 9 +- .../hooks/actions/useTimelineZoomActions.ts | 54 +++-- .../hooks/useTimelineEditorRuntime.ts | 6 + src/contexts/I18nContext.tsx | 9 +- src/hooks/useScreenRecorder.ts | 5 +- src/i18n/i18nLocale.test.ts | 124 +++++++++++ src/i18n/locales/de/common.json | 18 +- src/i18n/locales/de/dialogs.json | 4 +- src/i18n/locales/de/editor.json | 137 ++++++++++++- src/i18n/locales/de/launch.json | 13 +- src/i18n/locales/de/settings.json | 87 +++++++- src/i18n/locales/de/shortcuts.json | 1 + src/i18n/locales/de/timeline.json | 36 +++- src/i18n/locales/en/common.json | 18 +- src/i18n/locales/en/dialogs.json | 4 +- src/i18n/locales/en/editor.json | 138 ++++++++++++- src/i18n/locales/en/launch.json | 13 +- src/i18n/locales/en/settings.json | 89 +++++++- src/i18n/locales/en/shortcuts.json | 1 + src/i18n/locales/en/timeline.json | 36 +++- src/i18n/locales/es/common.json | 18 +- src/i18n/locales/es/dialogs.json | 4 +- src/i18n/locales/es/editor.json | 138 ++++++++++++- src/i18n/locales/es/launch.json | 13 +- src/i18n/locales/es/settings.json | 87 +++++++- src/i18n/locales/es/shortcuts.json | 1 + src/i18n/locales/es/timeline.json | 36 +++- src/i18n/locales/fr/common.json | 18 +- src/i18n/locales/fr/dialogs.json | 4 +- src/i18n/locales/fr/editor.json | 138 ++++++++++++- src/i18n/locales/fr/launch.json | 13 +- src/i18n/locales/fr/settings.json | 87 +++++++- src/i18n/locales/fr/shortcuts.json | 1 + src/i18n/locales/fr/timeline.json | 36 +++- src/i18n/locales/it/common.json | 18 +- src/i18n/locales/it/dialogs.json | 4 +- src/i18n/locales/it/editor.json | 138 ++++++++++++- src/i18n/locales/it/launch.json | 13 +- src/i18n/locales/it/settings.json | 87 +++++++- src/i18n/locales/it/shortcuts.json | 1 + src/i18n/locales/it/timeline.json | 36 +++- src/i18n/locales/ko/common.json | 18 +- src/i18n/locales/ko/dialogs.json | 4 +- src/i18n/locales/ko/editor.json | 139 ++++++++++++- src/i18n/locales/ko/launch.json | 13 +- src/i18n/locales/ko/settings.json | 87 +++++++- src/i18n/locales/ko/shortcuts.json | 1 + src/i18n/locales/ko/timeline.json | 36 +++- src/i18n/locales/nl/common.json | 18 +- src/i18n/locales/nl/dialogs.json | 4 +- src/i18n/locales/nl/editor.json | 139 ++++++++++++- src/i18n/locales/nl/launch.json | 13 +- src/i18n/locales/nl/settings.json | 87 +++++++- src/i18n/locales/nl/shortcuts.json | 1 + src/i18n/locales/nl/timeline.json | 36 +++- src/i18n/locales/pt-BR/common.json | 18 +- src/i18n/locales/pt-BR/dialogs.json | 4 +- src/i18n/locales/pt-BR/editor.json | 138 ++++++++++++- src/i18n/locales/pt-BR/launch.json | 13 +- src/i18n/locales/pt-BR/settings.json | 87 +++++++- src/i18n/locales/pt-BR/shortcuts.json | 1 + src/i18n/locales/pt-BR/timeline.json | 36 +++- src/i18n/locales/ru/common.json | 18 +- src/i18n/locales/ru/dialogs.json | 4 +- src/i18n/locales/ru/editor.json | 138 ++++++++++++- src/i18n/locales/ru/launch.json | 13 +- src/i18n/locales/ru/settings.json | 87 +++++++- src/i18n/locales/ru/shortcuts.json | 1 + src/i18n/locales/ru/timeline.json | 36 +++- src/i18n/locales/zh-CN/common.json | 18 +- src/i18n/locales/zh-CN/dialogs.json | 6 +- src/i18n/locales/zh-CN/editor.json | 194 ++++++++++++++---- src/i18n/locales/zh-CN/launch.json | 79 +++---- src/i18n/locales/zh-CN/settings.json | 137 ++++++++++--- src/i18n/locales/zh-CN/shortcuts.json | 9 +- src/i18n/locales/zh-CN/timeline.json | 60 ++++-- src/i18n/locales/zh-TW/common.json | 18 +- src/i18n/locales/zh-TW/dialogs.json | 4 +- src/i18n/locales/zh-TW/editor.json | 138 ++++++++++++- src/i18n/locales/zh-TW/launch.json | 13 +- src/i18n/locales/zh-TW/settings.json | 87 +++++++- src/i18n/locales/zh-TW/shortcuts.json | 1 + src/i18n/locales/zh-TW/timeline.json | 36 +++- src/lib/shortcuts.ts | 37 +++- 131 files changed, 4449 insertions(+), 641 deletions(-) create mode 100644 docs/localization/zh-CN-localization-changes.md create mode 100644 electron/nativeDialogLocale.test.ts create mode 100644 electron/nativeDialogLocale.ts create mode 100644 src/components/launch/sourceLabel.test.ts create mode 100644 src/components/launch/sourceLabel.ts create mode 100644 src/i18n/i18nLocale.test.ts diff --git a/docs/localization/zh-CN-localization-changes.md b/docs/localization/zh-CN-localization-changes.md new file mode 100644 index 000000000..a2987bccc --- /dev/null +++ b/docs/localization/zh-CN-localization-changes.md @@ -0,0 +1,99 @@ +# Recordly 简体中文化修改清单 + +本次修改目标是补齐界面中遗漏的中文、修正翻译命名空间错误,并把项目保存、导出、字幕、时间线等操作反馈接入现有 i18n 体系。 + +## 1. 翻译资源 + +| 文件与位置 | 修改前示例 | 修改后示例 | +| --- | --- | --- | +| src/i18n/locales/zh-CN/common.json 的 loading、light、dark、system、announcements | Refreshing...、Light、Dark、System | 正在刷新...、浅色、深色、跟随系统;公告关闭为 关闭 | +| src/i18n/locales/zh-CN/common.json 的 close | 通用对话框的屏幕阅读器文本为 Close | 关闭 | +| src/i18n/locales/zh-CN/dialogs.json 的 addFont | This font is already added. 等英文提示 | 此字体已添加。、取消 | +| src/i18n/locales/zh-CN/editor.json 的 annotations | Custom Fonts、Custom Color、Annotation 等英文 | 自定义字体、自定义颜色、标注,并补齐箭头方向、图片/箭头错误提示 | +| src/i18n/locales/zh-CN/editor.json 的 projectBrowser、extensions、project | Projects、Import、No preview yet、Extensions、Project saved... | 项目、导入、暂无预览、扩展、项目已保存到... | +| src/i18n/locales/zh-CN/editor.json 的 exportStatus | No video loaded、Export failed、Show in Folder 等 | 未加载视频、导出失败、在文件夹中显示等,保留 {{path}}、{{error}} 占位符 | +| src/i18n/locales/zh-CN/editor.json 的 presets、theme、captions | 预设、外观、Whisper/字幕操作存在英文或缺失键 | 补齐 已保存的预设、外观、模型选择/删除/生成字幕等中文提示 | +| src/i18n/locales/zh-CN/launch.json | 录制准备、预览更新、更新提示和选择录制源存在英文回退 | 正在准备录制、即将打开编辑器、预览更新界面、请选择要录制的源、Recordly 更新 | +| src/i18n/locales/zh-CN/settings.json | Auto、点击效果、字幕编辑、视频背景导入、颜色选择器等缺少中文 | 补齐 自动、关闭/波纹/聚光灯/回声、文本/开始/结束/拆分/合并/删除、视频背景和颜色选择器提示 | +| src/i18n/locales/zh-CN/settings.json 的 captions.languages | Auto Detect、English、Chinese (Simplified) 等固定英文 | 自动检测、英语、简体中文等 | +| src/i18n/locales/zh-CN/timeline.json | No Video Loaded、Custom、Trim、Manual 等英文 | 未加载视频、自定义、分割、手动等 | + +同一批键已同步补入 de、en、es、fr、it、ko、nl、pt-BR、ru、zh-TW,以保持所有语言文件结构一致。除简体中文外,新补键先使用英文基线,后续可以分别翻译。 + +## 2. 代码接入位置 + +| 文件 | 位置/作用 | 修改前 | 修改后 | +| --- | --- | --- | --- | +| src/components/launch/popovers/MorePopover.tsx | 主题菜单 | 在 launch 作用域中读取 common.light/dark/system,键作用域不匹配 | 同时使用全局 useI18n() 和 launch 作用域,正确读取 common.* | +| src/components/launch/SourceSelector.tsx | 刷新源按钮 | Refreshing... | tCommon("common.loading", ...) | +| src/components/launch/UpdateToastWindow.tsx | 更新提示无障碍标签 | aria-label="Recordly update" | t("launch.updateToast.ariaLabel", ...) | +| src/components/video-editor/TutorialHelp.tsx | Discord 按钮标题/无障碍标签 | common.app.discord 在 editor 作用域中读取 | 改为全局 common 作用域 | +| ProjectBrowserDialog.tsx、ExtensionManager.tsx、EditorShell.tsx | 项目浏览、扩展、加载中和打开项目 | JSX 中直接写 Projects、Import、Extensions、Loading video... | 改为 editor.projectBrowser.*、editor.extensions.*、editor.loadingVideo 等翻译键 | +| TimelineEditor.tsx、timeline/components/toolbar/TimelineToolbar.tsx、timeline/Item.tsx | 时间线空状态、工具栏、片段操作 | 直接写 No Video Loaded、Pan、Zoom、Trim、Manual | 接入 timeline.empty.*、timeline.toolbar.*、timeline.item.* | +| AnnotationOverlay.tsx、AnnotationSettingsPanel.tsx | 标注占位图、字体/颜色控件 | Annotation、No image、Custom Fonts、Custom Color | 接入 editor.annotations.* | +| src/components/ui/dialog.tsx | 通用对话框关闭按钮 | 固定为 Close | 接入 common.close | +| SettingsPanel.tsx | 视频背景导入、颜色按钮、DEV 标记、字幕语言 | Unsupported format、Pick、Custom color picker、固定英文语言名 | 接入 settings.background.*、settings.effects.*、settings.captions.languages.* | +| export/useExportDialogActions.ts、export/useExportRunner.ts、export/exportRunnerSupport.ts、export/useEditorExportController.ts | 导出前置检查、保存取消、导出失败、显示文件夹 | 直接写导出英文 toast | 接入 editor.exportStatus.*,并把翻译函数沿控制器传入导出 hooks | +| project/useProjectSaveActions.ts、project/useProjectOpenActions.ts、project/useEditorProjectController.ts | 项目保存/加载/导入反馈 | Project saved...、Failed to load project 等英文 toast | 接入 editor.project.*,并把“打开其他项目/导入文件”作为可翻译动作名 | +| src/hooks/useScreenRecorder.ts | 未选择录制源时的系统提示 | alert("Please select a source to record") | alert(t("launch.permissions.selectSource", ...)) | + +## 3. 验证 + +新增测试文件 src/i18n/i18nLocale.test.ts,覆盖本次关键中文键和占位符。 + +已运行并通过: + +- npx vitest --run src/i18n/i18nLocale.test.ts +- npm run i18n:check +- npx tsc --noEmit +- npm run lint +- npm test:141 个测试文件通过,1210 个测试通过,1 个原有测试跳过 +- git diff --check + +## 4. 手动调整名称的方法 + +### 4.1 调整界面固定文案 + +直接编辑对应语言文件,例如: + +~~~text +src/i18n/locales/zh-CN/settings.json +~~~ + +代码中的 tSettings("effects.auto", "Auto") 对应 JSON 中的: + +~~~text +settings.json -> effects -> auto +~~~ + +代码中的 t("editor.project.savedTo", "Project saved to {{path}}", ...) 对应: + +~~~text +editor.json -> project -> savedTo +~~~ + +保存 JSON 后运行 npm run i18n:check,可检查所有语言文件的键结构是否一致。 + +### 4.2 调整带变量的文案 + +占位符名称必须保持不变,只修改占位符周围的文字。例如: + +~~~text +已成功导出到 {{path}} +项目已保存到 {{path}} +导出失败:{{error}} +~~~ + +不要把 {{path}} 改成别的名字,否则运行时不会替换实际路径。 + +### 4.3 调整系统音频/麦克风/字体/项目名称 + +这类名称不是翻译资源,而是运行时从系统或用户数据读取的动态值: + +- 系统音频设备的格式化位置是 src/hooks/audioOutputDevices.ts,其中 normalizeBrowserAudioOutputLabel 负责识别并保留 USB 标识,enrichNativeAudioOutputLabels 负责把原生设备名与浏览器枚举名称合并。 +- 因此 Default - 扬声器 (Yeti Nano) (046d:0acf) 这类名称不应写进 zh-CN/*.json。若要改变格式,应修改上述函数;若只想改设备显示名,优先在 Windows 声音设置中修改设备名称。 +- 自定义字体名、项目名来自用户选择或项目文件,也不在翻译 JSON 中;需要在字体/项目数据源或生成显示名的代码处修改。 + +修改动态名称后,重新启动开发版并重新打开对应菜单即可看到结果。 + +本次修改已整理为独立的中文本地化提交,没有删除或覆盖项目中的二进制/构建产物。 diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4f6f49e19..386ec6d27 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -218,6 +218,7 @@ interface RendererExportHardwareInfo { interface Window { electronAPI: { + setAppLocale: (locale: string) => void; hudOverlaySetIgnoreMouse: (ignore: boolean) => void; hudOverlaySetSourceSelectionActive: (active: boolean) => void; hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 7dfe5d671..3d1490450 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -8,6 +8,7 @@ import { sendWhisperModelDownloadProgress, } from "../captions/whisper"; import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; +import { getNativeDialogCopy } from "../../nativeDialogLocale"; import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager"; import { setCurrentProjectPath } from "../state"; import { approveUserPath, getRecordingsDir } from "../utils"; @@ -22,16 +23,17 @@ type OpenVideoFilePickerOptions = { export function registerCaptionHandlers() { ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => { try { + const copy = getNativeDialogCopy(); const includeProjects = Boolean(options?.includeProjects); const recordingsDir = await getRecordingsDir(); const result = await dialog.showOpenDialog({ - title: includeProjects ? "Import Media or Recordly Project" : "Select Video File", + title: includeProjects ? copy.importMediaOrProjectTitle : copy.selectVideoTitle, defaultPath: recordingsDir, filters: [ ...(includeProjects ? [ { - name: "Media or Recordly Projects", + name: copy.mediaOrProjectFilter, extensions: [ ...VIDEO_FILE_EXTENSIONS, ...PROJECT_FILE_EXTENSIONS, @@ -39,11 +41,11 @@ export function registerCaptionHandlers() { }, ] : []), - { name: "Video Files", extensions: VIDEO_FILE_EXTENSIONS }, + { name: copy.videoFileFilter, extensions: VIDEO_FILE_EXTENSIONS }, ...(includeProjects - ? [{ name: "Recordly Projects", extensions: PROJECT_FILE_EXTENSIONS }] + ? [{ name: copy.projectFileFilter, extensions: PROJECT_FILE_EXTENSIONS }] : []), - { name: "All Files", extensions: ["*"] }, + { name: copy.allFilesFilter, extensions: ["*"] }, ], properties: ["openFile"], }); @@ -81,14 +83,15 @@ export function registerCaptionHandlers() { ipcMain.handle("open-audio-file-picker", async () => { try { + const copy = getNativeDialogCopy(); const result = await dialog.showOpenDialog({ - title: "Select Audio File", + title: copy.selectAudioTitle, filters: [ { - name: "Audio Files", + name: copy.audioFileFilter, extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"], }, - { name: "All Files", extensions: ["*"] }, + { name: copy.allFilesFilter, extensions: ["*"] }, ], properties: ["openFile"], }); @@ -114,14 +117,15 @@ export function registerCaptionHandlers() { ipcMain.handle("open-whisper-executable-picker", async () => { try { + const copy = getNativeDialogCopy(); const result = await dialog.showOpenDialog({ - title: "Select Whisper Executable", + title: copy.selectWhisperExecutableTitle, filters: [ { - name: "Executables", + name: copy.executablesFilter, extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"], }, - { name: "All Files", extensions: ["*"] }, + { name: copy.allFilesFilter, extensions: ["*"] }, ], properties: ["openFile"], }); @@ -140,11 +144,12 @@ export function registerCaptionHandlers() { ipcMain.handle("open-whisper-model-picker", async () => { try { + const copy = getNativeDialogCopy(); const result = await dialog.showOpenDialog({ - title: "Select Whisper Model", + title: copy.selectWhisperModelTitle, filters: [ - { name: "Whisper Models", extensions: ["bin"] }, - { name: "All Files", extensions: ["*"] }, + { name: copy.whisperModelsFilter, extensions: ["bin"] }, + { name: copy.allFilesFilter, extensions: ["*"] }, ], properties: ["openFile"], }); diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 82b523de7..ea5ff30d7 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { getNativeDialogCopy } from "../../nativeDialogLocale"; import { closeExportStream, isOwnedExportPath, @@ -863,12 +864,13 @@ export function registerExportHandlers() { // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); + const copy = getNativeDialogCopy(); const filters = isGif - ? [{ name: "GIF Image", extensions: ["gif"] }] - : [{ name: "MP4 Video", extensions: ["mp4"] }]; + ? [{ name: copy.gifFileFilter, extensions: ["gif"] }] + : [{ name: copy.mp4FileFilter, extensions: ["mp4"] }]; const parentWindow = BrowserWindow.fromWebContents(event.sender); const saveDialogOptions: SaveDialogOptions = { - title: isGif ? "Save Exported GIF" : "Save Exported Video", + title: isGif ? copy.saveGifTitle : copy.saveVideoTitle, defaultPath: path.join(app.getPath("downloads"), fileName), filters, properties: ["createDirectory", "showOverwriteConfirmation"], @@ -1018,12 +1020,13 @@ export function registerExportHandlers() { } const isGif = fileName.toLowerCase().endsWith(".gif"); + const copy = getNativeDialogCopy(); const filters = isGif - ? [{ name: "GIF Image", extensions: ["gif"] }] - : [{ name: "MP4 Video", extensions: ["mp4"] }]; + ? [{ name: copy.gifFileFilter, extensions: ["gif"] }] + : [{ name: copy.mp4FileFilter, extensions: ["mp4"] }]; const parentWindow = BrowserWindow.fromWebContents(event.sender); const saveDialogOptions: SaveDialogOptions = { - title: isGif ? "Save Exported GIF" : "Save Exported Video", + title: isGif ? copy.saveGifTitle : copy.saveVideoTitle, defaultPath: path.join(app.getPath("downloads"), fileName), filters, properties: ["createDirectory", "showOverwriteConfirmation"], diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index ff309a19a..483baeda6 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; +import { getNativeDialogCopy } from "../../nativeDialogLocale"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants"; import { getProjectBackupPath, writeProjectFileAtomically } from "../project/atomicSave"; @@ -278,9 +279,10 @@ export function registerProjectHandlers() { ipcMain.handle("choose-recordings-directory", async () => { try { + const copy = getNativeDialogCopy(); const current = await getRecordingsDir(); const result = await dialog.showOpenDialog({ - title: "Choose recordings folder", + title: copy.recordingsFolderTitle, defaultPath: current, properties: ["openDirectory", "createDirectory", "promptToCreate"], }); @@ -355,13 +357,17 @@ export function registerProjectHandlers() { const safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`; const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`; + const copy = getNativeDialogCopy(); const result = await dialog.showSaveDialog({ - title: "Save Recordly Project", + title: copy.saveProjectTitle, defaultPath: path.join(projectsDir, defaultName), filters: [ - { name: "Recordly Project", extensions: [PROJECT_FILE_EXTENSION] }, - { name: "JSON", extensions: ["json"] }, + { + name: copy.projectFileFilter, + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: copy.jsonFileFilter, extensions: ["json"] }, ], properties: ["createDirectory", "showOverwriteConfirmation"], }); @@ -508,17 +514,18 @@ export function registerProjectHandlers() { ipcMain.handle("load-project-file", async () => { try { + const copy = getNativeDialogCopy(); const projectsDir = await getProjectsDir(); const result = await dialog.showOpenDialog({ - title: "Open Recordly Project", + title: copy.openProjectTitle, defaultPath: projectsDir, filters: [ { - name: "Recordly Project", + name: copy.projectFileFilter, extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS], }, - { name: "JSON", extensions: ["json"] }, - { name: "All Files", extensions: ["*"] }, + { name: copy.jsonFileFilter, extensions: ["json"] }, + { name: copy.allFilesFilter, extensions: ["*"] }, ], properties: ["openFile"], }); diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e04d25f7f..13b262e0d 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -4,6 +4,7 @@ import { promisify } from "node:util"; import { app, ipcMain } from "electron"; import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../../appSettingsStore"; import { hideCursor } from "../../cursorHider"; +import { setNativeDialogLocale } from "../../nativeDialogLocale"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; import { getWindowsCaptureExePath } from "../paths/binaries"; @@ -47,6 +48,12 @@ function getBrowserMicrophoneProfileFromEnv() { } export function registerSettingsHandlers() { + ipcMain.on("set-app-locale", (_event, locale: unknown) => { + if (typeof locale === "string") { + setNativeDialogLocale(locale); + } + }); + ipcMain.handle("app:getVersion", () => { return app.getVersion(); }); diff --git a/electron/main.ts b/electron/main.ts index 890726670..e92187140 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,7 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; +import { getNativeDialogCopy } from "./nativeDialogLocale"; import { hardenWebContentsNavigation, shouldHardenWebContentsType } from "./navigationPolicy"; import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; @@ -815,14 +816,15 @@ function createEditorWindowWrapper() { event.preventDefault(); + const copy = getNativeDialogCopy(); const choice = dialog.showMessageBoxSync(editorWindow, { type: "warning", - buttons: ["Save & Close", "Discard & Close", "Cancel"], + buttons: [copy.saveAndClose, copy.discardAndClose, copy.cancel], defaultId: 0, cancelId: 2, - title: "Unsaved Changes", - message: "You have unsaved changes.", - detail: "Do you want to save your project before closing?", + title: copy.unsavedChangesTitle, + message: copy.unsavedChangesMessage, + detail: copy.unsavedChangesDetail, }); if (choice === 0) { diff --git a/electron/nativeDialogLocale.test.ts b/electron/nativeDialogLocale.test.ts new file mode 100644 index 000000000..599ad98f3 --- /dev/null +++ b/electron/nativeDialogLocale.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + formatNativeDialogText, + getNativeDialogCopy, + setNativeDialogLocale, +} from "./nativeDialogLocale"; + +describe("native dialog localization", () => { + it("returns simplified Chinese copy for native file dialogs", () => { + setNativeDialogLocale("zh-CN"); + + expect(getNativeDialogCopy().openProjectTitle).toBe("打开 Recordly 项目"); + expect(getNativeDialogCopy().saveVideoTitle).toBe("保存导出视频"); + expect(getNativeDialogCopy().videoFileFilter).toBe("视频文件"); + expect(getNativeDialogCopy().updateAvailableTitle).toBe("有可用更新"); + expect(getNativeDialogCopy().installAndRestart).toBe("安装并重启"); + expect(getNativeDialogCopy().previewOnlyTitle).toBe("仅供预览"); + expect(getNativeDialogCopy().unsavedChangesTitle).toBe("未保存的更改"); + expect(getNativeDialogCopy().unsavedChangesMessage).toBe("你有未保存的更改。"); + expect(getNativeDialogCopy().unsavedChangesDetail).toBe("关闭前是否保存项目?"); + expect(getNativeDialogCopy().saveAndClose).toBe("保存并关闭"); + expect(getNativeDialogCopy().discardAndClose).toBe("放弃并关闭"); + expect(getNativeDialogCopy().cancel).toBe("取消"); + expect( + formatNativeDialogText(getNativeDialogCopy().updateAvailableMessage, { + version: "9.9.9", + }), + ).toBe("Recordly 9.9.9 可用。"); + }); + + it("falls back to English copy for unsupported locales", () => { + setNativeDialogLocale("en"); + + expect(getNativeDialogCopy().openProjectTitle).toBe("Open Recordly Project"); + expect(getNativeDialogCopy().saveVideoTitle).toBe("Save Exported Video"); + expect(getNativeDialogCopy().updateAvailableTitle).toBe("Update Available"); + expect(getNativeDialogCopy().installAndRestart).toBe("Install & Restart"); + expect(getNativeDialogCopy().unsavedChangesTitle).toBe("Unsaved Changes"); + expect(getNativeDialogCopy().unsavedChangesMessage).toBe("You have unsaved changes."); + expect(getNativeDialogCopy().unsavedChangesDetail).toBe( + "Do you want to save your project before closing?", + ); + expect(getNativeDialogCopy().saveAndClose).toBe("Save & Close"); + expect(getNativeDialogCopy().discardAndClose).toBe("Discard & Close"); + expect(getNativeDialogCopy().cancel).toBe("Cancel"); + }); +}); diff --git a/electron/nativeDialogLocale.ts b/electron/nativeDialogLocale.ts new file mode 100644 index 000000000..eb623db86 --- /dev/null +++ b/electron/nativeDialogLocale.ts @@ -0,0 +1,188 @@ +export type NativeDialogCopy = { + recordingsFolderTitle: string; + importMediaOrProjectTitle: string; + selectVideoTitle: string; + mediaOrProjectFilter: string; + videoFileFilter: string; + projectFileFilter: string; + jsonFileFilter: string; + allFilesFilter: string; + selectAudioTitle: string; + audioFileFilter: string; + selectWhisperExecutableTitle: string; + executablesFilter: string; + selectWhisperModelTitle: string; + whisperModelsFilter: string; + saveProjectTitle: string; + openProjectTitle: string; + saveGifTitle: string; + saveVideoTitle: string; + gifFileFilter: string; + mp4FileFilter: string; + updateAvailableTitle: string; + experimentalUpdateAvailableTitle: string; + updateAvailableMessage: string; + experimentalUpdateAvailableMessage: string; + updateAvailableDetail: string; + updateAvailablePreviewDetail: string; + experimentalUpdateDetail: string; + experimentalUpdatePreviewDetail: string; + installAndRestart: string; + later: string; + previewOnlyTitle: string; + previewOnlyMessage: string; + previewOnlyDetail: string; + updateReadyTitle: string; + updateReadyMessage: string; + updateReadyPreviewMessage: string; + updateReadyDetail: string; + updateReadyPreviewDetail: string; + updateFailedTitle: string; + updateFailedMessage: string; + updatesNotEnabledTitle: string; + updatesNotEnabledMessage: string; + updatesDisabledDetail: string; + developmentBuildDetail: string; + unsavedChangesTitle: string; + unsavedChangesMessage: string; + unsavedChangesDetail: string; + saveAndClose: string; + discardAndClose: string; + cancel: string; + okButton: string; +}; + +const ENGLISH_COPY: NativeDialogCopy = { + recordingsFolderTitle: "Choose recordings folder", + importMediaOrProjectTitle: "Import Media or Recordly Project", + selectVideoTitle: "Select Video File", + mediaOrProjectFilter: "Media or Recordly Projects", + videoFileFilter: "Video Files", + projectFileFilter: "Recordly Projects", + jsonFileFilter: "JSON", + allFilesFilter: "All Files", + selectAudioTitle: "Select Audio File", + audioFileFilter: "Audio Files", + selectWhisperExecutableTitle: "Select Whisper Executable", + executablesFilter: "Executables", + selectWhisperModelTitle: "Select Whisper Model", + whisperModelsFilter: "Whisper Models", + saveProjectTitle: "Save Recordly Project", + openProjectTitle: "Open Recordly Project", + saveGifTitle: "Save Exported GIF", + saveVideoTitle: "Save Exported Video", + gifFileFilter: "GIF Image", + mp4FileFilter: "MP4 Video", + updateAvailableTitle: "Update Available", + experimentalUpdateAvailableTitle: "Experimental Update Available", + updateAvailableMessage: "Recordly {{version}} is available.", + experimentalUpdateAvailableMessage: + "Recordly {{version}} is available on the experimental channel.", + updateAvailableDetail: "Install and restart now, or remind me later.", + updateAvailablePreviewDetail: + "This is a development preview of the standard update flow. No real update will be installed.", + experimentalUpdateDetail: + "You've opted into experimental updates so you can test the latest Recordly update before it is widely available.", + experimentalUpdatePreviewDetail: + "You've opted into experimental updates. This is a development preview, and no real update will be installed.", + installAndRestart: "Install & Restart", + later: "Later", + previewOnlyTitle: "Preview Only", + previewOnlyMessage: "No real update was installed.", + previewOnlyDetail: "This was only a manual development preview of the update prompt.", + updateReadyTitle: "Update Ready", + updateReadyMessage: "Recordly {{version}} has been downloaded.", + updateReadyPreviewMessage: "Recordly {{version}} is ready to install.", + updateReadyDetail: "Install and restart now, or remind me later.", + updateReadyPreviewDetail: + "Development preview of the native update prompt. No real update will be installed.", + updateFailedTitle: "Update Failed", + updateFailedMessage: "Recordly {{version}} could not be downloaded.", + updatesNotEnabledTitle: "Updates Not Enabled", + updatesNotEnabledMessage: "Auto-updates are only available in packaged releases.", + updatesDisabledDetail: + "This build disabled auto-updates through RECORDLY_DISABLE_AUTO_UPDATES=1.", + developmentBuildDetail: + "Development builds do not ship the packaged update metadata required by electron-updater.", + unsavedChangesTitle: "Unsaved Changes", + unsavedChangesMessage: "You have unsaved changes.", + unsavedChangesDetail: "Do you want to save your project before closing?", + saveAndClose: "Save & Close", + discardAndClose: "Discard & Close", + cancel: "Cancel", + okButton: "OK", +}; + +const SIMPLIFIED_CHINESE_COPY: NativeDialogCopy = { + recordingsFolderTitle: "选择录制文件夹", + importMediaOrProjectTitle: "导入媒体或 Recordly 项目", + selectVideoTitle: "选择视频文件", + mediaOrProjectFilter: "媒体或 Recordly 项目", + videoFileFilter: "视频文件", + projectFileFilter: "Recordly 项目", + jsonFileFilter: "JSON 文件", + allFilesFilter: "所有文件", + selectAudioTitle: "选择音频文件", + audioFileFilter: "音频文件", + selectWhisperExecutableTitle: "选择 Whisper 可执行文件", + executablesFilter: "可执行文件", + selectWhisperModelTitle: "选择 Whisper 模型", + whisperModelsFilter: "Whisper 模型", + saveProjectTitle: "保存 Recordly 项目", + openProjectTitle: "打开 Recordly 项目", + saveGifTitle: "保存导出 GIF", + saveVideoTitle: "保存导出视频", + gifFileFilter: "GIF 图片", + mp4FileFilter: "MP4 视频", + updateAvailableTitle: "有可用更新", + experimentalUpdateAvailableTitle: "有测试版更新可用", + updateAvailableMessage: "Recordly {{version}} 可用。", + experimentalUpdateAvailableMessage: "Recordly {{version}} 测试版可用。", + updateAvailableDetail: "现在安装并重启,或稍后提醒。", + updateAvailablePreviewDetail: "这是标准更新流程的开发预览,不会安装真实更新。", + experimentalUpdateDetail: "你已启用测试版更新,可在正式发布前体验 Recordly 的最新版本。", + experimentalUpdatePreviewDetail: "这是测试版更新流程的开发预览,不会安装真实更新。", + installAndRestart: "安装并重启", + later: "稍后", + previewOnlyTitle: "仅供预览", + previewOnlyMessage: "未安装真实更新。", + previewOnlyDetail: "这只是更新提示的开发预览。", + updateReadyTitle: "更新已准备就绪", + updateReadyMessage: "Recordly {{version}} 已下载完成。", + updateReadyPreviewMessage: "Recordly {{version}} 已准备安装。", + updateReadyDetail: "现在安装并重启,或稍后提醒。", + updateReadyPreviewDetail: "这是原生更新提示的开发预览,不会安装真实更新。", + updateFailedTitle: "更新失败", + updateFailedMessage: "无法下载 Recordly {{version}}。", + updatesNotEnabledTitle: "未启用更新", + updatesNotEnabledMessage: "自动更新仅适用于正式打包版本。", + updatesDisabledDetail: "此版本通过 RECORDLY_DISABLE_AUTO_UPDATES=1 禁用了自动更新。", + developmentBuildDetail: "开发版没有 electron-updater 所需的正式更新元数据。", + unsavedChangesTitle: "未保存的更改", + unsavedChangesMessage: "你有未保存的更改。", + unsavedChangesDetail: "关闭前是否保存项目?", + saveAndClose: "保存并关闭", + discardAndClose: "放弃并关闭", + cancel: "取消", + okButton: "确定", +}; + +let currentLocale = "en"; + +export function setNativeDialogLocale(locale: string): void { + currentLocale = locale.toLowerCase().startsWith("zh-cn") ? "zh-CN" : "en"; +} + +export function getNativeDialogCopy(): NativeDialogCopy { + return currentLocale === "zh-CN" ? SIMPLIFIED_CHINESE_COPY : ENGLISH_COPY; +} + +export function formatNativeDialogText( + text: string, + vars: Record, +): string { + return text.replace(/\{\{(\w+)\}\}/g, (placeholder, key: string) => { + const value = vars[key]; + return value === undefined ? placeholder : String(value); + }); +} diff --git a/electron/preload.ts b/electron/preload.ts index 723ae7bdd..1c438cbb7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -184,6 +184,9 @@ function settleNativeVideoExportPendingRequests( } contextBridge.exposeInMainWorld("electronAPI", { + setAppLocale: (locale: string) => { + ipcRenderer.send("set-app-locale", locale); + }, hudOverlaySetIgnoreMouse: (ignore: boolean) => { ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore); }, diff --git a/electron/updater.ts b/electron/updater.ts index b9c4e5dca..8551246d1 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { USER_DATA_PATH } from "./appPaths"; import { readAppSetting, writeAppSetting } from "./appSettingsStore"; +import { formatNativeDialogText, getNativeDialogCopy } from "./nativeDialogLocale"; import { EXPERIMENTAL_UPDATE_DESCRIPTION, getUpdateChannelConfiguration } from "./updateChannel"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; @@ -586,16 +587,22 @@ async function showAvailableUpdateDialog( ) { const isPreview = Boolean(options?.isPreview); const isExperimental = options?.isExperimental ?? getExperimentalUpdatesEnabled(); + const copy = getNativeDialogCopy(); const result = await showMessageBox(getMainWindow, { type: "info", - title: isExperimental ? "Experimental Update Available" : "Update Available", - message: `Recordly ${version} is available${isExperimental ? " on the experimental channel" : ""}.`, + title: isExperimental ? copy.experimentalUpdateAvailableTitle : copy.updateAvailableTitle, + message: formatNativeDialogText( + isExperimental ? copy.experimentalUpdateAvailableMessage : copy.updateAvailableMessage, + { version }, + ), detail: isPreview - ? `${isExperimental ? EXPERIMENTAL_UPDATE_DESCRIPTION : "This is a development preview of the standard update flow."} No real update will be installed.` + ? isExperimental + ? copy.experimentalUpdatePreviewDetail + : copy.updateAvailablePreviewDetail : isExperimental - ? EXPERIMENTAL_UPDATE_DESCRIPTION - : "Install and restart now, or remind me later.", - buttons: ["Install & Restart", "Later"], + ? copy.experimentalUpdateDetail + : copy.updateAvailableDetail, + buttons: [copy.installAndRestart, copy.later], defaultId: 0, cancelId: 1, noLink: true, @@ -603,11 +610,13 @@ async function showAvailableUpdateDialog( if (result.response === 0) { if (isPreview) { + const previewCopy = getNativeDialogCopy(); await showMessageBox(getMainWindow, { type: "info", - title: "Preview Only", - message: "No real update was installed.", - detail: "This was only a manual development preview of the update prompt.", + title: previewCopy.previewOnlyTitle, + message: previewCopy.previewOnlyMessage, + detail: previewCopy.previewOnlyDetail, + buttons: [previewCopy.okButton], }); return; } @@ -629,16 +638,15 @@ async function showDownloadedUpdateDialog( options?: { isPreview?: boolean }, ) { const isPreview = Boolean(options?.isPreview); + const copy = getNativeDialogCopy(); const result = await showMessageBox(getMainWindow, { type: "info", - title: "Update Ready", + title: copy.updateReadyTitle, message: isPreview - ? `Recordly ${version} is ready to install.` - : `Recordly ${version} has been downloaded.`, - detail: isPreview - ? "Development preview of the native update prompt. No real update will be installed." - : "Install and restart now, or remind me later.", - buttons: ["Install & Restart", "Later"], + ? formatNativeDialogText(copy.updateReadyPreviewMessage, { version }) + : formatNativeDialogText(copy.updateReadyMessage, { version }), + detail: isPreview ? copy.updateReadyPreviewDetail : copy.updateReadyDetail, + buttons: [copy.installAndRestart, copy.later], defaultId: 0, cancelId: 1, noLink: true, @@ -646,11 +654,13 @@ async function showDownloadedUpdateDialog( if (result.response === 0) { if (isPreview) { + const previewCopy = getNativeDialogCopy(); await showMessageBox(getMainWindow, { type: "info", - title: "Preview Only", - message: "No real update was installed.", - detail: "This was only a manual development preview of the update prompt.", + title: previewCopy.previewOnlyTitle, + message: previewCopy.previewOnlyMessage, + detail: previewCopy.previewOnlyDetail, + buttons: [previewCopy.okButton], }); return; } @@ -683,12 +693,13 @@ async function showUpdateErrorDialog( version: string, error: unknown, ) { + const copy = getNativeDialogCopy(); await showMessageBox(getMainWindow, { type: "error", - title: "Update Failed", - message: `Recordly ${version} could not be downloaded.`, + title: copy.updateFailedTitle, + message: formatNativeDialogText(copy.updateFailedMessage, { version }), detail: String(error), - buttons: ["OK"], + buttons: [copy.okButton], defaultId: 0, noLink: true, }); @@ -703,13 +714,15 @@ export async function checkForAppUpdates( `Skipped update check because auto-updates are unavailable. packaged=${app.isPackaged} mas=${process.mas ? "yes" : "no"} disabled=${AUTO_UPDATES_DISABLED ? "yes" : "no"}`, ); if (options?.manual) { + const copy = getNativeDialogCopy(); await showMessageBox(getMainWindow, { type: "info", - title: "Updates Not Enabled", - message: "Auto-updates are only available in packaged releases.", + title: copy.updatesNotEnabledTitle, + message: copy.updatesNotEnabledMessage, detail: AUTO_UPDATES_DISABLED - ? "This build disabled auto-updates through RECORDLY_DISABLE_AUTO_UPDATES=1." - : "Development builds do not ship the packaged update metadata required by electron-updater.", + ? copy.updatesDisabledDetail + : copy.developmentBuildDetail, + buttons: [copy.okButton], }); } return; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 14ca0dc11..c644da9ce 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -46,6 +46,7 @@ import { SourcePopover } from "./popovers/SourcePopover"; import { SystemAudioPopover } from "./popovers/SystemAudioPopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; import { RecordingControls } from "./RecordingControls"; +import { getLocalizedSourceLabel } from "./sourceLabel"; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; @@ -102,6 +103,7 @@ function LaunchWindowContent() { syncSelectedSource, refreshProjectLibrary, } = useLaunchWindowActions(); + const selectedSourceLabel = getLocalizedSourceLabel(selectedSource, t); const showWebcamControls = webcamEnabled && !recording; const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( @@ -271,11 +273,11 @@ function LaunchWindowContent() { variant="outline" size="lg" className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] shrink-0 border-[var(--launch-border)] bg-[var(--launch-surface)] text-[var(--launch-text)] hover:border-[var(--launch-border-strong)] hover:bg-[var(--launch-hover)] transition-all ${openId === "sources" ? "border-[var(--launch-border-strong)] bg-[var(--launch-hover)]" : ""}`} - title={selectedSource} + title={selectedSourceLabel} >
- +
- + @@ -122,7 +126,7 @@ export const SourceSelectorContent = ({ loading ? "opacity-100" : "opacity-0", )} > - {t("common.loading", "Refreshing...")} + {tCommon("common.loading", "Refreshing...")}
@@ -169,6 +173,7 @@ export const SourceSelector = React.memo(function SourceSelector({ onOpenChange: propsOnOpenChange, children, }: SourceSelectorProps) { + const t = useScopedT("launch"); // Internal state for standalone/uncontrolled use const [internalOpen, setInternalOpen] = useState(false); const [internalSources, setInternalSources] = useState([]); @@ -235,6 +240,7 @@ export const SourceSelector = React.memo(function SourceSelector({ const screenSources = propsScreenSources ?? internalScreenSources; const windowSources = propsWindowSources ?? internalWindowSources; + const selectedSourceLabel = getLocalizedSourceLabel(selectedSource, t); const hasPrefetchedRef = useRef(false); const fetchInFlightRef = useRef(false); @@ -301,11 +307,11 @@ export const SourceSelector = React.memo(function SourceSelector({ "border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all", "data-[state=open]:border-[#3e3e4c] data-[state=open]:bg-[#20202a]", )} - title={selectedSource} + title={selectedSourceLabel} >
- +
-
+
diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a52905..fb3bed39d 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -1,17 +1,16 @@ import { + ArrowClockwiseIcon, + DesktopIcon, EyeIcon, EyeSlashIcon, FolderOpenIcon, + MoonIcon, + SunIcon, TranslateIcon, VideoCameraIcon, - ArrowClockwiseIcon, - SunIcon, - MoonIcon, - DesktopIcon, } from "@phosphor-icons/react"; import type { ReactElement } from "react"; -import { useI18n } from "@/contexts/I18nContext"; -import { useScopedT } from "@/contexts/I18nContext"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useTheme } from "@/contexts/ThemeContext"; import type { AppLocale } from "@/i18n/config"; import { SUPPORTED_LOCALES } from "@/i18n/config"; @@ -29,7 +28,7 @@ const LOCALE_LABELS: Record = { nl: "Nederlands", ko: "한국어", "pt-BR": "Português", - "zh-CN": "簡體中文", + "zh-CN": "简体中文", "zh-TW": "繁體中文", }; @@ -57,7 +56,7 @@ export function MorePopover({ appVersion: string | null; }) { const t = useScopedT("launch"); - const { locale, setLocale } = useI18n(); + const { locale, setLocale, t: tCommon } = useI18n(); const { preference, setPreference } = useTheme(); const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); const open = isOpen(POPOVER_ID); @@ -135,7 +134,7 @@ export function MorePopover({ requestClose(POPOVER_ID); }} > - {t("common.light", "Light")} + {tCommon("common.light", "Light")} } @@ -145,7 +144,7 @@ export function MorePopover({ requestClose(POPOVER_ID); }} > - {t("common.dark", "Dark")} + {tCommon("common.dark", "Dark")} } @@ -155,7 +154,7 @@ export function MorePopover({ requestClose(POPOVER_ID); }} > - {t("common.system", "System")} + {tCommon("common.system", "System")}
{t("recording.language")} diff --git a/src/components/launch/sourceLabel.test.ts b/src/components/launch/sourceLabel.test.ts new file mode 100644 index 000000000..f68339d70 --- /dev/null +++ b/src/components/launch/sourceLabel.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getLocalizedSourceLabel } from "./sourceLabel"; + +const translate = (key: string, _fallback?: string, vars?: Record) => { + if (key === "recording.screen") return "屏幕"; + if (key === "recording.folder") return "文件夹"; + if (key === "recording.display") return `显示器 ${vars?.index}`; + if (key === "recording.primaryDisplay") { + return `显示器 ${vars?.index}(主显示器)`; + } + return key; +}; + +describe("getLocalizedSourceLabel", () => { + it("localizes generated screen names while preserving the source index", () => { + expect(getLocalizedSourceLabel("Screen 1", translate)).toBe("显示器 1"); + expect(getLocalizedSourceLabel("Screen 2 (Primary)", translate)).toBe( + "显示器 2(主显示器)", + ); + }); + + it("localizes the default screen label", () => { + expect(getLocalizedSourceLabel("Screen", translate)).toBe("屏幕"); + }); + + it("does not alter dynamic window titles", () => { + expect(getLocalizedSourceLabel("FolderBrowser", translate)).toBe("文件夹"); + expect(getLocalizedSourceLabel("文件夹", translate)).toBe("文件夹"); + expect(getLocalizedSourceLabel("ChatGPT — Recordly", translate)).toBe("ChatGPT — Recordly"); + }); +}); diff --git a/src/components/launch/sourceLabel.ts b/src/components/launch/sourceLabel.ts new file mode 100644 index 000000000..ce4d24778 --- /dev/null +++ b/src/components/launch/sourceLabel.ts @@ -0,0 +1,35 @@ +type SourceLabelTranslate = ( + key: string, + fallback?: string, + vars?: Record, +) => string; + +const SCREEN_SOURCE_PATTERN = /^Screen\s+(\d+)(?:\s+\(Primary\))?$/i; + +/** + * Returns a localized label for generated screen sources without changing + * the source name used by Electron for selection and recording. + */ +export function getLocalizedSourceLabel( + sourceName: string, + translate: SourceLabelTranslate, +): string { + const normalizedName = sourceName.trim(); + + if (normalizedName.toLowerCase() === "screen") { + return translate("recording.screen", "Screen"); + } + + if (/^folder\s*browser$/i.test(normalizedName)) { + return translate("recording.folder", "Folder"); + } + + const screenMatch = normalizedName.match(SCREEN_SOURCE_PATTERN); + if (!screenMatch) return sourceName; + + const index = screenMatch[1]; + const isPrimary = /\(Primary\)$/i.test(normalizedName); + return isPrimary + ? translate("recording.primaryDisplay", "Display {{index}} (Primary)", { index }) + : translate("recording.display", "Display {{index}}", { index }); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index fa1ff0c63..6f729f3b3 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "@phosphor-icons/react"; import * as React from "react"; +import { useI18n } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; @@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); +>(({ className, children, ...props }, ref) => { + const { t } = useI18n(); + return ( + + + + {children} + + + {t("common.close", "Close")} + + + + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 4bdd44eca..d9b1d2819 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; import { Rnd } from "react-rnd"; +import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; import { type AnnotationRegion, BASE_PREVIEW_WIDTH, BLUR_ANNOTATION_STRENGTH } from "./types"; @@ -55,6 +56,7 @@ export function AnnotationOverlay({ zIndex, isSelectedBoost, }: AnnotationOverlayProps) { + const t = useScopedT("editor"); const safeRecordingRect = recordingRect.width > 0 && recordingRect.height > 0 ? recordingRect @@ -153,7 +155,7 @@ export function AnnotationOverlay({ return ( Annotation @@ -161,7 +163,7 @@ export function AnnotationOverlay({ } return (
- No image + {t("annotations.noImage", "No image")}
); @@ -169,7 +171,7 @@ export function AnnotationOverlay({ if (!annotation.figureData) { return (
- No arrow data + {t("annotations.noArrowData", "No arrow data")}
); } diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index af5326320..e0cd20819 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -247,7 +247,10 @@ export function AnnotationSettingsPanel({ {customFonts.length > 0 && ( <>
- Custom Fonts + {t( + "annotations.customFonts", + "Custom Fonts", + )}
{customFonts.map((font) => ( Uploaded annotation
@@ -713,7 +719,7 @@ export function AnnotationSettingsPanel({ ? "border-[#2563EB] scale-110" : "border-transparent hover:border-foreground/20", )} - title="Black" + title={t("annotations.colorBlack", "Black")} />
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index f9c24c636..6fdb50b0a 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -2,12 +2,18 @@ import { Gear as Settings2, Question as HelpCircle } from "@phosphor-icons/react import { useEffect, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts"; +import { + formatBinding, + SHORTCUT_ACTIONS, + SHORTCUT_LABEL_KEYS, + SHORTCUT_LABELS, +} from "@/lib/shortcuts"; import { formatShortcut } from "@/utils/platformUtils"; export function KeyboardShortcutsHelp() { const { shortcuts, isMac, openConfig } = useShortcuts(); const t = useScopedT("editor"); + const tShortcuts = useScopedT("shortcuts"); const [scrollLabels, setScrollLabels] = useState({ pan: "Shift + Scroll", @@ -43,7 +49,12 @@ export function KeyboardShortcutsHelp() {
{SHORTCUT_ACTIONS.map((action) => (
- {SHORTCUT_LABELS[action]} + + {tShortcuts( + `actions.${SHORTCUT_LABEL_KEYS[action]}`, + SHORTCUT_LABELS[action], + )} + {formatBinding(shortcuts[action], isMac)} diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index ab71fd6b0..84f528bb1 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; import { toFileUrl } from "./projectPersistence"; export type ProjectLibraryEntry = { @@ -32,6 +33,7 @@ export default function ProjectBrowserDialog({ onPanelHeightChange, renderMode = "floating", }: ProjectBrowserDialogProps) { + const t = useScopedT("editor"); const panelRef = useRef(null); const [position, setPosition] = useState({ top: 72, left: 16, maxHeight: 360 }); const visibleEntries = useMemo(() => entries.slice(0, 24), [entries]); @@ -173,12 +175,12 @@ export default function ProjectBrowserDialog({
- Projects + {t("projectBrowser.title", "Projects")}
{onImportFile ? ( ) : null}
@@ -214,13 +216,16 @@ export default function ProjectBrowserDialog({ /> ) : (
- No preview yet + {t( + "projectBrowser.noPreview", + "No preview yet", + )}
)} {entry.isCurrent ? (
- Current + {t("projectBrowser.current", "Current")}
) : null} @@ -237,7 +242,7 @@ export default function ProjectBrowserDialog({ ) : (
- No saved projects yet + {t("projectBrowser.empty", "No saved projects yet")}
)} @@ -251,13 +256,13 @@ export default function ProjectBrowserDialog({
- Projects + {t("projectBrowser.title", "Projects")}
{onImportFile ? ( ) : null}
@@ -296,13 +301,16 @@ export default function ProjectBrowserDialog({ /> ) : (
- No preview yet + {t( + "projectBrowser.noPreview", + "No preview yet", + )}
)} {entry.isCurrent ? (
- Current + {t("projectBrowser.current", "Current")}
) : null} @@ -319,7 +327,7 @@ export default function ProjectBrowserDialog({ ) : (
- No saved projects yet + {t("projectBrowser.empty", "No saved projects yet")}
)} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 8b289e3f5..2a9b95383 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -142,6 +142,13 @@ const CAPTION_ANIMATION_OPTIONS: Array<{ value: AutoCaptionAnimation; label: str { value: "pop", label: "Pop" }, ]; +const CAPTION_ANIMATION_LABEL_KEYS: Record = { + none: "animationOff", + fade: "animationFade", + rise: "animationRise", + pop: "animationPop", +}; + const CLICK_EFFECT_COLOR_OPTIONS = [ "#2563EB", "#EF4444", @@ -717,7 +724,7 @@ const APP_LANGUAGE_LABELS: Record = { nl: "Nederlands", ko: "한국어", "pt-BR": "Português", - "zh-CN": "簡體中文", + "zh-CN": "简体中文", "zh-TW": "繁體中文", }; @@ -1709,16 +1716,21 @@ export function SettingsPanel({ if (!result?.success || !result.path) return; const filePath = result.path; if (!isVideoWallpaperSource(filePath)) { - toast.error("Unsupported format", { - description: "Please select a video file (mp4, webm, mov, etc.)", + toast.error(tSettings("background.unsupportedFormat", "Unsupported format"), { + description: tSettings( + "background.videoFormatDescription", + "Please select a video file (mp4, webm, mov, etc.)", + ), }); return; } setCustomImages((prev) => [filePath, ...prev]); onWallpaperChange(filePath); - toast.success("Video background added"); + toast.success(tSettings("background.videoAdded", "Video background added")); } catch { - toast.error("Failed to import video background"); + toast.error( + tSettings("background.videoImportFailed", "Failed to import video background"), + ); } }; @@ -1988,10 +2000,13 @@ export function SettingsPanel({ style={{ background: `linear-gradient(135deg, ${selectedColor} 0%, ${selectedColor} 58%, rgba(255,255,255,0.92) 58%, rgba(255,255,255,0.92) 100%)`, }} - aria-label="Custom color picker" + aria-label={tSettings( + "effects.customColorPicker", + "Custom color picker", + )} >
- Pick + {tSettings("effects.pickColor", "Pick")}
@@ -2327,7 +2342,7 @@ export function SettingsPanel({ {CAPTION_LANGUAGE_OPTIONS.map((option) => ( - {option.label} + {tSettings(`captions.languages.${option.value}`, option.label)} ))} @@ -2427,7 +2442,10 @@ export function SettingsPanel({ {CAPTION_ANIMATION_OPTIONS.map((option) => ( - {option.label} + {tSettings( + `captions.${CAPTION_ANIMATION_LABEL_KEYS[option.value]}`, + option.label, + )} ))} @@ -2435,7 +2453,10 @@ export function SettingsPanel({
- {tSettings("captions.timelineQuickAdd", "Hover to add on timeline")} + {tSettings( + "captions.timelineQuickAdd", + "Hover to add a caption on the timeline", + )}
- DEV + {tSettings("effects.devBadge", "DEV")}
@@ -3281,8 +3302,14 @@ export function SettingsPanel({
- Pick + {tSettings("effects.pickColor", "Pick")}
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index c359c1f11..f5399ea7d 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -16,6 +16,7 @@ import { findConflict, formatBinding, SHORTCUT_ACTIONS, + SHORTCUT_LABEL_KEYS, SHORTCUT_LABELS, type ShortcutAction, type ShortcutBinding, @@ -28,6 +29,7 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]); export function ShortcutsConfigDialog() { const t = useScopedT("dialogs"); + const tShortcuts = useScopedT("shortcuts"); const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } = useShortcuts(); @@ -72,7 +74,11 @@ export function ShortcutsConfigDialog() { setCaptureFor(null); if (found?.type === "fixed") { - toast.error(t("shortcutsConfig.reserved", undefined, { label: found.label })); + toast.error( + t("shortcutsConfig.reserved", undefined, { + label: tShortcuts(`actions.${found.translationKey}`, found.label), + }), + ); return; } @@ -86,7 +92,7 @@ export function ShortcutsConfigDialog() { window.addEventListener("keydown", handleCapture, { capture: true }); return () => window.removeEventListener("keydown", handleCapture, { capture: true }); - }, [captureFor, draft, t]); + }, [captureFor, draft, t, tShortcuts]); const handleSwap = useCallback(() => { if (!conflict || conflict.conflictWith.type !== "configurable") return; @@ -145,7 +151,10 @@ export function ShortcutsConfigDialog() {
- {SHORTCUT_LABELS[action]} + {tShortcuts( + `actions.${SHORTCUT_LABEL_KEYS[action]}`, + SHORTCUT_LABELS[action], + )} @@ -187,6 +193,7 @@ export function KeyboardShortcutsDialog({ }: KeyboardShortcutsDialogProps) { const { shortcuts, isMac, openConfig } = useShortcuts(); const t = useScopedT("editor"); + const tShortcuts = useScopedT("shortcuts"); const [scrollLabels, setScrollLabels] = useState({ pan: "Shift + Scroll", zoom: "Ctrl + Scroll", @@ -233,7 +240,10 @@ export function KeyboardShortcutsDialog({ className="flex items-center justify-between gap-3 rounded-lg border border-foreground/5 bg-foreground/5 px-3 py-2.5" > - {SHORTCUT_LABELS[action]} + {tShortcuts( + `actions.${SHORTCUT_LABEL_KEYS[action]}`, + SHORTCUT_LABELS[action], + )} {formatBinding(shortcuts[action], isMac)} diff --git a/src/components/video-editor/audio/useSourceAudioFallback.ts b/src/components/video-editor/audio/useSourceAudioFallback.ts index f081d2ce6..612ddb6fd 100644 --- a/src/components/video-editor/audio/useSourceAudioFallback.ts +++ b/src/components/video-editor/audio/useSourceAudioFallback.ts @@ -1,17 +1,20 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { SOURCE_AUDIO_FALLBACK_TOAST_ID } from "@/components/video-editor/audio/audioTypes"; +import type { I18nTranslate } from "@/contexts/I18nContext"; interface UseSourceAudioFallbackParams { currentSourcePath: string | null; refreshKey?: number; summarizeErrorMessage: (message: string) => string; + t: I18nTranslate; } export function useSourceAudioFallback({ currentSourcePath, refreshKey = 0, summarizeErrorMessage, + t, }: UseSourceAudioFallbackParams) { const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState([]); const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] = @@ -49,8 +52,8 @@ export function useSourceAudioFallback({ } toast.warning( result.error - ? `Could not load companion audio sources: ${summarizeErrorMessage(result.error)}` - : "Could not load companion audio sources. Playback and export may miss microphone audio.", + ? `${t("editor.audio.fallbackUnavailable", "Could not load companion audio sources")}: ${summarizeErrorMessage(result.error)}` + : `${t("editor.audio.fallbackUnavailable", "Could not load companion audio sources")}. ${t("editor.audio.fallbackPlaybackHint", "Playback and export may miss microphone audio.")}`, { id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 }, ); return; @@ -66,7 +69,7 @@ export function useSourceAudioFallback({ setSourceAudioFallbackStartDelayMsByPath({}); } toast.warning( - `Could not load companion audio sources: ${summarizeErrorMessage(String(error))}`, + `${t("editor.audio.fallbackUnavailable", "Could not load companion audio sources")}: ${summarizeErrorMessage(String(error))}`, { id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 }, ); } @@ -76,7 +79,7 @@ export function useSourceAudioFallback({ return () => { cancelled = true; }; - }, [currentSourcePath, refreshKey, summarizeErrorMessage]); + }, [currentSourcePath, refreshKey, summarizeErrorMessage, t]); return { sourceAudioFallbackPaths, sourceAudioFallbackStartDelayMsByPath }; } diff --git a/src/components/video-editor/audio/useVideoEditorAudio.ts b/src/components/video-editor/audio/useVideoEditorAudio.ts index 4d14131d4..e860c0dd2 100644 --- a/src/components/video-editor/audio/useVideoEditorAudio.ts +++ b/src/components/video-editor/audio/useVideoEditorAudio.ts @@ -1,5 +1,6 @@ import React, { useMemo } from "react"; import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; +import type { I18nTranslate } from "@/contexts/I18nContext"; import { resolveSourceTrackRoutingPolicy } from "@/lib/exporter/sourceTrackRoutingPolicy"; import type { AudioRegion, ClipRegion } from "../types"; import { findClipAtTimelineTime } from "../types"; @@ -43,6 +44,7 @@ interface UseVideoEditorAudioParams { previewVolume: number; sourceAudioFallbackRefreshKey?: number; summarizeErrorMessage: (message: string) => string; + t: I18nTranslate; onSourceFallbackLoadError: (error: unknown) => void; } @@ -62,6 +64,7 @@ export function useVideoEditorAudio({ previewVolume, sourceAudioFallbackRefreshKey = 0, summarizeErrorMessage, + t, onSourceFallbackLoadError, }: UseVideoEditorAudioParams) { const fallbackLookupSourcePath = useMemo( @@ -74,6 +77,7 @@ export function useVideoEditorAudio({ currentSourcePath: fallbackLookupSourcePath, refreshKey: sourceAudioFallbackRefreshKey, summarizeErrorMessage, + t, }); const sourceTrackRoutingPolicy = useMemo( diff --git a/src/components/video-editor/captions/useAutoCaptionController.ts b/src/components/video-editor/captions/useAutoCaptionController.ts index 8ac1c11cf..bce2e90ac 100644 --- a/src/components/video-editor/captions/useAutoCaptionController.ts +++ b/src/components/video-editor/captions/useAutoCaptionController.ts @@ -102,8 +102,10 @@ export function useAutoCaptionController({ const result = await window.electronAPI.openWhisperExecutablePicker(); if (!result.success || !result.path) return; setWhisperExecutablePath(result.path); - toast.success("Whisper executable selected"); - }, [setWhisperExecutablePath]); + toast.success( + t("editor.captions.whisperExecutableSelected", "Whisper executable selected"), + ); + }, [setWhisperExecutablePath, t]); const handleDownloadWhisperSmallModel = useCallback(async () => { if (whisperModelDownloadStatus === "downloading") return; @@ -112,7 +114,13 @@ export function useAutoCaptionController({ const result = await window.electronAPI.downloadWhisperSmallModel(); if (!result.success) { setWhisperModelDownloadStatus("error"); - toast.error(result.error || "Failed to download Whisper small model"); + toast.error( + result.error || + t( + "editor.captions.downloadModelFailed", + "Failed to download Whisper small model", + ), + ); return; } if (result.path) { @@ -120,6 +128,7 @@ export function useAutoCaptionController({ setWhisperModelPath(result.path); } }, [ + t, setDownloadedWhisperModelPath, setWhisperModelDownloadProgress, setWhisperModelDownloadStatus, @@ -131,21 +140,25 @@ export function useAutoCaptionController({ const result = await window.electronAPI.openWhisperModelPicker(); if (!result.success || !result.path) return; setWhisperModelPath(result.path); - toast.success("Whisper model selected"); - }, [setWhisperModelPath]); + toast.success(t("editor.captions.whisperModelSelected", "Whisper model selected")); + }, [setWhisperModelPath, t]); const handleDeleteWhisperSmallModel = useCallback(async () => { const result = await window.electronAPI.deleteWhisperSmallModel(); if (!result.success) { - toast.error(result.error || "Failed to delete Whisper small model"); + toast.error( + result.error || + t("editor.captions.deleteModelFailed", "Failed to delete Whisper small model"), + ); return; } setWhisperModelPath((current) => (current === downloadedWhisperModelPath ? null : current)); setDownloadedWhisperModelPath(null); setWhisperModelDownloadStatus("idle"); setWhisperModelDownloadProgress(0); - toast.success("Whisper small model deleted"); + toast.success(t("editor.captions.whisperSmallModelDeleted", "Whisper small model deleted")); }, [ + t, downloadedWhisperModelPath, setDownloadedWhisperModelPath, setWhisperModelDownloadProgress, @@ -173,7 +186,7 @@ export function useAutoCaptionController({ }); } if (!sourcePath) { - toast.error("No source video is loaded"); + toast.error(t("editor.captions.noSourceVideo", "No source video is loaded")); return; } await syncActiveVideoSource(sourcePath, webcamSourcePath); @@ -182,7 +195,12 @@ export function useAutoCaptionController({ setVideoPath(await resolveVideoUrl(sourcePath)); } if (!whisperModelPath) { - toast.error("Select a Whisper model or download the small model first"); + toast.error( + t( + "editor.captions.selectModel", + "Select a Whisper model or download the small model first", + ), + ); return; } @@ -194,14 +212,22 @@ export function useAutoCaptionController({ }); if (!result.success || !result.cues) { const errorMessage = result.error ? getErrorMessage(result.error) : result.message; - toast.error(errorMessage || "Failed to generate captions"); + toast.error( + errorMessage || + t("editor.captions.generateFailed", "Failed to generate captions"), + ); return; } setAutoCaptions(result.cues); if (result.cues.length > 0) { setAutoCaptionSettings((current) => ({ ...current, enabled: true })); } - toast.success(result.message || `Generated ${result.cues.length} captions`); + toast.success( + result.message || + t("editor.captions.generatedCount", "Generated {{count}} captions", { + count: result.cues.length, + }), + ); } catch (error) { toast.error(getErrorMessage(error)); } finally { @@ -211,6 +237,7 @@ export function useAutoCaptionController({ }, [ autoCaptionSettings.language, isGeneratingCaptions, + t, setAutoCaptionSettings, setAutoCaptions, setIsGeneratingCaptions, diff --git a/src/components/video-editor/export/exportRunnerSupport.ts b/src/components/video-editor/export/exportRunnerSupport.ts index 11c8cf8e6..d862632b6 100644 --- a/src/components/video-editor/export/exportRunnerSupport.ts +++ b/src/components/video-editor/export/exportRunnerSupport.ts @@ -1,6 +1,7 @@ import type { RefObject } from "react"; import { useCallback } from "react"; import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; import type { SupportedMp4Dimensions } from "@/lib/exporter"; import type { useVideoEditorAudio } from "../audio/useVideoEditorAudio"; import type { getSmokeExportConfig } from "../smokeExportConfig"; @@ -14,6 +15,7 @@ import type { useExportSession } from "./useExportSession"; import type { useExportSettings } from "./useExportSettings"; export type ExportRunnerInput = { + t: ReturnType["t"]; videoPath: string | null; videoPlaybackRef: RefObject; isPlaying: boolean; @@ -44,26 +46,45 @@ export function showExportErrorToast(message: string) { }); } -export function useExportSuccessToast() { - return useCallback((filePath: string) => { - toast.success(`Exported successfully to ${filePath}`, { - action: { - label: "Show in Folder", - onClick: async () => { - try { - const result = await window.electronAPI.revealInFolder(filePath); - if (!result.success) { - toast.error( - result.error || - result.message || - "Failed to reveal item in folder.", - ); - } - } catch (error) { - toast.error(`Error revealing in folder: ${String(error)}`); - } +export function useExportSuccessToast(t: ReturnType["t"]) { + return useCallback( + (filePath: string) => { + toast.success( + t("editor.exportStatus.successToPath", "Exported successfully to {{path}}", { + path: filePath, + }), + { + action: { + label: t("editor.exportStatus.showInFolder", "Show in Folder"), + onClick: async () => { + try { + const result = await window.electronAPI.revealInFolder(filePath); + if (!result.success) { + toast.error( + result.error || + result.message || + t( + "editor.exportStatus.revealFailed", + "Failed to reveal item in folder.", + ), + ); + } + } catch (error) { + toast.error( + t( + "editor.exportStatus.revealError", + "Error revealing in folder: {{error}}", + { + error: String(error), + }, + ), + ); + } + }, + }, }, - }, - }); - }, []); + ); + }, + [t], + ); } diff --git a/src/components/video-editor/export/useEditorExportController.ts b/src/components/video-editor/export/useEditorExportController.ts index ec6aa3f77..8d97eb67a 100644 --- a/src/components/video-editor/export/useEditorExportController.ts +++ b/src/components/video-editor/export/useEditorExportController.ts @@ -46,6 +46,7 @@ type Input = { export function useEditorExportController(input: Input) { const runner = useExportRunner({ + t: input.t, videoPath: input.videoPath, videoPlaybackRef: input.videoPlaybackRef, isPlaying: input.isPlaying, @@ -66,6 +67,7 @@ export function useEditorExportController(input: Input) { remountPreview: input.remountPreview, }); const dialogActions = useExportDialogActions({ + t: input.t, videoPath: input.videoPath, videoPlaybackRef: input.videoPlaybackRef, hasCaptionsForSidecar: input.hasCaptionsForSidecar, diff --git a/src/components/video-editor/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts index 0c7bc87f4..d2d87a7e2 100644 --- a/src/components/video-editor/export/useExportDialogActions.ts +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -1,5 +1,6 @@ import { type RefObject, useCallback } from "react"; import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; import type { ExportSettings } from "@/lib/exporter"; import { resolveExportStartSettings } from "../exportStartSettings"; import type { VideoPlaybackRef } from "../VideoPlayback"; @@ -10,6 +11,7 @@ type ExportSession = ReturnType; type ExportSettingsState = ReturnType; type UseExportDialogActionsInput = { + t: ReturnType["t"]; videoPath: string | null; videoPlaybackRef: RefObject; hasCaptionsForSidecar: boolean; @@ -20,6 +22,7 @@ type UseExportDialogActionsInput = { }; export function useExportDialogActions({ + t, videoPath, videoPlaybackRef, hasCaptionsForSidecar, @@ -30,34 +33,39 @@ export function useExportDialogActions({ }: UseExportDialogActionsInput) { const handleOpenExportDropdown = useCallback(() => { if (!videoPath) { - toast.error("No video loaded"); + toast.error(t("editor.exportStatus.noVideoLoaded", "No video loaded")); return; } if (session.hasPendingExportSave) { session.setShowExportDropdown(true); session.setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", + t( + "editor.exportStatus.saveDialogCanceled", + "Save dialog canceled. Click Save Again to save without re-rendering.", + ), ); return; } session.setShowExportDropdown(true); session.setExportProgress(null); session.setExportError(null); - }, [videoPath, session]); + }, [t, videoPath, session]); const handleStartExportFromDropdown = useCallback(() => { const video = videoPlaybackRef.current?.video; if (!videoPath) { - toast.error("No video loaded"); + toast.error(t("editor.exportStatus.noVideoLoaded", "No video loaded")); return; } if (!video) { - toast.error("Video not ready"); + toast.error(t("editor.exportStatus.videoNotReady", "Video not ready")); return; } if (video.videoWidth <= 0 || video.videoHeight <= 0) { - toast.error("Video metadata is still loading"); + toast.error( + t("editor.exportStatus.metadataLoading", "Video metadata is still loading"), + ); return; } @@ -80,7 +88,7 @@ export function useExportDialogActions({ session.setExportedFilePath(undefined); session.setShowExportDropdown(true); handleExport(resolvedSettings); - }, [videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings, session, handleExport]); + }, [t, videoPath, videoPlaybackRef, hasCaptionsForSidecar, settings, session, handleExport]); const handleCancelExport = useCallback(() => { if (!session.isExporting) return; @@ -88,14 +96,14 @@ export function useExportDialogActions({ session.exportRunIdRef.current += 1; session.exporterRef.current?.cancel(); session.exporterRef.current = null; - toast.info("Export canceled"); + toast.info(t("editor.exportStatus.exportCanceled", "Export canceled")); session.clearPendingExportSave(); session.setShowExportDropdown(false); session.setIsExporting(false); session.setExportProgress(null); session.setExportError(null); session.setExportedFilePath(undefined); - }, [session]); + }, [t, session]); const handleExportDropdownClose = useCallback(() => { session.clearPendingExportSave(); @@ -122,13 +130,24 @@ export function useExportDialogActions({ pendingSave.fileName, pendingSave.captionSidecar, ) - : { success: false, message: "No pending export to save" }; + : { + success: false, + message: t( + "editor.exportStatus.noPendingExport", + "No pending export to save", + ), + }; if (saveResult.canceled) { session.setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", + t( + "editor.exportStatus.saveDialogCanceled", + "Save dialog canceled. Click Save Again to save without re-rendering.", + ), + ); + toast.info( + t("editor.exportStatus.saveCanceledTryAgain", "Save canceled. You can try again."), ); - toast.info("Save canceled. You can try again."); return; } if (saveResult.success && saveResult.path) { @@ -141,22 +160,32 @@ export function useExportDialogActions({ return; } - const errorMessage = saveResult.message || "Failed to save video"; + const errorMessage = + saveResult.message || + t("editor.exportStatus.failedToSaveVideo", "Failed to save video"); session.setExportError(errorMessage); toast.error(errorMessage); - }, [session, showExportSuccessToast]); + }, [t, session, showExportSuccessToast]); const revealExportedFile = useCallback(async () => { if (!session.exportedFilePath) return; try { const result = await window.electronAPI.revealInFolder(session.exportedFilePath); if (!result.success) { - toast.error(result.error || result.message || "Failed to reveal item in folder."); + toast.error( + result.error || + result.message || + t("editor.exportStatus.revealFailed", "Failed to reveal item in folder."), + ); } } catch (error) { - toast.error(`Failed to reveal item in folder: ${String(error)}`); + toast.error( + t("editor.exportStatus.revealError", "Error revealing in folder: {{error}}", { + error: String(error), + }), + ); } - }, [session.exportedFilePath]); + }, [t, session.exportedFilePath]); return { handleOpenExportDropdown, diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts index 37edbdba4..f54024a1f 100644 --- a/src/components/video-editor/export/useExportRunner.ts +++ b/src/components/video-editor/export/useExportRunner.ts @@ -22,11 +22,12 @@ import { export function useExportRunner(input: ExportRunnerInput) { const inputRef = useRef(input); inputRef.current = input; - const showExportSuccessToast = useExportSuccessToast(); + const showExportSuccessToast = useExportSuccessToast(input.t); const handleExport = useCallback( async (settings: ExportSettings) => { const { + t, videoPath, videoPlaybackRef, isPlaying, @@ -70,13 +71,13 @@ export function useExportRunner(input: ExportRunnerInput) { cancelledExportRunIdRef, } = exportSession; if (!videoPath) { - toast.error("No video loaded"); + toast.error(t("editor.exportStatus.noVideoLoaded", "No video loaded")); return; } const video = videoPlaybackRef.current?.video; if (!video) { - toast.error("Video not ready"); + toast.error(t("editor.exportStatus.videoNotReady", "Video not ready")); return; } @@ -179,9 +180,17 @@ export function useExportRunner(input: ExportRunnerInput) { pendingExportSaveRef.current = pendingSave; setHasPendingExportSave(true); setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", + t( + "editor.exportStatus.saveDialogCanceled", + "Save dialog canceled. Click Save Again to save without re-rendering.", + ), + ); + toast.info( + t( + "editor.exportStatus.saveCanceledWithoutReexport", + "Save canceled. You can save again without re-exporting.", + ), ); - toast.info("Save canceled. You can save again without re-exporting."); keepExportDialogOpen = true; } else if (saveResult.success && saveResult.path) { if (smokeExportStartedAt !== null) { @@ -196,16 +205,22 @@ export function useExportRunner(input: ExportRunnerInput) { return; } } else { - setExportError(saveResult.message || "Failed to save GIF"); - toast.error(saveResult.message || "Failed to save GIF"); + const saveError = + saveResult.message || + t("editor.exportStatus.failedToSaveGif", "Failed to save GIF"); + setExportError(saveError); + toast.error(saveError); if (smokeExportConfig.enabled) { window.close(); return; } } } else { - setExportError(result.error || "GIF export failed"); - toast.error(result.error || "GIF export failed"); + const gifExportError = + result.error || + t("editor.exportStatus.gifExportFailed", "GIF export failed"); + setExportError(gifExportError); + toast.error(gifExportError); if (smokeExportConfig.enabled) { window.close(); return; @@ -407,9 +422,17 @@ export function useExportRunner(input: ExportRunnerInput) { pendingExportSaveRef.current = pendingOnCancel; setHasPendingExportSave(true); setExportError( - "Save dialog canceled. Click Save Again to save without re-rendering.", + t( + "editor.exportStatus.saveDialogCanceled", + "Save dialog canceled. Click Save Again to save without re-rendering.", + ), + ); + toast.info( + t( + "editor.exportStatus.saveCanceledWithoutReexport", + "Save canceled. You can save again without re-exporting.", + ), ); - toast.info("Save canceled. You can save again without re-exporting."); keepExportDialogOpen = true; } else if (saveResult.success && saveResult.path) { if (smokeExportConfig.enabled) { @@ -449,13 +472,21 @@ export function useExportRunner(input: ExportRunnerInput) { encodingMode, shadowIntensity: effectiveShadowIntensity, elapsedMs: smokeExportElapsedMs, - error: saveResult.message || "Failed to save video", + error: + saveResult.message || + t( + "editor.exportStatus.failedToSaveVideo", + "Failed to save video", + ), progressSamples: smokeProgressSamples, metrics: result.metrics, }); } - setExportError(saveResult.message || "Failed to save video"); - showExportErrorToast(saveResult.message || "Failed to save video"); + const saveError = + saveResult.message || + t("editor.exportStatus.failedToSaveVideo", "Failed to save video"); + setExportError(saveError); + showExportErrorToast(saveError); // Keep the pending-save entry so the user can retry without // re-rendering. The temp file is still on disk (the main // process only moves/deletes it on success) and the @@ -481,13 +512,17 @@ export function useExportRunner(input: ExportRunnerInput) { encodingMode, shadowIntensity: effectiveShadowIntensity, elapsedMs: smokeExportElapsedMs, - error: result.error || "Export failed", + error: + result.error || + t("editor.exportStatus.exportFailed", "Export failed"), progressSamples: smokeProgressSamples, metrics: result.metrics, }); } - setExportError(result.error || "Export failed"); - showExportErrorToast(result.error || "Export failed"); + const exportError = + result.error || t("editor.exportStatus.exportFailed", "Export failed"); + setExportError(exportError); + showExportErrorToast(exportError); keepExportDialogOpen = true; if (smokeExportConfig.enabled) { window.close(); @@ -504,7 +539,10 @@ export function useExportRunner(input: ExportRunnerInput) { } catch (error) { if (exportWasCancelled()) return; console.error("Export error:", error); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorMessage = + error instanceof Error + ? error.message + : t("editor.exportStatus.unknownError", "Unknown error"); if (smokeExportConfig.enabled) { await writeSmokeExportReport(smokeExportConfig.outputPath, { success: false, @@ -518,7 +556,11 @@ export function useExportRunner(input: ExportRunnerInput) { }); } setExportError(errorMessage); - showExportErrorToast(`Export failed: ${errorMessage}`); + showExportErrorToast( + t("editor.exportStatus.exportFailedWithDetail", "Export failed: {{error}}", { + error: errorMessage, + }), + ); keepExportDialogOpen = true; if (smokeExportConfig.enabled) { window.close(); diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts index e6cd9942c..f3f675f94 100644 --- a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts +++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts @@ -34,7 +34,7 @@ export function useAnnotationRegionCommands({ startMs: Math.round(span.start), endMs: Math.round(span.end), type: "text", - content: "Enter text...", + content: "", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, @@ -107,7 +107,7 @@ export function useAnnotationRegionCommands({ current.map((region) => { if (region.id !== id) return region; const updated = { ...region, type }; - if (type === "text") updated.content = region.textContent || "Enter text..."; + if (type === "text") updated.content = region.textContent || ""; else if (type === "image") updated.content = region.imageContent || ""; else if (type === "figure") { updated.content = ""; diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts index 083340f9a..b7cf45def 100644 --- a/src/components/video-editor/hooks/useTimelineEditingController.ts +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -59,12 +59,15 @@ type Input = { export function useTimelineEditingController(input: Input) { const { timeline } = input; - const handleSourceFallbackLoadError = useCallback((error: unknown) => { - toast.warning( - `Could not load companion audio source: ${summarizeErrorMessage(getErrorMessage(error))}`, - { duration: 10000 }, - ); - }, []); + const handleSourceFallbackLoadError = useCallback( + (error: unknown) => { + toast.warning( + `${input.t("editor.audio.fallbackLoadError", "Could not load companion audio source")}: ${summarizeErrorMessage(getErrorMessage(error))}`, + { duration: 10000 }, + ); + }, + [input.t], + ); const cursor = useCursorTelemetry({ videoPath: input.videoPath, videoSourcePath: input.videoSourcePath, @@ -99,6 +102,7 @@ export function useTimelineEditingController(input: Input) { previewVolume: input.previewVolume, sourceAudioFallbackRefreshKey: timeline.sourceAudioFallbackRefreshKey, summarizeErrorMessage, + t: input.t, onSourceFallbackLoadError: handleSourceFallbackLoadError, }); const playback = useEditorPlaybackControls({ diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 7f557a70a..df693b7b4 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -130,7 +130,10 @@ export function EditorExportMenu(props: Props) { ) : null} {isLegacyExportInProgress ? (

- Export too slow? Cancel and try Lightning export! + {t( + "editor.exportStatus.legacySlow", + "Export too slow? Cancel and try Lightning export!", + )}

) : null}
@@ -172,7 +175,9 @@ export function EditorExportMenu(props: Props) { ) : null} {exportRuntimeLabel ? (

- Path: {exportRuntimeLabel} + {t("editor.exportStatus.path", "Path: {{path}}", { + path: exportRuntimeLabel, + })}

) : null} {exportNativeSkipLabel ? ( @@ -188,7 +193,9 @@ export function EditorExportMenu(props: Props) {

{exportRuntimeLabel ? (

- Path: {exportRuntimeLabel} + {t("editor.exportStatus.path", "Path: {{path}}", { + path: exportRuntimeLabel, + })}

) : null}

@@ -249,7 +256,9 @@ export function EditorExportMenu(props: Props) {

{exportRuntimeLabel ? (

- Path: {exportRuntimeLabel} + {t("editor.exportStatus.path", "Path: {{path}}", { + path: exportRuntimeLabel, + })}

) : null}

@@ -269,7 +278,7 @@ export function EditorExportMenu(props: Props) { onClick={handleExportDropdownClose} className="h-8 flex-1 border-foreground/10 bg-foreground/5 text-xs text-muted-foreground hover:bg-foreground/10" > - Done + {t("editor.exportStatus.done", "Done")}

diff --git a/src/components/video-editor/layout/EditorPreviewPanel.tsx b/src/components/video-editor/layout/EditorPreviewPanel.tsx index 1c7ca4869..fe695af13 100644 --- a/src/components/video-editor/layout/EditorPreviewPanel.tsx +++ b/src/components/video-editor/layout/EditorPreviewPanel.tsx @@ -125,7 +125,9 @@ export function EditorPreviewPanel(props: Props) { className="h-7 gap-1 px-2 text-xs text-muted-foreground transition-all hover:bg-foreground/10 hover:text-foreground" > - {getAspectRatioLabel(aspectRatio)} + {aspectRatio === "native" + ? t("timeline.toolbar.aspectRatioNative", "Native") + : getAspectRatioLabel(aspectRatio)} @@ -140,7 +142,11 @@ export function EditorPreviewPanel(props: Props) { onClick={() => setAspectRatio(ratio)} className="flex cursor-pointer items-center justify-between gap-3 text-muted-foreground hover:bg-foreground/10 hover:text-foreground" > - {getAspectRatioLabel(ratio)} + + {ratio === "native" + ? t("timeline.toolbar.aspectRatioNative", "Native") + : getAspectRatioLabel(ratio)} + {aspectRatio === ratio ? ( ) : null} @@ -316,7 +322,11 @@ export function EditorPreviewPanel(props: Props) { size="icon" className={`h-7 w-7 rounded-full border border-foreground/10 shadow-[0_8px_18px_rgba(0,0,0,0.18)] transition-all ${isPlaying ? "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"}`} onClick={playback.togglePlayPause} - title={isPlaying ? "Pause" : "Play"} + title={ + isPlaying + ? t("editor.playback.pause", "Pause") + : t("editor.playback.play", "Play") + } > {isPlaying ? ( diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index e85928280..5ef7a5dd8 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -120,7 +120,9 @@ export function EditorShell(props: Props) { if (project.loading) return (
-
Loading video...
+
+ {t("editor.loadingVideo", "Loading video...")} +
{editorDialogs}
@@ -136,7 +138,7 @@ export function EditorShell(props: Props) { onClick={openActions.handleOpenProjectBrowser} className="rounded-[5px] bg-neutral-800 px-3 py-1.5 text-sm font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90" > - Open Projects + {t("editor.openProjects", "Open Projects")} {editorDialogs} diff --git a/src/components/video-editor/project/useEditorProjectController.ts b/src/components/video-editor/project/useEditorProjectController.ts index a2bba3204..2ed7bef8a 100644 --- a/src/components/video-editor/project/useEditorProjectController.ts +++ b/src/components/video-editor/project/useEditorProjectController.ts @@ -183,6 +183,7 @@ export function useEditorProjectController(input: Input) { syncActiveVideoSource: lifecycle.syncActiveVideoSource, }); const saveActions = useProjectSaveActions({ + t: input.t, project: input.project, currentSourcePath: snapshot.currentSourcePath, currentProjectSnapshot: lifecycle.currentProjectSnapshot, @@ -198,6 +199,7 @@ export function useEditorProjectController(input: Input) { remountPreview: input.remountPreview, }); const openActions = useProjectOpenActions({ + t: input.t, project: input.project, appearance: input.appearance, videoPlaybackRef: input.videoPlaybackRef, diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 127e5ec54..123e8eb9d 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -7,6 +7,7 @@ import { useEffect, } from "react"; import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useProjectState } from "../state/useProjectState"; @@ -16,6 +17,7 @@ import type { VideoPlaybackRef } from "../VideoPlayback"; type Set = Dispatch>; type UseProjectOpenActionsInput = { + t: ReturnType["t"]; project: ReturnType; appearance: ReturnType; videoPlaybackRef: RefObject; @@ -35,6 +37,7 @@ type UseProjectOpenActionsInput = { }; export function useProjectOpenActions({ + t, project, appearance, videoPlaybackRef, @@ -65,22 +68,34 @@ export function useProjectOpenActions({ const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { - if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) return; + if ( + !(await confirmReplaceSourceWithUnsavedChanges( + t("editor.project.openAnotherProject", "open another project"), + )) + ) + return; const result = await window.electronAPI.openProjectFileAtPath(projectPath); if (result.canceled) return; if (!result.success) { - toast.error(result.message || "Failed to load project"); + toast.error( + result.message || t("editor.project.loadFailed", "Failed to load project"), + ); return; } if (!(await applyLoadedProject(result.project, result.path ?? null))) { - toast.error("Invalid project file format"); + toast.error(t("editor.project.invalidFormat", "Invalid project file format")); return; } project.setProjectBrowserOpen(false); await refreshProjectLibrary(); - toast.success(`Project loaded from ${result.path}`); + toast.success( + t("editor.project.loadedFrom", "Project loaded from {{path}}", { + path: result.path ?? "", + }), + ); }, [ + t, applyLoadedProject, confirmReplaceSourceWithUnsavedChanges, project, @@ -89,25 +104,38 @@ export function useProjectOpenActions({ ); const handleImportMediaOrProject = useCallback(async () => { - if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) return; + if ( + !(await confirmReplaceSourceWithUnsavedChanges( + t("editor.project.importFile", "import a file"), + )) + ) + return; const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); if (result.canceled) return; if (!result.success) { - toast.error(result.message || "Failed to import file"); + toast.error( + result.message || t("editor.project.importFailed", "Failed to import file"), + ); return; } if (result.kind === "project" || result.project) { if (!(await applyLoadedProject(result.project, result.path ?? null))) { - toast.error("Invalid project file format"); + toast.error(t("editor.project.invalidFormat", "Invalid project file format")); return; } project.setProjectBrowserOpen(false); await refreshProjectLibrary(); - toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded"); + toast.success( + result.path + ? t("editor.project.loadedFrom", "Project loaded from {{path}}", { + path: result.path, + }) + : t("editor.project.loaded", "Project loaded"), + ); return; } if (!result.path) { - toast.error("No media file selected"); + toast.error(t("editor.project.noMediaSelected", "No media file selected")); return; } @@ -139,8 +167,9 @@ export function useProjectOpenActions({ applySessionPresentation(null); project.setProjectBrowserOpen(false); await refreshProjectLibrary(); - toast.success("Media imported"); + toast.success(t("editor.project.mediaImported", "Media imported")); }, [ + t, confirmReplaceSourceWithUnsavedChanges, applyLoadedProject, project, diff --git a/src/components/video-editor/project/useProjectSaveActions.ts b/src/components/video-editor/project/useProjectSaveActions.ts index bf1c51b27..4bc0e6fb9 100644 --- a/src/components/video-editor/project/useProjectSaveActions.ts +++ b/src/components/video-editor/project/useProjectSaveActions.ts @@ -1,5 +1,6 @@ import { type RefObject, useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; +import type { useI18n } from "@/contexts/I18nContext"; import { createProjectData, type EditorProjectData } from "../projectPersistence"; import type { useProjectState } from "../state/useProjectState"; import { cloneStructured, getErrorMessage } from "../videoEditorUtils"; @@ -14,6 +15,7 @@ type SaveProjectOptions = { }; type UseProjectSaveActionsInput = { + t: ReturnType["t"]; project: ReturnType; currentSourcePath: string | null; currentProjectSnapshot: EditorProjectData | null; @@ -30,6 +32,7 @@ type UseProjectSaveActionsInput = { }; export function useProjectSaveActions({ + t, project, currentSourcePath, currentProjectSnapshot, @@ -76,7 +79,8 @@ export function useProjectSaveActions({ clearPendingAutosave(); return queueSave(async () => { if (!currentSourcePath) { - if (!options?.silent) toast.error("No video loaded"); + if (!options?.silent) + toast.error(t("editor.project.noVideoLoaded", "No video loaded")); return false; } @@ -121,12 +125,16 @@ export function useProjectSaveActions({ thumbnail, ); if (result.canceled) { - if (!options?.silent) toast.info("Project save canceled"); + if (!options?.silent) + toast.info(t("editor.project.saveCanceled", "Project save canceled")); return false; } if (!result.success) { if (!options?.silent) - toast.error(result.message || "Failed to save project"); + toast.error( + result.message || + t("editor.project.saveFailed", "Failed to save project"), + ); return false; } @@ -141,7 +149,12 @@ export function useProjectSaveActions({ ), ); if (refreshLibrary) await refreshProjectLibrary(); - if (!options?.silent) toast.success(`Project saved to ${result.path}`); + if (!options?.silent) + toast.success( + t("editor.project.savedTo", "Project saved to {{path}}", { + path: result.path ?? "", + }), + ); return true; } finally { if (remount) remountPreview(); @@ -149,6 +162,7 @@ export function useProjectSaveActions({ }); }, [ + t, clearPendingAutosave, queueSave, currentSourcePath, @@ -195,11 +209,11 @@ export function useProjectSaveActions({ async (name: string, mode: "rename" | "copy" = "rename") => { const trimmedName = name.trim(); if (!trimmedName) { - toast.error("Project name is required"); + toast.error(t("editor.project.nameRequired", "Project name is required")); return false; } if (!currentSourcePath) { - toast.error("No video loaded"); + toast.error(t("editor.project.noVideoLoaded", "No video loaded")); return false; } try { @@ -218,11 +232,13 @@ export function useProjectSaveActions({ mode, ); if (result.canceled) { - toast.info("Project save canceled"); + toast.info(t("editor.project.saveCanceled", "Project save canceled")); return false; } if (!result.success) { - toast.error(result.message || "Failed to save project"); + toast.error( + result.message || t("editor.project.saveFailed", "Failed to save project"), + ); return false; } if (result.path) setCurrentProjectPath(result.path); @@ -236,13 +252,20 @@ export function useProjectSaveActions({ ), ); await refreshProjectLibrary(); - toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); + toast.success( + result.path + ? t("editor.project.savedTo", "Project saved to {{path}}", { + path: result.path, + }) + : t("editor.project.saved", "Project saved"), + ); return true; } finally { remountPreview(); } }, [ + t, currentSourcePath, currentProjectSnapshot, currentPersistedEditorState, @@ -260,7 +283,7 @@ export function useProjectSaveActions({ event?.preventDefault(); const name = projectSaveDialogDraft.trim(); if (!name) { - toast.error("Project name is required"); + toast.error(t("editor.project.nameRequired", "Project name is required")); projectSaveDialogInputRef.current?.focus(); return; } @@ -280,6 +303,7 @@ export function useProjectSaveActions({ } }, [ + t, projectSaveDialogDraft, setIsSavingProjectDialog, projectSaveDialogInputRef, diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 7dff788be..db6ce5dc9 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -12,6 +12,7 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; import { useMemo } from "react"; import { Skeleton } from "@/components/ui/skeleton"; +import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; import { formatClipSpeedLabel } from "../clipSpeedChange"; import AudioWaveform from "./components/waveform/AudioWaveform"; @@ -81,6 +82,7 @@ export default function Item({ loadingLabel, children, }: ItemProps) { + const t = useScopedT("timeline"); const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, @@ -112,7 +114,7 @@ export default function Item({ @@ -193,12 +195,12 @@ export default function Item({
{showAudioWaveform && waveformPeaks && ( - Trim + {t("item.trim", "Trim")} ) : isClip ? ( <> - Clip + {t("item.clip", "Clip")} {clipSpeedLabel && ( @@ -249,7 +251,9 @@ export default function Item({ <> - {speedValue !== undefined ? `${speedValue}×` : "Speed"} + {speedValue !== undefined + ? `${speedValue}×` + : t("item.speed", "Speed")} ) : isAudio ? ( @@ -277,7 +281,9 @@ export default function Item({ weight={zoomMode === "manual" ? "regular" : "fill"} /> - {zoomMode === "manual" ? "Manual" : "Auto"} + {zoomMode === "manual" + ? t("item.manual", "Manual") + : t("item.auto", "Auto")}
) : ( diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 46a610f29..9897010f1 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -173,6 +173,7 @@ const TimelineEditor = forwardRef( ref, ) { const t = useScopedT("settings"); + const tTimeline = useScopedT("timeline"); const totalMs = useMemo( () => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration], @@ -353,6 +354,7 @@ const TimelineEditor = forwardRef( addCaptionAtMs, resolveCaptionSpanAtMs, } = useTimelineEditorRuntime({ + t: tTimeline, ref, videoDuration, totalMs, @@ -409,9 +411,11 @@ const TimelineEditor = forwardRef(
-

No Video Loaded

+

+ {tTimeline("empty.noVideo", "No Video Loaded")} +

- Drag and drop a video to start editing + {tTimeline("empty.dragDrop", "Drag and drop a video to start editing")}

diff --git a/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx index cec20aca8..ee375c6ea 100644 --- a/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx +++ b/src/components/video-editor/timeline/components/toolbar/TimelineToolbar.tsx @@ -16,6 +16,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useScopedT } from "@/contexts/I18nContext"; import { ASPECT_RATIOS, type AspectRatio, @@ -72,6 +73,7 @@ export default function TimelineToolbar({ addAudioLabel, splitClipLabel, }: TimelineToolbarProps) { + const t = useScopedT("timeline"); return (
@@ -134,7 +136,11 @@ export default function TimelineToolbar({ size="sm" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all gap-1" > - {getAspectRatioLabel(aspectRatio)} + + {aspectRatio === "native" + ? t("toolbar.aspectRatioNative", "Native") + : getAspectRatioLabel(aspectRatio)} + @@ -148,7 +154,11 @@ export default function TimelineToolbar({ onClick={() => onAspectRatioChange?.(ratio)} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3" > - {getAspectRatioLabel(ratio)} + + {ratio === "native" + ? t("toolbar.aspectRatioNative", "Native") + : getAspectRatioLabel(ratio)} + {aspectRatio === ratio && ( )} @@ -156,7 +166,7 @@ export default function TimelineToolbar({ ))}
- Custom + {t("toolbar.custom", "Custom")} : {isCustomAspectRatio(aspectRatio) && ( @@ -213,21 +223,21 @@ export default function TimelineToolbar({
- Side Scroll + {t("toolbar.sideScroll", "Side Scroll")} - Pan + {t("toolbar.pan", "Pan")} {scrollLabels.pan} - Pan + {t("toolbar.pan", "Pan")} {scrollLabels.zoom} - Zoom + {t("toolbar.zoom", "Zoom")}
diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts index 63dc36eb2..463de619c 100644 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; +import type { I18nTranslate } from "@/contexts/I18nContext"; import type { TimelineAudioRegion } from "../../core/timelineTypes"; import { resolveAudioPlacement } from "../utils/timelineAudioPlacement"; import { timelineNotifications } from "../utils/timelineNotifications"; @@ -16,6 +17,7 @@ interface TimelineAudioActionsDeps { } interface UseTimelineAudioActionsParams { + t: I18nTranslate; timeline: { videoDuration: number; totalMs: number; @@ -77,6 +79,7 @@ function buildTimelineAudioActionsDeps( } export function useTimelineAudioActions({ + t, timeline, regions, onAudioAdded, @@ -101,8 +104,11 @@ export function useTimelineAudioActions({ const audioDurationMs = await deps.probeAudioDurationMs(audioPath); if (audioDurationMs <= 0) { deps.reportError( - "Could not read audio file", - "The selected file may be corrupted or in an unsupported format.", + t("audio.cannotRead", "Could not read audio file"), + t( + "audio.cannotReadDescription", + "The selected file may be corrupted or in an unsupported format.", + ), ); return; } @@ -110,8 +116,11 @@ export function useTimelineAudioActions({ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); if (totalMs - startPos <= 0) { deps.reportError( - "Cannot place audio here", - "There is no remaining space at the current playhead position.", + t("audio.cannotPlace", "Cannot place audio here"), + t( + "audio.noRemainingSpace", + "There is no remaining space at the current playhead position.", + ), ); return; } @@ -125,8 +134,11 @@ export function useTimelineAudioActions({ }); if (!placement) { deps.reportError( - "Cannot place audio here", - "Audio region already exists at this location or not enough space available.", + t("audio.cannotPlace", "Cannot place audio here"), + t( + "audio.occupied", + "Audio region already exists at this location or not enough space available.", + ), ); return; } @@ -137,7 +149,7 @@ export function useTimelineAudioActions({ placement.trackIndex, ); }, - [videoDuration, totalMs, onAudioAdded, deps, currentTimeMs, audioRegions], + [videoDuration, totalMs, onAudioAdded, deps, currentTimeMs, audioRegions, t], ); return { handleAddAudio }; diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts index c0d19fe25..b7954f4a5 100644 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineCaptionActions.ts @@ -1,5 +1,6 @@ import type { Span } from "dnd-timeline"; import { useCallback } from "react"; +import type { I18nTranslate } from "@/contexts/I18nContext"; import type { CaptionCue } from "../../../types"; import { timelineNotifications } from "../utils/timelineNotifications"; @@ -8,6 +9,7 @@ import { timelineNotifications } from "../utils/timelineNotifications"; export const DEFAULT_CAPTION_DURATION_MS = 1500; interface UseTimelineCaptionActionsParams { + t: I18nTranslate; totalMs: number; // Caption regions in timeline-ms (matches the hover position passed in). captionRegions: CaptionCue[]; @@ -15,6 +17,7 @@ interface UseTimelineCaptionActionsParams { } export function useTimelineCaptionActions({ + t, totalMs, captionRegions, onCaptionAdded, @@ -68,8 +71,8 @@ export function useTimelineCaptionActions({ const startPos = Math.max(0, Math.min(startMs, totalMs)); if (!canPlaceCaptionAtMs(startPos)) { timelineNotifications.error( - "Cannot place caption here", - "A caption already exists at this position.", + t("caption.cannotPlace", "Cannot place caption here"), + t("caption.exists", "A caption already exists at this position."), ); return; } @@ -79,7 +82,7 @@ export function useTimelineCaptionActions({ } onCaptionAdded(span); }, - [onCaptionAdded, totalMs, canPlaceCaptionAtMs, resolveCaptionSpanAtMs], + [onCaptionAdded, totalMs, canPlaceCaptionAtMs, resolveCaptionSpanAtMs, t], ); return { diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts index 4f85751e3..756fa9abe 100644 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts @@ -1,10 +1,12 @@ import type { Span } from "dnd-timeline"; import { useCallback, useEffect, useMemo } from "react"; +import type { I18nTranslate } from "@/contexts/I18nContext"; import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../../../types"; import { buildInteractionZoomSuggestions } from "../../zoomSuggestionUtils"; import { timelineNotifications } from "../utils/timelineNotifications"; interface UseTimelineZoomActionsParams { + t: I18nTranslate; timeline: { videoDuration: number; totalMs: number; @@ -25,6 +27,7 @@ interface UseTimelineZoomActionsParams { } export function useTimelineZoomActions({ + t, timeline, regions, cursorTelemetry, @@ -88,15 +91,18 @@ export function useTimelineZoomActions({ const startPos = Math.max(0, Math.min(startMs, totalMs)); if (!canPlaceZoomAtMs(startPos)) { timelineNotifications.error( - "Cannot place zoom here", - "Zoom already exists here or there is not enough room before the next zoom or clip end.", + t("zoom.cannotPlace", "Cannot place zoom here"), + t( + "zoom.existsOrNoSpace", + "Zoom already exists here or there is not enough room before the next zoom or clip end.", + ), ); return; } onZoomAdded({ start: startPos, end: startPos + defaultDuration }); }, - [videoDuration, totalMs, defaultRegionDurationMs, canPlaceZoomAtMs, onZoomAdded], + [videoDuration, totalMs, defaultRegionDurationMs, canPlaceZoomAtMs, onZoomAdded, t], ); const handleAddZoom = useCallback(() => { @@ -114,20 +120,28 @@ export function useTimelineZoomActions({ if (disableSuggestedZooms) { timelineNotifications.info( - "Suggested zooms are unavailable while cursor looping is enabled.", + t( + "zoom.suggestUnavailable", + "Suggested zooms are unavailable while cursor looping is enabled.", + ), ); return; } if (!onZoomSuggested) { - timelineNotifications.error("Zoom suggestion handler unavailable"); + timelineNotifications.error( + t("zoom.suggestHandlerUnavailable", "Zoom suggestion handler unavailable"), + ); return; } if (cursorTelemetry.length < 2) { timelineNotifications.info( - "No cursor telemetry available", - "Record a screencast first to generate cursor-based suggestions.", + t("zoom.noTelemetry", "No cursor telemetry available"), + t( + "zoom.recordFirst", + "Record a screencast first to generate cursor-based suggestions.", + ), ); return; } @@ -148,24 +162,33 @@ export function useTimelineZoomActions({ if (result.status === "no-telemetry") { timelineNotifications.info( - "No usable cursor telemetry", - "The recording does not include enough cursor movement data.", + t("zoom.noUsableTelemetry", "No usable cursor telemetry"), + t( + "zoom.notEnoughMovement", + "The recording does not include enough cursor movement data.", + ), ); return; } if (result.status === "no-interactions") { timelineNotifications.info( - "No clear interaction moments found", - "Try a recording with pauses or clicks around important actions.", + t("zoom.noInteractionMoments", "No clear interaction moments found"), + t( + "zoom.tryRecording", + "Try a recording with pauses or clicks around important actions.", + ), ); return; } if (result.status === "no-slots" || result.suggestions.length === 0) { timelineNotifications.info( - "No auto-zoom slots available", - "Detected dwell points overlap existing zoom regions.", + t("zoom.noAutoZoomSlots", "No auto-zoom slots available"), + t( + "zoom.dwellPointsOverlap", + "Detected dwell points overlap existing zoom regions.", + ), ); return; } @@ -175,7 +198,9 @@ export function useTimelineZoomActions({ } timelineNotifications.success( - `Added ${result.suggestions.length} interaction-based zoom suggestion${result.suggestions.length === 1 ? "" : "s"}`, + t("zoom.addedSuggestions", "Added {{count}} interaction-based zoom suggestions", { + count: result.suggestions.length, + }), ); }, [ videoDuration, @@ -185,6 +210,7 @@ export function useTimelineZoomActions({ cursorTelemetry, defaultRegionDurationMs, zoomRegions, + t, ]); useEffect(() => { diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 9f7092db0..052dca515 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -1,6 +1,7 @@ import type { Span } from "dnd-timeline"; import type { ForwardedRef, RefObject } from "react"; import { useCallback, useImperativeHandle } from "react"; +import type { I18nTranslate } from "@/contexts/I18nContext"; import type { AnnotationRegion, AudioRegion, @@ -23,6 +24,7 @@ import { useTimelineNormalization } from "./useTimelineNormalization"; import { useTimelineSelection } from "./useTimelineSelection"; interface UseTimelineEditorRuntimeParams { + t: I18nTranslate; ref: ForwardedRef; videoDuration: number; totalMs: number; @@ -73,6 +75,7 @@ interface UseTimelineEditorRuntimeParams { } export function useTimelineEditorRuntime({ + t, ref, videoDuration, totalMs, @@ -211,6 +214,7 @@ export function useTimelineEditorRuntime({ handleAddZoom, handleSuggestZooms, } = useTimelineZoomActions({ + t, timeline: { videoDuration, totalMs, currentTimeMs }, regions: { zoom: zoomRegions, clip: clipRegions }, cursorTelemetry, @@ -223,6 +227,7 @@ export function useTimelineEditorRuntime({ const { canPlaceCaptionAtMs, addCaptionAtMs, resolveCaptionSpanAtMs } = useTimelineCaptionActions({ + t, totalMs, captionRegions: captionCues, onCaptionAdded, @@ -236,6 +241,7 @@ export function useTimelineEditorRuntime({ }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); const { handleAddAudio } = useTimelineAudioActions({ + t, timeline: { videoDuration, totalMs, currentTimeMs }, regions: { audio: audioRegions }, onAudioAdded, diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index da831a168..f6f2b6870 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -182,10 +182,16 @@ const messages: Record = { }, } as const; +export type I18nTranslate = ( + key: string, + fallback?: string, + vars?: Record, +) => string; + interface I18nContextValue { locale: AppLocale; setLocale: (locale: AppLocale) => void; - t: (key: string, fallback?: string, vars?: Record) => string; + t: I18nTranslate; } const I18nContext = createContext(null); @@ -319,6 +325,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { useEffect(() => { document.documentElement.lang = locale; + window.electronAPI?.setAppLocale?.(locale); }, [locale]); const t = useCallback( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 7fe6d5890..810719173 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,6 +1,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { useI18n } from "@/contexts/I18nContext"; import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; import { getVideoExtensionForMimeType, @@ -377,6 +378,7 @@ async function createAudioInputDeviceSnapshot(): Promise< } export function useScreenRecorder(): UseScreenRecorderReturn { + const { t } = useI18n(); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); @@ -1139,7 +1141,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const selectedSource = existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null); if (!selectedSource) { - alert("Please select a source to record"); + alert(t("launch.permissions.selectSource", "Please select a source to record")); return null; } @@ -1238,6 +1240,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { systemAudioDeviceId, systemAudioDeviceName, systemAudioEnabled, + t, ]); const discardActiveNativeCapture = useCallback(async () => { diff --git a/src/i18n/i18nLocale.test.ts b/src/i18n/i18nLocale.test.ts new file mode 100644 index 000000000..70e68ed32 --- /dev/null +++ b/src/i18n/i18nLocale.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import zhCommon from "@/i18n/locales/zh-CN/common.json"; +import zhDialogs from "@/i18n/locales/zh-CN/dialogs.json"; +import zhEditor from "@/i18n/locales/zh-CN/editor.json"; +import zhLaunch from "@/i18n/locales/zh-CN/launch.json"; +import zhSettings from "@/i18n/locales/zh-CN/settings.json"; +import zhShortcuts from "@/i18n/locales/zh-CN/shortcuts.json"; +import zhTimeline from "@/i18n/locales/zh-CN/timeline.json"; + +const messages = { + common: zhCommon, + dialogs: zhDialogs, + editor: zhEditor, + launch: zhLaunch, + settings: zhSettings, + shortcuts: zhShortcuts, + timeline: zhTimeline, +} as const; + +function readMessage(namespace: keyof typeof messages, path: string): string | undefined { + let current: unknown = messages[namespace]; + for (const part of path.split(".")) { + if (!current || typeof current !== "object" || !(part in current)) return undefined; + current = (current as Record)[part]; + } + return typeof current === "string" ? current : undefined; +} + +describe("简体中文界面资源", () => { + it("contains Chinese text for the visible localization gaps", () => { + const requiredMessages: Array<[keyof typeof messages, string, string]> = [ + ["common", "loading", "正在刷新..."], + ["common", "light", "浅色"], + ["common", "dark", "深色"], + ["common", "system", "跟随系统"], + ["common", "close", "关闭"], + ["common", "announcements.dismiss", "关闭"], + ["dialogs", "addFont.alreadyAdded", "此字体已添加。"], + ["editor", "playback.play", "播放"], + ["editor", "playback.pause", "暂停"], + ["editor", "annotations.arrowDirectionOption", "箭头方向:{{direction}}"], + ["editor", "annotations.textPlaceholder", "输入文本"], + ["editor", "annotations.settings", "标注设置"], + ["editor", "annotations.tipCycleForward", "按 Tab 选择下一个重叠标注。"], + ["editor", "annotations.tipCycleBackward", "按 Shift+Tab 选择上一个重叠标注。"], + ["editor", "keyboardShortcuts.cycleAnnotations", "切换重叠标注"], + ["editor", "toolbar.splitClip", "分割片段 (C)"], + [ + "editor", + "timeline.speedClipOverlap", + "变速区域会与下一个片段重叠。在减速前,请先移动或分割片段。", + ], + ["editor", "exportStatus.renderingAudio", "正在渲染音频 {{percent}}%"], + ["editor", "exportStatus.noVideoLoaded", "未加载视频"], + [ + "editor", + "exportStatus.saveDialogCanceled", + "保存已取消。点击“再次保存”即可直接保存,无需重新渲染。", + ], + ["editor", "exportStatus.successToPath", "已成功导出到 {{path}}"], + ["editor", "presets.savedList", "已保存的预设"], + ["editor", "project.saveTitle", "保存项目"], + ["editor", "project.noVideoLoaded", "未加载视频"], + ["editor", "project.savedTo", "项目已保存到 {{path}}"], + ["editor", "theme.appearance", "外观"], + ["editor", "extensions.unavailableTitle", "扩展功能已不可用"], + ["editor", "projectBrowser.noPreview", "暂无预览"], + ["launch", "recording.preparing", "正在准备录制"], + ["launch", "permissions.selectSource", "请选择要录制的源"], + ["launch", "updateToast.experimentalAvailableTitle", "有测试版更新可用"], + [ + "launch", + "updateToast.experimentalDescription", + "已开启测试版更新,可在 Recordly 正式版公开发布前优先试用最新版本", + ], + ["launch", "updateToast.experimentalBadge", "测试版"], + ["launch", "updateToast.previewBadge", "预览版"], + ["settings", "effects.auto", "自动"], + ["settings", "background.unsupportedFormat", "不支持的格式"], + ["settings", "effects.cursorClickEffects.ripple.label", "波纹"], + ["settings", "effects.cursorStyleOptions.dot", "圆点"], + ["settings", "effects.cursorStyleOptions.figma", "极简"], + ["settings", "sections.settings", "设置"], + ["settings", "sections.extensions", "扩展"], + ["settings", "trim.deleteRegion", "删除分割区域"], + ["settings", "annotation.delete", "删除标注"], + ["settings", "effects.webcamMirror", "摄像头镜像"], + ["settings", "captions.timelineQuickAdd", "悬停即可在时间轴添加字幕"], + ["settings", "export.exportVideo", "导出视频"], + ["settings", "captions.animationOff", "关闭"], + ["settings", "captions.animationFade", "淡入淡出"], + ["settings", "captions.animationRise", "上移"], + ["settings", "captions.animationPop", "弹出"], + ["settings", "captions.editor.text", "文本"], + ["settings", "captions.languages.auto", "自动检测"], + [ + "settings", + "updates.experimentalDescription", + "你已选择接收测试版更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + ], + ["settings", "updates.experimental", "测试版更新"], + ["editor", "exportTips.experimentalBuilds", "提示:可在设置中开启测试版访问权限"], + ["shortcuts", "actions.splitClip", "分割片段"], + ["shortcuts", "actions.addTrim", "添加分割"], + ["shortcuts", "actions.addAnnotation", "添加标注"], + ["shortcuts", "actions.cycleForward", "下一个重叠标注"], + ["shortcuts", "actions.cycleBackward", "上一个重叠标注"], + ["shortcuts", "actions.deleteSelectedAlt", "删除选中(替代)"], + ["timeline", "empty.noVideo", "未加载视频"], + ["timeline", "toolbar.custom", "自定义"], + ["timeline", "toolbar.aspectRatioNative", "原始比例"], + ["timeline", "annotation.label", "标注"], + ["timeline", "item.manual", "手动"], + ["timeline", "audio.cannotRead", "无法读取音频文件"], + ["timeline", "caption.cannotPlace", "无法在此处放置字幕"], + ["editor", "audio.fallbackUnavailable", "无法加载备用音频源"], + ["editor", "audio.fallbackPlaybackHint", "播放和导出可能会缺失麦克风声音。"], + ]; + + for (const [namespace, path, expected] of requiredMessages) { + expect(readMessage(namespace, path), `${namespace}.${path}`).toBe(expected); + } + }); +}); diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index e322c3d0a..599a7847d 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly Editor", "subtitle": "Bildschirmaufzeichnung und -bearbeitung", "language": "Sprache", - "manageRecordings": "Aufzeichnungsordner öffnen" + "manageRecordings": "Aufzeichnungsordner öffnen", + "discord": "Join Discord" }, "actions": { "cancel": "Abbrechen", @@ -22,5 +23,18 @@ "invalidFileType": "Ungültiger Dateityp", "failedToUploadImage": "Bild konnte nicht hochgeladen werden", "fileReadError": "Beim Lesen der Datei ist ein Fehler aufgetreten." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/de/dialogs.json b/src/i18n/locales/de/dialogs.json index f99c5ab3b..6db5c7c2a 100644 --- a/src/i18n/locales/de/dialogs.json +++ b/src/i18n/locales/de/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Schriftart \"{{name}}\" erfolgreich hinzugefügt", "addFailed": "Schriftart konnte nicht hinzugefügt werden", "loadTimeout": "Das Laden der Schriftart hat zu lange gedauert. Bitte überprüfen Sie die URL und versuchen Sie es erneut.", - "loadFailed": "Die Schriftart konnte nicht geladen werden. Bitte überprüfen Sie, ob die Google Fonts-URL korrekt ist." + "loadFailed": "Die Schriftart konnte nicht geladen werden. Bitte überprüfen Sie, ob die Google Fonts-URL korrekt ist.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Tastaturkürzel", diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 294c2ce5c..ffa259555 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -43,7 +43,18 @@ "imageUploadError": "Bitte lade eine JPG-, PNG-, GIF- oder WebP-Bilddatei hoch.", "blurStrength": "Unschärfestärke: {{strength}}", "solidColor": "Einfarbig (Zensur)", - "borderRadius": "Randradius" + "borderRadius": "Randradius", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, "fontStyles": { "classic": "Klassisch", @@ -115,14 +126,39 @@ "unsavedChangesTitle": "Ungespeicherte Änderungen", "unsavedChangesDescription": "Möchtest du dein aktuelles Projekt speichern, bevor du {{action}}?", "discardChanges": "Änderungen verwerfen", - "saveProject": "Projekt speichern" + "saveProject": "Projekt speichern", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Tipp: Automatisch angewendete Zooms können in den Einstellungen deaktiviert werden", "experimentalBuilds": "Tipp: Aktiviere den Zugriff auf experimentelle Builds in den Einstellungen", "cursorAppearance": "Tipp: Du kannst das Aussehen deines Cursors anpassen" }, - "account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" }, + "account": { + "title": "Konto", + "comingSoon": "Konto demnächst verfügbar" + }, "nativeCaptureUnavailable": { "title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.", "description": "Ihr Gerät unterstützt keine native Erfassung. Dies kann verschiedene Gründe haben, die wir noch nicht ermittelt haben. Recordly funktioniert weiterhin, aber eine Cursor-Glättung ist nicht möglich.", @@ -135,7 +171,37 @@ "completePercent": "{{percent}} % abgeschlossen", "issue": "Problem beim Export", "complete": "Export abgeschlossen", - "savedSuccessfully": "Deine Datei wurde erfolgreich gespeichert." + "savedSuccessfully": "Deine Datei wurde erfolgreich gespeichert.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Audio mit Geschwindigkeits-/Overlay-Bearbeitungen wird verarbeitet" @@ -146,7 +212,66 @@ }, "timeline": { "expand": "Zeitleiste erweitern", - "collapse": "Zeitleiste reduzieren" + "collapse": "Zeitleiste reduzieren", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Aufnahmeordner öffnen", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Aufnahmeordner öffnen" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 2381f206c..d31d9e61c 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Mikrofon deaktivieren", "enableMicrophone": "Mikrofon aktivieren", "micToggleDisabledTip": "Mikrofon kann während der Aufnahme nicht umgeschaltet werden", + "systemAudioToggleDisabledTip": "Systemaudio kann während der Aufnahme nicht umgeschaltet werden", "disableWebcam": "Webcam-Overlay deaktivieren", "enableWebcam": "Webcam-Overlay aktivieren", "countdownDelay": "Countdown-Verzögerung", @@ -23,6 +24,9 @@ "windows": "Fenster", "screen": "Bildschirm", "window": "Fenster", + "folder": "Ordner", + "display": "Anzeige {{index}}", + "primaryDisplay": "Anzeige {{index}} (Primär)", "noSourcesFound": "Keine Quellen gefunden", "microphone": "Mikrofon", "systemAudio": "Systemaudio", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} ist auf dem neuesten Stand.", "availableTitle": "Recordly {{version}} ist verfügbar.", "availableGenericTitle": "Ein Update ist verfügbar." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Quellen werden geladen...", @@ -94,6 +102,7 @@ "notNow": "Nicht jetzt", "updateNow": "Jetzt aktualisieren", "restartToUpdate": "Zum Aktualisieren neu starten", - "tryAgain": "Erneut versuchen" + "tryAgain": "Erneut versuchen", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index d0837fae2..d7613fbba 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Automatisch", "modeManual": "Manuell", "modeManualDescription": "Legen Sie einen festen Fokuspunkt für diesen Zoom fest", - "modeAutoDescription": "Die Kamera zentriert sich neu, wenn sich der Cursor dem Rand der gezoomten Ansicht nähert" + "modeAutoDescription": "Die Kamera zentriert sich neu, wenn sich der Cursor dem Rand der gezoomten Ansicht nähert", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Trimmbereich löschen" @@ -15,7 +16,8 @@ "playbackSpeed": "Wiedergabegeschwindigkeit", "selectRegion": "Wähle einen Geschwindigkeitsbereich zum Anpassen aus", "deleteRegion": "Geschwindigkeitsbereich löschen", - "label": "Geschwindigkeit" + "label": "Geschwindigkeit", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -150,7 +152,37 @@ "paddingBottom": "Unten", "paddingLeft": "Links", "paddingRight": "Rechts", - "removeBackground": "Hintergrund entfernen" + "removeBackground": "Hintergrund entfernen", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Szene", @@ -160,7 +192,9 @@ "cursor": "Cursor", "webcam": "Webcam", "frame": "Bild", - "crop": "Zuschneiden" + "crop": "Zuschneiden", + "settings": "Einstellungen", + "extensions": "Erweiterungen" }, "captions": { "selectOnTimeline": "Wählen Sie einen Untertitel auf der Zeitleiste aus, um ihn zu bearbeiten.", @@ -197,6 +231,21 @@ "split": "Teilen", "merge": "Zusammenführen", "delete": "Löschen" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Benutzerdefiniertes Bild hochladen", "uploadSuccess": "Benutzerdefiniertes Bild erfolgreich hochgeladen!", "uploadError": "Bitte lade eine JPG- oder JPEG-Bilddatei hoch.", - "uploadErrorDescription": "Es werden nur JPG- und JPEG-Bilder unterstützt." + "uploadErrorDescription": "Es werden nur JPG- und JPEG-Bilder unterstützt.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Exportieren", @@ -239,7 +294,24 @@ "saveProject": "Projekt speichern", "exportVideo": "{{format}} exportieren", "reportBug": "Fehler melden", - "starOnGithub": "Auf GitHub markieren" + "starOnGithub": "Auf GitHub markieren", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "title": "Updates", "experimental": "Experimentelle Updates", "saveFailed": "Update-Kanal konnte nicht geändert werden." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/de/shortcuts.json b/src/i18n/locales/de/shortcuts.json index fc00e5a5b..abdc3ba8c 100644 --- a/src/i18n/locales/de/shortcuts.json +++ b/src/i18n/locales/de/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Zoom hinzufügen", + "splitClip": "Clip teilen", "addTrim": "Trimmen hinzufügen", "addSpeed": "Geschwindigkeit hinzufügen", "addAnnotation": "Anmerkung hinzufügen", diff --git a/src/i18n/locales/de/timeline.json b/src/i18n/locales/de/timeline.json index 6e916dfca..47449848c 100644 --- a/src/i18n/locales/de/timeline.json +++ b/src/i18n/locales/de/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Zoom kann hier nicht platziert werden", "existsOrNoSpace": "Zoom existiert bereits an dieser Stelle oder es ist nicht genügend Platz verfügbar.", + "suggestUnavailable": "Vorgeschlagene Zooms sind bei aktivierter Cursor-Schleife nicht verfügbar.", "suggestHandlerUnavailable": "Zoom-Vorschlags-Handler nicht verfügbar", "noTelemetry": "Keine Cursor-Telemetrie verfügbar", "recordFirst": "Nehmen Sie zuerst einen Screencast auf, um cursorbasierte Vorschläge zu generieren.", @@ -33,9 +34,40 @@ "addAnnotation": "Anmerkung hinzufügen (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Geschwindigkeit hinzufügen (S)", "resizeLeft": "Größe links anpassen", - "resizeRight": "Größe rechts anpassen" + "resizeRight": "Größe rechts anpassen", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 98bb2c7aa..ad14c2f72 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly Editor", "subtitle": "Screen recording and editing", "language": "Language", - "manageRecordings": "Open recordings folder" + "manageRecordings": "Open recordings folder", + "discord": "Join Discord" }, "actions": { "cancel": "Cancel", @@ -22,5 +23,18 @@ "invalidFileType": "Invalid file type", "failedToUploadImage": "Failed to upload image", "fileReadError": "There was an error reading the file." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 97d931a20..38a8bc37e 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Font \"{{name}}\" added successfully", "addFailed": "Failed to add font", "loadTimeout": "Font took too long to load. Please check the URL and try again.", - "loadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct." + "loadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Keyboard Shortcuts", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 099393ba0..18f6e8390 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file.", "blurStrength": "Blur Strength: {{strength}}", "solidColor": "Solid Color (Censorship)", - "borderRadius": "Border Radius" + "borderRadius": "Border Radius", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Classic", "editor": "Editor", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Unsaved changes", "unsavedChangesDescription": "Save your current project before you {{action}}?", "discardChanges": "Discard changes", - "saveProject": "Save project" + "saveProject": "Save project", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Tip: Turn off auto-applied zooms in settings", "experimentalBuilds": "Tip: Try experimental builds by turning on access in settings", "cursorAppearance": "Tip: You can customise your cursor appearance" }, - "account": { "title": "Account", "comingSoon": "Account coming soon" }, + "account": { + "title": "Account", + "comingSoon": "Account coming soon" + }, "nativeCaptureUnavailable": { "title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", "description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.", @@ -136,7 +171,37 @@ "completePercent": "{{percent}}% complete", "issue": "Export issue", "complete": "Export complete", - "savedSuccessfully": "Your file was saved successfully." + "savedSuccessfully": "Your file was saved successfully.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Processing audio with speed/overlay edits" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Expand Timeline", - "collapse": "Collapse Timeline" + "collapse": "Collapse Timeline", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Open recordings folder", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Open recordings folder" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 3752b06b8..af3cc0100 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Disable microphone", "enableMicrophone": "Enable microphone", "micToggleDisabledTip": "Microphone cannot be toggled during recording", + "systemAudioToggleDisabledTip": "System audio cannot be toggled during recording", "disableWebcam": "Disable webcam overlay", "enableWebcam": "Enable webcam overlay", "countdownDelay": "Countdown delay", @@ -23,6 +24,9 @@ "windows": "Windows", "screen": "Screen", "window": "Window", + "folder": "Folder", + "display": "Display {{index}}", + "primaryDisplay": "Display {{index}} (Primary)", "noSourcesFound": "No sources found", "microphone": "Microphone", "systemAudio": "System audio", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} is up to date.", "availableTitle": "Recordly {{version}} is available.", "availableGenericTitle": "An update is available." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Loading sources...", @@ -94,6 +102,7 @@ "notNow": "Not now", "updateNow": "Update now", "restartToUpdate": "Restart to update", - "tryAgain": "Try again" + "tryAgain": "Try again", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 596109cb3..8f0c99efd 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -12,7 +12,8 @@ "modeAuto": "Auto", "modeManual": "Manual", "modeManualDescription": "Set a fixed focus point for this zoom", - "modeAutoDescription": "Camera recenters when the cursor nears the edge of the zoomed view" + "modeAutoDescription": "Camera recenters when the cursor nears the edge of the zoomed view", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Delete Trim Region" @@ -21,7 +22,8 @@ "playbackSpeed": "Playback Speed", "selectRegion": "Select a speed region to adjust", "deleteRegion": "Delete Speed Region", - "label": "Speed" + "label": "Speed", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -156,7 +158,37 @@ "paddingBottom": "Bottom", "paddingLeft": "Left", "paddingRight": "Right", - "removeBackground": "Remove background" + "removeBackground": "Remove background", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Scene", @@ -166,12 +198,14 @@ "cursor": "Cursor", "webcam": "Webcam", "frame": "Frame", - "crop": "Crop" + "crop": "Crop", + "settings": "Settings", + "extensions": "Extensions" }, "captions": { "selectOnTimeline": "Select a caption on the timeline to edit it.", "enabled": "Show", - "timelineQuickAdd": "Hover to add on timeline", + "timelineQuickAdd": "Hover to add a caption on the timeline", "language": "Language", "downloading": "Downloading...", "deleteModel": "Delete Model", @@ -203,6 +237,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -223,7 +272,13 @@ "uploadCustom": "Upload Custom", "uploadSuccess": "Custom image uploaded successfully!", "uploadError": "Please upload a JPG or JPEG image file.", - "uploadErrorDescription": "Only JPG and JPEG images are supported." + "uploadErrorDescription": "Only JPG and JPEG images are supported.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Export", @@ -245,7 +300,24 @@ "saveProject": "Save Project", "exportVideo": "Export {{format}}", "reportBug": "Report Bug", - "starOnGithub": "Star on GitHub" + "starOnGithub": "Star on GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "micLabel": "Source Mic", "mixedLabel": "Source", "deleteRegion": "Delete Audio" + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json index dd789d984..8e60c7190 100644 --- a/src/i18n/locales/en/shortcuts.json +++ b/src/i18n/locales/en/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Add Zoom", + "splitClip": "Split Clip", "addTrim": "Add Trim", "addSpeed": "Add Speed", "addAnnotation": "Add Annotation", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 2fcda722d..441fc5932 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Cannot place zoom here", "existsOrNoSpace": "Zoom already exists at this location or not enough space available.", + "suggestUnavailable": "Suggested zooms are unavailable while cursor looping is enabled.", "suggestHandlerUnavailable": "Zoom suggestion handler unavailable", "noTelemetry": "No cursor telemetry available", "recordFirst": "Record a screencast first to generate cursor-based suggestions.", @@ -33,9 +34,40 @@ "addAnnotation": "Add Annotation (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Add Speed (S)", "resizeLeft": "Resize left", - "resizeRight": "Resize right" + "resizeRight": "Resize right", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f74c23773..49c406ac0 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -4,7 +4,8 @@ "editorTitle": "Editor de Recordly", "subtitle": "Grabación de pantalla y edición", "language": "Idioma", - "manageRecordings": "Abrir carpeta de grabaciones" + "manageRecordings": "Abrir carpeta de grabaciones", + "discord": "Join Discord" }, "actions": { "cancel": "Cancelar", @@ -22,5 +23,18 @@ "invalidFileType": "Tipo de archivo inválido", "failedToUploadImage": "Error al subir la imagen", "fileReadError": "Hubo un error al leer el archivo." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index e6203e667..65770c67f 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Fuente \"{{name}}\" agregada exitosamente", "addFailed": "Error al agregar la fuente", "loadTimeout": "La fuente tardó demasiado en cargar. Por favor verifica la URL e inténtalo de nuevo.", - "loadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta." + "loadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Atajos de teclado", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index c5da834b6..e724aa85e 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.", "blurStrength": "Fuerza del Desenfoque: {{strength}}", "solidColor": "Color Sólido (Censura)", - "borderRadius": "Radio del Borde" + "borderRadius": "Radio del Borde", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Clásico", "editor": "Editor", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Cambios sin guardar", "unsavedChangesDescription": "¿Quieres guardar el proyecto actual antes de {{action}}?", "discardChanges": "Descartar cambios", - "saveProject": "Guardar proyecto" + "saveProject": "Guardar proyecto", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Consejo: Desactiva los zooms automáticos en la configuración", "experimentalBuilds": "Consejo: Activa el acceso a versiones experimentales en la configuración", "cursorAppearance": "Consejo: Puedes personalizar la apariencia del cursor" }, - "account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" }, + "account": { + "title": "Cuenta", + "comingSoon": "Cuenta próximamente" + }, "nativeCaptureUnavailable": { "title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.", "description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. Recordly seguirá funcionando, pero hará imposible el suavizado del cursor.", @@ -136,7 +171,37 @@ "completePercent": "{{percent}}% completado", "issue": "Problema de exportación", "complete": "Exportación completada", - "savedSuccessfully": "Tu archivo se guardó correctamente." + "savedSuccessfully": "Tu archivo se guardó correctamente.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Procesando audio con ediciones de velocidad/superposición" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Expandir línea de tiempo", - "collapse": "Contraer línea de tiempo" + "collapse": "Contraer línea de tiempo", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Abrir carpeta de grabaciones", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Abrir carpeta de grabaciones" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 43ca3e91e..999b82f20 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Desactivar micrófono", "enableMicrophone": "Activar micrófono", "micToggleDisabledTip": "El micrófono no se puede alternar durante la grabación", + "systemAudioToggleDisabledTip": "El audio del sistema no se puede alternar durante la grabación", "disableWebcam": "Desactivar superposición de cámara", "enableWebcam": "Activar superposición de cámara", "countdownDelay": "Retraso de cuenta regresiva", @@ -23,6 +24,9 @@ "windows": "Ventanas", "screen": "Pantalla", "window": "Ventana", + "folder": "Carpeta", + "display": "Pantalla {{index}}", + "primaryDisplay": "Pantalla {{index}} (Principal)", "noSourcesFound": "No se encontraron fuentes", "microphone": "Micrófono", "systemAudio": "Audio del sistema", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} está actualizado.", "availableTitle": "Recordly {{version}} está disponible.", "availableGenericTitle": "Hay una actualización disponible." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Cargando fuentes...", @@ -94,6 +102,7 @@ "notNow": "Ahora no", "updateNow": "Actualizar ahora", "restartToUpdate": "Reiniciar para actualizar", - "tryAgain": "Intentar de nuevo" + "tryAgain": "Intentar de nuevo", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 3510d6bbb..aee69df0e 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Auto", "modeManual": "Manual", "modeManualDescription": "Establece un punto de enfoque fijo para este zoom", - "modeAutoDescription": "La cámara sigue el cursor automáticamente" + "modeAutoDescription": "La cámara sigue el cursor automáticamente", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Eliminar región de recorte" @@ -15,7 +16,8 @@ "playbackSpeed": "Velocidad de reproducción", "selectRegion": "Selecciona una región de velocidad para ajustar", "deleteRegion": "Eliminar región de velocidad", - "label": "Velocidad" + "label": "Velocidad", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -150,7 +152,37 @@ "paddingBottom": "Inferior", "paddingLeft": "Izquierdo", "paddingRight": "Derecho", - "removeBackground": "Quitar fondo" + "removeBackground": "Quitar fondo", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Escena", @@ -160,7 +192,9 @@ "cursor": "Cursor", "webcam": "Cámara", "frame": "Marco", - "crop": "Recorte" + "crop": "Recorte", + "settings": "Configuración", + "extensions": "Extensiones" }, "captions": { "selectOnTimeline": "Selecciona un subtítulo en la línea de tiempo para editarlo.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Subir personalizado", "uploadSuccess": "¡Imagen personalizada subida exitosamente!", "uploadError": "Por favor sube un archivo de imagen JPG o JPEG.", - "uploadErrorDescription": "Solo se admiten imágenes JPG y JPEG." + "uploadErrorDescription": "Solo se admiten imágenes JPG y JPEG.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Exportar", @@ -239,7 +294,24 @@ "saveProject": "Guardar proyecto", "exportVideo": "Exportar {{format}}", "reportBug": "Reportar error", - "starOnGithub": "Estrella en GitHub" + "starOnGithub": "Estrella en GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "title": "Actualizaciones", "experimental": "Actualizaciones experimentales", "saveFailed": "No se pudo cambiar el canal de actualización." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json index 28b42cbc6..b8e1b9467 100644 --- a/src/i18n/locales/es/shortcuts.json +++ b/src/i18n/locales/es/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Agregar Zoom", + "splitClip": "Dividir clip", "addTrim": "Agregar Recorte", "addSpeed": "Agregar Velocidad", "addAnnotation": "Agregar Anotación", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 69d213b27..3338b294a 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "No se puede colocar zoom aquí", "existsOrNoSpace": "Ya existe un zoom en esta ubicación o no hay suficiente espacio.", + "suggestUnavailable": "Las sugerencias de zoom no están disponibles mientras el bucle del cursor está activado.", "suggestHandlerUnavailable": "Manejador de sugerencias de zoom no disponible", "noTelemetry": "No hay telemetría de cursor disponible", "recordFirst": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.", @@ -33,9 +34,40 @@ "addAnnotation": "Agregar Anotación (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Agregar Velocidad (S)", "resizeLeft": "Redimensionar izquierda", - "resizeRight": "Redimensionar derecha" + "resizeRight": "Redimensionar derecha", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 38daaa826..4f5004163 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly Editor", "subtitle": "Enregistrement et édition d’écran", "language": "Langue", - "manageRecordings": "Ouvrir le dossier des enregistrements" + "manageRecordings": "Ouvrir le dossier des enregistrements", + "discord": "Join Discord" }, "actions": { "cancel": "Annuler", @@ -22,5 +23,18 @@ "invalidFileType": "Type de fichier invalide", "failedToUploadImage": "Échec du téléchargement de l’image", "fileReadError": "Une erreur s’est produite lors de la lecture du fichier." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index d13fe5eba..076baf945 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Police « {{name}} » ajoutée avec succès", "addFailed": "Échec de l’ajout de la police", "loadTimeout": "Le chargement de la police a pris trop de temps. Veuillez vérifier l’URL et réessayer.", - "loadFailed": "La police n’a pas pu être chargée. Veuillez vérifier que l’URL Google Fonts est correcte." + "loadFailed": "La police n’a pas pu être chargée. Veuillez vérifier que l’URL Google Fonts est correcte.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Raccourcis clavier", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 08704aaaa..095543543 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Veuillez importer une image JPG, PNG, GIF ou WebP.", "blurStrength": "Intensité du flou : {{strength}}", "solidColor": "Couleur unie (censure)", - "borderRadius": "Rayon de bordure" + "borderRadius": "Rayon de bordure", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Classique", "editor": "Éditeur", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Modifications non enregistrées", "unsavedChangesDescription": "Enregistrer le projet actuel avant de {{action}} ?", "discardChanges": "Ignorer les modifications", - "saveProject": "Enregistrer le projet" + "saveProject": "Enregistrer le projet", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Astuce : Désactivez les zooms automatiques dans les paramètres", "experimentalBuilds": "Astuce : Activez l’accès aux versions expérimentales dans les paramètres", "cursorAppearance": "Astuce : Vous pouvez personnaliser l’apparence du curseur" }, - "account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" }, + "account": { + "title": "Compte", + "comingSoon": "Compte bientôt disponible" + }, "nativeCaptureUnavailable": { "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", "description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.", @@ -136,7 +171,37 @@ "completePercent": "{{percent}} % terminé", "issue": "Problème d’exportation", "complete": "Exportation terminée", - "savedSuccessfully": "Votre fichier a été enregistré avec succès." + "savedSuccessfully": "Votre fichier a été enregistré avec succès.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Traitement de l’audio avec modifications de vitesse/superposition" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Développer la timeline", - "collapse": "Réduire la timeline" + "collapse": "Réduire la timeline", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Ouvrir le dossier des enregistrements", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Ouvrir le dossier des enregistrements" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 083426afa..76b94136a 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Désactiver le microphone", "enableMicrophone": "Activer le microphone", "micToggleDisabledTip": "Le microphone ne peut pas être basculé pendant l'enregistrement", + "systemAudioToggleDisabledTip": "L’audio système ne peut pas être basculé pendant l’enregistrement", "disableWebcam": "Désactiver l’incrustation webcam", "enableWebcam": "Activer l’incrustation webcam", "countdownDelay": "Délai du compte à rebours", @@ -23,6 +24,9 @@ "windows": "Fenêtres", "screen": "Écran", "window": "Fenêtre", + "folder": "Dossier", + "display": "Écran {{index}}", + "primaryDisplay": "Écran {{index}} (principal)", "noSourcesFound": "Aucune source trouvée", "microphone": "Microphone", "systemAudio": "Audio système", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} est à jour.", "availableTitle": "Recordly {{version}} est disponible.", "availableGenericTitle": "Une mise à jour est disponible." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Chargement des sources...", @@ -94,6 +102,7 @@ "notNow": "Plus tard", "updateNow": "Mettre à jour", "restartToUpdate": "Redémarrer pour mettre à jour", - "tryAgain": "Réessayer" + "tryAgain": "Réessayer", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index d7e4e6753..3255c6219 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Automatique", "modeManual": "Manuel", "modeManualDescription": "Définir un point de focus fixe pour ce zoom", - "modeAutoDescription": "La caméra suit automatiquement le curseur" + "modeAutoDescription": "La caméra suit automatiquement le curseur", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Supprimer la zone de découpe" @@ -15,7 +16,8 @@ "playbackSpeed": "Vitesse de lecture", "selectRegion": "Sélectionnez une zone de vitesse à ajuster", "deleteRegion": "Supprimer la zone de vitesse", - "label": "Vitesse" + "label": "Vitesse", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -150,7 +152,37 @@ "paddingBottom": "Bas", "paddingLeft": "Gauche", "paddingRight": "Droite", - "removeBackground": "Supprimer l’arrière-plan" + "removeBackground": "Supprimer l’arrière-plan", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Scène", @@ -160,7 +192,9 @@ "cursor": "Curseur", "webcam": "Webcam", "frame": "Cadre", - "crop": "Recadrage" + "crop": "Recadrage", + "settings": "Paramètres", + "extensions": "Extensions" }, "captions": { "selectOnTimeline": "Sélectionnez un sous-titre sur la timeline pour le modifier.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Importer un fond personnalisé", "uploadSuccess": "Image personnalisée importée avec succès !", "uploadError": "Veuillez importer un fichier image JPG ou JPEG.", - "uploadErrorDescription": "Seules les images JPG et JPEG sont prises en charge." + "uploadErrorDescription": "Seules les images JPG et JPEG sont prises en charge.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Exportation", @@ -239,7 +294,24 @@ "saveProject": "Enregistrer le projet", "exportVideo": "Exporter en {{format}}", "reportBug": "Signaler un bug", - "starOnGithub": "Mettre une étoile sur GitHub" + "starOnGithub": "Mettre une étoile sur GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "title": "Mises à jour", "experimental": "Mises à jour expérimentales", "saveFailed": "Impossible de changer le canal de mise à jour." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json index ed9f03088..1aec8e1f3 100644 --- a/src/i18n/locales/fr/shortcuts.json +++ b/src/i18n/locales/fr/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Ajouter un zoom", + "splitClip": "Scinder le clip", "addTrim": "Ajouter un découpage", "addSpeed": "Ajouter une vitesse", "addAnnotation": "Ajouter une annotation", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 68c59633b..290d84436 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Impossible de placer le zoom ici", "existsOrNoSpace": "Un zoom existe déjà à cet emplacement ou il n’y a pas assez d’espace disponible.", + "suggestUnavailable": "Les suggestions de zoom ne sont pas disponibles lorsque la boucle du curseur est activée.", "suggestHandlerUnavailable": "Gestionnaire de suggestion de zoom indisponible", "noTelemetry": "Aucune télémétrie du curseur disponible", "recordFirst": "Enregistrez d’abord un screencast pour générer des suggestions basées sur le curseur.", @@ -33,9 +34,40 @@ "addAnnotation": "Ajouter une annotation (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Ajouter une vitesse (S)", "resizeLeft": "Redimensionner vers la gauche", - "resizeRight": "Redimensionner vers la droite" + "resizeRight": "Redimensionner vers la droite", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 9c270d7e8..9ad678d6c 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -4,7 +4,8 @@ "editorTitle": "Editor Recordly", "subtitle": "Registrazione e modifica dello schermo", "language": "Lingua", - "manageRecordings": "Apri cartella registrazioni" + "manageRecordings": "Apri cartella registrazioni", + "discord": "Join Discord" }, "actions": { "cancel": "Annulla", @@ -22,5 +23,18 @@ "invalidFileType": "Tipo di file non valido", "failedToUploadImage": "Caricamento immagine non riuscito", "fileReadError": "Si è verificato un errore durante la lettura del file." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json index 4681c0bc9..96f22f62f 100644 --- a/src/i18n/locales/it/dialogs.json +++ b/src/i18n/locales/it/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Font \"{{name}}\" aggiunto con successo", "addFailed": "Aggiunta del font non riuscita", "loadTimeout": "Il caricamento del font ha richiesto troppo tempo. Verifica l'URL e riprova.", - "loadFailed": "Impossibile caricare il font. Verifica che l'URL Google Fonts sia corretto." + "loadFailed": "Impossibile caricare il font. Verifica che l'URL Google Fonts sia corretto.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Scorciatoie da tastiera", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 0576b03c3..4f0ca0fb8 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Carica un file immagine JPG, PNG, GIF o WebP.", "blurStrength": "Intensità sfocatura: {{strength}}", "solidColor": "Colore pieno (Censura)", - "borderRadius": "Raggio bordo" + "borderRadius": "Raggio bordo", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Classico", "editor": "Editor", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Modifiche non salvate", "unsavedChangesDescription": "Salvare il progetto corrente prima di {{action}}?", "discardChanges": "Ignora modifiche", - "saveProject": "Salva progetto" + "saveProject": "Salva progetto", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Suggerimento: Disattiva gli zoom applicati automaticamente nelle impostazioni", "experimentalBuilds": "Suggerimento: Attiva l’accesso alle build sperimentali nelle impostazioni", "cursorAppearance": "Suggerimento: Puoi personalizzare l’aspetto del cursore" }, - "account": { "title": "Account", "comingSoon": "Account in arrivo" }, + "account": { + "title": "Account", + "comingSoon": "Account in arrivo" + }, "nativeCaptureUnavailable": { "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", "description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. Recordly funziona comunque, ma non è possibile applicare lo smoothing del cursore.", @@ -136,7 +171,37 @@ "completePercent": "{{percent}}% completato", "issue": "Problema di esportazione", "complete": "Esportazione completata", - "savedSuccessfully": "Il tuo file è stato salvato con successo." + "savedSuccessfully": "Il tuo file è stato salvato con successo.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Elaborazione audio con modifiche di velocità/overlay" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Espandi timeline", - "collapse": "Comprimi timeline" + "collapse": "Comprimi timeline", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Apri cartella registrazioni", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Apri cartella registrazioni" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 487ba2499..2715c74ff 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Disabilita microfono", "enableMicrophone": "Abilita microfono", "micToggleDisabledTip": "Il microfono non può essere attivato/disattivato durante la registrazione", + "systemAudioToggleDisabledTip": "L'audio di sistema non può essere attivato/disattivato durante la registrazione", "disableWebcam": "Disabilita overlay webcam", "enableWebcam": "Abilita overlay webcam", "countdownDelay": "Ritardo conto alla rovescia", @@ -23,6 +24,9 @@ "windows": "Finestre", "screen": "Schermo", "window": "Finestra", + "folder": "Cartella", + "display": "Schermo {{index}}", + "primaryDisplay": "Schermo {{index}} (principale)", "noSourcesFound": "Nessuna sorgente trovata", "microphone": "Microfono", "systemAudio": "Audio di sistema", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} è aggiornato.", "availableTitle": "Recordly {{version}} è disponibile.", "availableGenericTitle": "È disponibile un aggiornamento." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Caricamento sorgenti...", @@ -94,6 +102,7 @@ "notNow": "Non ora", "updateNow": "Aggiorna ora", "restartToUpdate": "Riavvia per aggiornare", - "tryAgain": "Riprova" + "tryAgain": "Riprova", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 8027ac02c..1703e7a42 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Auto", "modeManual": "Manuale", "modeManualDescription": "Imposta un punto focale fisso per questo zoom", - "modeAutoDescription": "La camera si ricentra quando il cursore si avvicina al bordo della vista zoomata" + "modeAutoDescription": "La camera si ricentra quando il cursore si avvicina al bordo della vista zoomata", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Elimina regione di taglio" @@ -15,7 +16,8 @@ "playbackSpeed": "Velocità di riproduzione", "selectRegion": "Seleziona una regione di velocità da modificare", "deleteRegion": "Elimina regione di velocità", - "label": "Velocità" + "label": "Velocità", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -150,7 +152,37 @@ "paddingBottom": "Basso", "paddingLeft": "Sinistra", "paddingRight": "Destra", - "removeBackground": "Rimuovi sfondo" + "removeBackground": "Rimuovi sfondo", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Scena", @@ -160,7 +192,9 @@ "cursor": "Cursore", "webcam": "Webcam", "frame": "Cornice", - "crop": "Ritaglio" + "crop": "Ritaglio", + "settings": "Impostazioni", + "extensions": "Estensioni" }, "captions": { "selectOnTimeline": "Seleziona un sottotitolo sulla timeline per modificarlo.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Carica personalizzato", "uploadSuccess": "Immagine personalizzata caricata con successo!", "uploadError": "Carica un file immagine JPG o JPEG.", - "uploadErrorDescription": "Sono supportate solo immagini JPG e JPEG." + "uploadErrorDescription": "Sono supportate solo immagini JPG e JPEG.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Esporta", @@ -239,7 +294,24 @@ "saveProject": "Salva progetto", "exportVideo": "Esporta {{format}}", "reportBug": "Segnala bug", - "starOnGithub": "Metti una stella su GitHub" + "starOnGithub": "Metti una stella su GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "title": "Aggiornamenti", "experimental": "Aggiornamenti sperimentali", "saveFailed": "Impossibile cambiare il canale di aggiornamento." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json index 692a30a40..15d7807f2 100644 --- a/src/i18n/locales/it/shortcuts.json +++ b/src/i18n/locales/it/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Aggiungi zoom", + "splitClip": "Dividi clip", "addTrim": "Aggiungi taglio", "addSpeed": "Aggiungi velocità", "addAnnotation": "Aggiungi annotazione", diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index 2f93eef0c..385c20fbf 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Impossibile posizionare lo zoom qui", "existsOrNoSpace": "Uno zoom esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "suggestUnavailable": "I suggerimenti di zoom non sono disponibili quando il ciclo del cursore è attivo.", "suggestHandlerUnavailable": "Gestore dei suggerimenti zoom non disponibile", "noTelemetry": "Telemetria del cursore non disponibile", "recordFirst": "Registra prima uno screencast per generare suggerimenti basati sul cursore.", @@ -33,9 +34,40 @@ "addAnnotation": "Aggiungi annotazione (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Aggiungi velocità (S)", "resizeLeft": "Ridimensiona a sinistra", - "resizeRight": "Ridimensiona a destra" + "resizeRight": "Ridimensiona a destra", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index f036318e0..d5e859361 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly 편집기", "subtitle": "화면 녹화 및 편집", "language": "언어", - "manageRecordings": "녹화 폴더 열기" + "manageRecordings": "녹화 폴더 열기", + "discord": "Join Discord" }, "actions": { "cancel": "취소", @@ -22,5 +23,18 @@ "invalidFileType": "잘못된 파일 형식입니다", "failedToUploadImage": "이미지 업로드에 실패했습니다", "fileReadError": "파일을 읽는 중 오류가 발생했습니다." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/ko/dialogs.json b/src/i18n/locales/ko/dialogs.json index e63fa57fa..f49032c99 100644 --- a/src/i18n/locales/ko/dialogs.json +++ b/src/i18n/locales/ko/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "글꼴 \"{{name}}\"을(를) 추가했습니다", "addFailed": "글꼴 추가에 실패했습니다", "loadTimeout": "글꼴을 불러오는 데 너무 오래 걸립니다. URL을 확인한 뒤 다시 시도해 주세요.", - "loadFailed": "글꼴을 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요." + "loadFailed": "글꼴을 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "키보드 단축키", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index ddc411b9e..f887b822c 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -15,7 +15,6 @@ "arrow": "화살표", "blur": "흐리게", "textContent": "텍스트 내용", - "textPlaceholder": "텍스트를 입력하세요...", "fontStyle": "글꼴 스타일", "selectStyle": "스타일 선택", @@ -44,9 +43,19 @@ "imageUploadError": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.", "blurStrength": "블러 강도: {{strength}}", "solidColor": "단색 (검열)", - "borderRadius": "테두리 반경" + "borderRadius": "테두리 반경", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "클래식", "editor": "에디터", @@ -117,14 +126,39 @@ "unsavedChangesTitle": "저장되지 않은 변경 사항", "unsavedChangesDescription": "{{action}} 전에 현재 프로젝트를 저장하시겠습니까?", "discardChanges": "변경 사항 버리기", - "saveProject": "프로젝트 저장" + "saveProject": "프로젝트 저장", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "팁: 설정에서 자동 적용 확대를 끌 수 있습니다", "experimentalBuilds": "팁: 설정에서 실험적 빌드 액세스를 켜 보세요", "cursorAppearance": "팁: 커서 모양을 사용자 지정할 수 있습니다" }, - "account": { "title": "계정", "comingSoon": "계정 기능 준비 중" }, + "account": { + "title": "계정", + "comingSoon": "계정 기능 준비 중" + }, "nativeCaptureUnavailable": { "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", "description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.", @@ -137,7 +171,37 @@ "completePercent": "{{percent}}% 완료", "issue": "내보내기 오류", "complete": "내보내기 완료", - "savedSuccessfully": "파일이 성공적으로 저장되었습니다." + "savedSuccessfully": "파일이 성공적으로 저장되었습니다.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "속도/오버레이 편집으로 오디오 처리 중" @@ -148,7 +212,66 @@ }, "timeline": { "expand": "타임라인 펼치기", - "collapse": "타임라인 접기" + "collapse": "타임라인 접기", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "녹화 폴더 열기", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "녹화 폴더 열기" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index ba6cbeff0..73d1edc48 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "마이크 끄기", "enableMicrophone": "마이크 켜기", "micToggleDisabledTip": "녹음 중에는 마이크를 전환할 수 없습니다", + "systemAudioToggleDisabledTip": "녹음 중에는 시스템 오디오를 전환할 수 없습니다", "disableWebcam": "웹캠 오버레이 끄기", "enableWebcam": "웹캠 오버레이 켜기", "countdownDelay": "카운트다운 지연", @@ -23,6 +24,9 @@ "windows": "창", "screen": "화면", "window": "창", + "folder": "폴더", + "display": "디스플레이 {{index}}", + "primaryDisplay": "디스플레이 {{index}} (주)", "noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다", "microphone": "마이크", "systemAudio": "시스템 오디오", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}}은(는) 최신 버전입니다.", "availableTitle": "Recordly {{version}}을(를) 사용할 수 있습니다.", "availableGenericTitle": "업데이트를 사용할 수 있습니다." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "소스를 불러오는 중...", @@ -94,6 +102,7 @@ "notNow": "나중에", "updateNow": "지금 업데이트", "restartToUpdate": "다시 시작하여 업데이트", - "tryAgain": "다시 시도" + "tryAgain": "다시 시도", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index b2063381b..5da1c2ad7 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -6,7 +6,8 @@ "modeAuto": "자동", "modeManual": "수동", "modeManualDescription": "이 확대에 고정 초점을 설정합니다", - "modeAutoDescription": "카메라가 커서를 자동으로 따라갑니다" + "modeAutoDescription": "카메라가 커서를 자동으로 따라갑니다", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "트림 구간 삭제" @@ -15,7 +16,8 @@ "playbackSpeed": "재생 속도", "selectRegion": "조정할 속도 구간을 선택하세요", "deleteRegion": "속도 구간 삭제", - "label": "속도" + "label": "속도", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "클립", @@ -150,7 +152,37 @@ "paddingBottom": "아래", "paddingLeft": "왼쪽", "paddingRight": "오른쪽", - "removeBackground": "배경 제거" + "removeBackground": "배경 제거", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "장면", @@ -160,7 +192,9 @@ "cursor": "커서", "webcam": "웹캠", "frame": "프레임", - "crop": "자르기" + "crop": "자르기", + "settings": "설정", + "extensions": "확장" }, "captions": { "selectOnTimeline": "타임라인에서 자막을 선택하여 편집하세요.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "사용자 이미지 업로드", "uploadSuccess": "사용자 이미지를 업로드했습니다!", "uploadError": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.", - "uploadErrorDescription": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요." + "uploadErrorDescription": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "내보내기", @@ -239,7 +294,24 @@ "saveProject": "프로젝트 저장", "exportVideo": "{{format}} 내보내기", "reportBug": "버그 신고", - "starOnGithub": "GitHub에서 별표 주기" + "starOnGithub": "GitHub에서 별표 주기", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "오디오", @@ -257,5 +329,8 @@ "title": "업데이트", "experimental": "실험적 업데이트", "saveFailed": "업데이트 채널을 변경하지 못했습니다." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/ko/shortcuts.json b/src/i18n/locales/ko/shortcuts.json index dc0303d20..647affc0b 100644 --- a/src/i18n/locales/ko/shortcuts.json +++ b/src/i18n/locales/ko/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "확대 추가", + "splitClip": "클립 분할", "addTrim": "트림 추가", "addSpeed": "속도 추가", "addAnnotation": "주석 추가", diff --git a/src/i18n/locales/ko/timeline.json b/src/i18n/locales/ko/timeline.json index 810c775c0..d8c1894f3 100644 --- a/src/i18n/locales/ko/timeline.json +++ b/src/i18n/locales/ko/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "여기에는 확대를 배치할 수 없습니다", "existsOrNoSpace": "이 위치에는 이미 확대가 있거나 사용할 수 있는 공간이 부족합니다.", + "suggestUnavailable": "커서 반복이 활성화되어 있는 동안에는 확대 추천을 사용할 수 없습니다.", "suggestHandlerUnavailable": "확대 추천 기능을 사용할 수 없습니다", "noTelemetry": "사용 가능한 커서 텔레메트리가 없습니다", "recordFirst": "커서 기반 추천을 만들려면 먼저 화면 녹화를 진행해 주세요.", @@ -33,9 +34,40 @@ "addAnnotation": "주석 추가 (A)" }, "audio": { - "label": "오디오" + "label": "오디오", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "속도 추가 (S)", "resizeLeft": "왼쪽 크기 조절", - "resizeRight": "오른쪽 크기 조절" + "resizeRight": "오른쪽 크기 조절", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0e7c2f33f..fd1318d83 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly Editor", "subtitle": "Schermopname en bewerking", "language": "Taal", - "manageRecordings": "Opnamemap openen" + "manageRecordings": "Opnamemap openen", + "discord": "Join Discord" }, "actions": { "cancel": "Annuleren", @@ -22,5 +23,18 @@ "invalidFileType": "Ongeldig bestandstype", "failedToUploadImage": "Afbeelding uploaden mislukt", "fileReadError": "Er is een fout opgetreden bij het lezen van het bestand." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/nl/dialogs.json b/src/i18n/locales/nl/dialogs.json index 32ca08e0a..01b6c4e10 100644 --- a/src/i18n/locales/nl/dialogs.json +++ b/src/i18n/locales/nl/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Lettertype \"{{name}}\" succesvol toegevoegd", "addFailed": "Lettertype toevoegen mislukt", "loadTimeout": "Lettertype duurde te lang om te laden. Controleer de URL en probeer het opnieuw.", - "loadFailed": "Het lettertype kon niet worden geladen. Controleer of de Google Fonts-URL correct is." + "loadFailed": "Het lettertype kon niet worden geladen. Controleer of de Google Fonts-URL correct is.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Sneltoetsen", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index 9d9376c7b..503b8e2de 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -15,7 +15,6 @@ "arrow": "Pijl", "blur": "Vervagen", "textContent": "Tekstinhoud", - "textPlaceholder": "Voer je tekst in...", "fontStyle": "Lettertype", "selectStyle": "Selecteer stijl", @@ -44,9 +43,19 @@ "imageUploadError": "Upload een JPG-, PNG-, GIF- of WebP-afbeelding.", "blurStrength": "Vervagingssterkte: {{strength}}", "solidColor": "Effen Kleur (Censuur)", - "borderRadius": "Hoekradius" + "borderRadius": "Hoekradius", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Klassiek", "editor": "Editor", @@ -117,14 +126,39 @@ "unsavedChangesTitle": "Niet-opgeslagen wijzigingen", "unsavedChangesDescription": "Wil je het huidige project opslaan voordat je {{action}}?", "discardChanges": "Wijzigingen negeren", - "saveProject": "Project opslaan" + "saveProject": "Project opslaan", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Tip: Schakel automatisch toegepaste zooms uit in de instellingen", "experimentalBuilds": "Tip: Schakel toegang tot experimentele builds in via de instellingen", "cursorAppearance": "Tip: Je kunt het uiterlijk van je cursor aanpassen" }, - "account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" }, + "account": { + "title": "Account", + "comingSoon": "Account binnenkort beschikbaar" + }, "nativeCaptureUnavailable": { "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", "description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.", @@ -137,7 +171,37 @@ "completePercent": "{{percent}}% voltooid", "issue": "Exportprobleem", "complete": "Export voltooid", - "savedSuccessfully": "Je bestand is succesvol opgeslagen." + "savedSuccessfully": "Je bestand is succesvol opgeslagen.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Audio verwerken met snelheids-/overlay-bewerkingen" @@ -148,7 +212,66 @@ }, "timeline": { "expand": "Tijdlijn uitvouwen", - "collapse": "Tijdlijn samenvouwen" + "collapse": "Tijdlijn samenvouwen", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Opnamemap openen", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Opnamemap openen" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index 13841229c..57db21fd5 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Microfoon uitschakelen", "enableMicrophone": "Microfoon inschakelen", "micToggleDisabledTip": "Microfoon kan niet worden gewijzigd tijdens de opname", + "systemAudioToggleDisabledTip": "Systeemaudio kan niet worden gewijzigd tijdens de opname", "disableWebcam": "Webcam-overlay uitschakelen", "enableWebcam": "Webcam-overlay inschakelen", "countdownDelay": "Aftelvertraging", @@ -23,6 +24,9 @@ "windows": "Vensters", "screen": "Scherm", "window": "Venster", + "folder": "Map", + "display": "Beeldscherm {{index}}", + "primaryDisplay": "Beeldscherm {{index}} (primair)", "noSourcesFound": "Geen bronnen gevonden", "microphone": "Microfoon", "systemAudio": "Systeemaudio", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} is up-to-date.", "availableTitle": "Recordly {{version}} is beschikbaar.", "availableGenericTitle": "Er is een update beschikbaar." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Bronnen laden...", @@ -94,6 +102,7 @@ "notNow": "Niet nu", "updateNow": "Nu updaten", "restartToUpdate": "Opnieuw starten om te updaten", - "tryAgain": "Opnieuw proberen" + "tryAgain": "Opnieuw proberen", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 4fda91805..0944b3cd6 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Auto", "modeManual": "Handmatig", "modeManualDescription": "Stel een vast focuspunt in voor deze zoom", - "modeAutoDescription": "De camera volgt de cursor automatisch" + "modeAutoDescription": "De camera volgt de cursor automatisch", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Trimgebied verwijderen" @@ -15,7 +16,8 @@ "playbackSpeed": "Afspeelsnelheid", "selectRegion": "Selecteer een snelheidsgebied om aan te passen", "deleteRegion": "Snelheidsgebied verwijderen", - "label": "Snelheid" + "label": "Snelheid", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clip", @@ -150,7 +152,37 @@ "paddingBottom": "Onder", "paddingLeft": "Links", "paddingRight": "Rechts", - "removeBackground": "Achtergrond verwijderen" + "removeBackground": "Achtergrond verwijderen", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Scène", @@ -160,7 +192,9 @@ "cursor": "Cursor", "webcam": "Webcam", "frame": "Kader", - "crop": "Bijsnijden" + "crop": "Bijsnijden", + "settings": "Instellingen", + "extensions": "Extensies" }, "captions": { "selectOnTimeline": "Selecteer een ondertitel op de tijdlijn om deze te bewerken.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Afbeelding uploaden", "uploadSuccess": "Eigen afbeelding succesvol geüpload!", "uploadError": "Upload een JPG- of JPEG-afbeelding.", - "uploadErrorDescription": "Alleen JPG- en JPEG-afbeeldingen worden ondersteund." + "uploadErrorDescription": "Alleen JPG- en JPEG-afbeeldingen worden ondersteund.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Exporteren", @@ -239,7 +294,24 @@ "saveProject": "Project opslaan", "exportVideo": "{{format}} exporteren", "reportBug": "Bug melden", - "starOnGithub": "Ster op GitHub" + "starOnGithub": "Ster op GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Audio", @@ -257,5 +329,8 @@ "title": "Updates", "experimental": "Experimentele updates", "saveFailed": "Kan het updatekanaal niet wijzigen." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/nl/shortcuts.json b/src/i18n/locales/nl/shortcuts.json index d11e3d8dd..7f7c856d3 100644 --- a/src/i18n/locales/nl/shortcuts.json +++ b/src/i18n/locales/nl/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Zoom toevoegen", + "splitClip": "Clip splitsen", "addTrim": "Trim toevoegen", "addSpeed": "Snelheid toevoegen", "addAnnotation": "Annotatie toevoegen", diff --git a/src/i18n/locales/nl/timeline.json b/src/i18n/locales/nl/timeline.json index aa5377424..642f9d446 100644 --- a/src/i18n/locales/nl/timeline.json +++ b/src/i18n/locales/nl/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Kan zoom hier niet plaatsen", "existsOrNoSpace": "Er bestaat al een zoom op deze locatie of er is niet genoeg ruimte beschikbaar.", + "suggestUnavailable": "Zoomsuggesties zijn niet beschikbaar wanneer de cursorlus is ingeschakeld.", "suggestHandlerUnavailable": "Zoomsuggestie-handler niet beschikbaar", "noTelemetry": "Geen cursortelemetrie beschikbaar", "recordFirst": "Neem eerst een screencast op om cursorsuggesties te genereren.", @@ -33,9 +34,40 @@ "addAnnotation": "Annotatie toevoegen (A)" }, "audio": { - "label": "Audio" + "label": "Audio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Snelheid toevoegen (S)", "resizeLeft": "Links verkleinen", - "resizeRight": "Rechts verkleinen" + "resizeRight": "Rechts verkleinen", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 97a2ea1d9..abdd0806b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -4,7 +4,8 @@ "editorTitle": "Editor do Recordly", "subtitle": "Gravação e edição de tela", "language": "Idioma", - "manageRecordings": "Abrir pasta de gravações" + "manageRecordings": "Abrir pasta de gravações", + "discord": "Join Discord" }, "actions": { "cancel": "Cancelar", @@ -22,5 +23,18 @@ "invalidFileType": "Tipo de arquivo inválido", "failedToUploadImage": "Falha ao enviar imagem", "fileReadError": "Ocorreu um erro ao ler o arquivo." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json index f5f772063..47eed4285 100644 --- a/src/i18n/locales/pt-BR/dialogs.json +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Fonte \"{{name}}\" adicionada com sucesso", "addFailed": "Falha ao adicionar fonte", "loadTimeout": "A fonte demorou muito para carregar. Verifique a URL e tente novamente.", - "loadFailed": "A fonte não pode ser carregada. Verifique se a URL do Google Fonts está correta." + "loadFailed": "A fonte não pode ser carregada. Verifique se a URL do Google Fonts está correta.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Atalhos de teclado", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 1a3766315..a02ac0df0 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Envie um arquivo de imagem JPG, PNG, GIF ou WebP.", "blurStrength": "Intensidade do blur: {{strength}}", "solidColor": "Cor sólida (censura)", - "borderRadius": "Raio da borda" + "borderRadius": "Raio da borda", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Classic", "editor": "Editor", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Alterações não salvas", "unsavedChangesDescription": "Salvar o projeto atual antes de {{action}}?", "discardChanges": "Descartar alterações", - "saveProject": "Salvar projeto" + "saveProject": "Salvar projeto", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Dica: Desative os zooms aplicados automaticamente nas configurações", "experimentalBuilds": "Dica: Ative o acesso a versões experimentais nas configurações", "cursorAppearance": "Dica: Você pode personalizar a aparência do cursor" }, - "account": { "title": "Conta", "comingSoon": "Conta em breve" }, + "account": { + "title": "Conta", + "comingSoon": "Conta em breve" + }, "nativeCaptureUnavailable": { "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", "description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.", @@ -136,7 +171,37 @@ "completePercent": "{{percent}}% concluído", "issue": "Problema na exportação", "complete": "Exportação concluída", - "savedSuccessfully": "Seu arquivo foi salvo com sucesso." + "savedSuccessfully": "Seu arquivo foi salvo com sucesso.", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Processando áudio com edições de velocidade/sobreposição" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Expandir linha do tempo", - "collapse": "Recolher linha do tempo" + "collapse": "Recolher linha do tempo", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Abrir pasta de gravações", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Abrir pasta de gravações" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index cf2c7ef69..b95be274f 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Desativar microfone", "enableMicrophone": "Ativar microfone", "micToggleDisabledTip": "O microfone não pode ser alternado durante a gravação", + "systemAudioToggleDisabledTip": "O áudio do sistema não pode ser alternado durante a gravação", "disableWebcam": "Desativar sobreposição da webcam", "enableWebcam": "Ativar sobreposição da webcam", "countdownDelay": "Atraso da contagem regressiva", @@ -23,6 +24,9 @@ "windows": "Janelas", "screen": "Tela", "window": "Janela", + "folder": "Pasta", + "display": "Monitor {{index}}", + "primaryDisplay": "Monitor {{index}} (principal)", "noSourcesFound": "Nenhuma fonte encontrada", "microphone": "Microfone", "systemAudio": "Áudio do sistema", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} está atualizado.", "availableTitle": "Recordly {{version}} está disponível.", "availableGenericTitle": "Há uma atualização disponível." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Carregando fontes...", @@ -94,6 +102,7 @@ "notNow": "Agora não", "updateNow": "Atualizar agora", "restartToUpdate": "Reiniciar para atualizar", - "tryAgain": "Tentar novamente" + "tryAgain": "Tentar novamente", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 1cb1b72ee..58ebd244b 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Auto", "modeManual": "Manual", "modeManualDescription": "Defina um ponto de foco fixo para este zoom", - "modeAutoDescription": "A câmera segue o cursor automaticamente" + "modeAutoDescription": "A câmera segue o cursor automaticamente", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Excluir região de corte" @@ -15,7 +16,8 @@ "playbackSpeed": "Velocidade de reprodução", "selectRegion": "Selecione uma região de velocidade para ajustar", "deleteRegion": "Excluir região de velocidade", - "label": "Velocidade" + "label": "Velocidade", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Clipe", @@ -150,7 +152,37 @@ "paddingBottom": "Inferior", "paddingLeft": "Esquerdo", "paddingRight": "Direito", - "removeBackground": "Remover fundo" + "removeBackground": "Remover fundo", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Cena", @@ -160,7 +192,9 @@ "cursor": "Cursor", "webcam": "Webcam", "frame": "Quadro", - "crop": "Corte" + "crop": "Corte", + "settings": "Configurações", + "extensions": "Extensões" }, "captions": { "selectOnTimeline": "Selecione uma legenda na linha do tempo para editá-la.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Enviar personalizado", "uploadSuccess": "Imagem personalizada enviada com sucesso!", "uploadError": "Envie um arquivo de imagem JPG ou JPEG.", - "uploadErrorDescription": "Somente imagens JPG e JPEG são suportadas." + "uploadErrorDescription": "Somente imagens JPG e JPEG são suportadas.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Exportar", @@ -239,7 +294,24 @@ "saveProject": "Salvar projeto", "exportVideo": "Exportar {{format}}", "reportBug": "Reportar bug", - "starOnGithub": "Dar estrela no GitHub" + "starOnGithub": "Dar estrela no GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Áudio", @@ -257,5 +329,8 @@ "title": "Atualizações", "experimental": "Atualizações experimentais", "saveFailed": "Falha ao alterar o canal de atualização." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/pt-BR/shortcuts.json b/src/i18n/locales/pt-BR/shortcuts.json index cf9d42b25..e161ab44a 100644 --- a/src/i18n/locales/pt-BR/shortcuts.json +++ b/src/i18n/locales/pt-BR/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Adicionar zoom", + "splitClip": "Dividir clipe", "addTrim": "Adicionar corte", "addSpeed": "Adicionar velocidade", "addAnnotation": "Adicionar anotação", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index 7bd64162f..98d82d881 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Não é possível inserir zoom aqui", "existsOrNoSpace": "Já existe zoom nesse local ou não há espaço suficiente.", + "suggestUnavailable": "As sugestões de zoom não estão disponíveis enquanto o loop do cursor estiver ativado.", "suggestHandlerUnavailable": "Manipulador de sugestão de zoom indisponível", "noTelemetry": "Nenhuma telemetria de cursor disponível", "recordFirst": "Grave uma captura de tela primeiro para gerar sugestões baseadas no cursor.", @@ -33,9 +34,40 @@ "addAnnotation": "Adicionar anotação (A)" }, "audio": { - "label": "Áudio" + "label": "Áudio", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Adicionar velocidade (S)", "resizeLeft": "Redimensionar para a esquerda", - "resizeRight": "Redimensionar para a direita" + "resizeRight": "Redimensionar para a direita", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 8ea45bfbd..1d0376c44 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly Editor", "subtitle": "Запись экрана и редактирование видео", "language": "Язык", - "manageRecordings": "Открыть папку с записями" + "manageRecordings": "Открыть папку с записями", + "discord": "Join Discord" }, "actions": { "cancel": "Отмена", @@ -22,5 +23,18 @@ "invalidFileType": "Неверный формат файла", "failedToUploadImage": "Не удалось загрузить изображение", "fileReadError": "Произошла ошибка при чтении файла." - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index bb5c7512a..9aea5deaf 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "Шрифт \"{{name}}\" успешно добавлен", "addFailed": "Не удалось добавить шрифт", "loadTimeout": "Шрифт загружается слишком долго. Проверьте ссылку и попробуйте снова.", - "loadFailed": "Не удалось загрузить шрифт. Проверьте ссылку на Google Fonts." + "loadFailed": "Не удалось загрузить шрифт. Проверьте ссылку на Google Fonts.", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "Сочетания клавиш", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 56c62e1c7..df7289491 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", "blurStrength": "Сила размытия: {{strength}}", "solidColor": "Сплошной цвет (цензура)", - "borderRadius": "Скругление углов" + "borderRadius": "Скругление углов", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "Классический", "editor": "Базовый", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "Несохранённые изменения", "unsavedChangesDescription": "Сохранить текущий проект перед действием «{{action}}»?", "discardChanges": "Отменить изменения", - "saveProject": "Сохранить проект" + "saveProject": "Сохранить проект", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "Совет: Автоматические приближения можно отключить в настройках", "experimentalBuilds": "Совет: Включите доступ к экспериментальным сборкам в настройках", "cursorAppearance": "Совет: Внешний вид курсора можно настроить" }, - "account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" }, + "account": { + "title": "Учётная запись", + "comingSoon": "Учётная запись скоро появится" + }, "nativeCaptureUnavailable": { "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", "description": "Устройство не поддерживает нативный захват изображения. Запись продолжится, но без сглаживания курсора.", @@ -136,7 +171,37 @@ "completePercent": "Готово: {{percent}}%", "issue": "Ошибка экспорта", "complete": "Экспорт завершён", - "savedSuccessfully": "Файл сохранён" + "savedSuccessfully": "Файл сохранён", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "Обработка звука (скорость, наложения)" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "Развернуть таймлайн", - "collapse": "Свернуть таймлайн" + "collapse": "Свернуть таймлайн", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "Открыть папку с записями", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "Открыть папку с записями" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index f430c761d..6f8b41c08 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "Отключить микрофон", "enableMicrophone": "Включить микрофон", "micToggleDisabledTip": "Нельзя переключать микрофон во время записи", + "systemAudioToggleDisabledTip": "Нельзя переключать системный звук во время записи", "disableWebcam": "Отключить наложение веб-камеры", "enableWebcam": "Включить наложение веб-камеры", "countdownDelay": "Обратный отсчёт", @@ -23,6 +24,9 @@ "windows": "Окна", "screen": "Экран", "window": "Окно", + "folder": "Папка", + "display": "Дисплей {{index}}", + "primaryDisplay": "Дисплей {{index}} (основной)", "noSourcesFound": "Источники изображения не найдены", "microphone": "Микрофон", "systemAudio": "Системный звук", @@ -57,7 +61,11 @@ "upToDateTitle": "У вас актуальная версия Recordly {{version}}.", "availableTitle": "Доступна новая версия Recordly {{version}}.", "availableGenericTitle": "Доступно обновление." - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "Загрузка источников...", @@ -94,6 +102,7 @@ "notNow": "Не сейчас", "updateNow": "Обновить сейчас", "restartToUpdate": "Перезапустить для обновления", - "tryAgain": "Повторить" + "tryAgain": "Повторить", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 7ec21ff1c..9aa5dca94 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -6,7 +6,8 @@ "modeAuto": "Автоматически", "modeManual": "Вручную", "modeManualDescription": "Установить фиксированную точку фокусировки", - "modeAutoDescription": "Камера выравнивается по центру, когда курсор приближается к краю увеличенного изображения" + "modeAutoDescription": "Камера выравнивается по центру, когда курсор приближается к краю увеличенного изображения", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "Удалить" @@ -15,7 +16,8 @@ "playbackSpeed": "Скорость воспроизведения", "selectRegion": "Диапазон скорости", "deleteRegion": "Удалить", - "label": "Скорость" + "label": "Скорость", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "Клип", @@ -150,7 +152,37 @@ "paddingBottom": "Снизу", "paddingLeft": "Слева", "paddingRight": "Справа", - "removeBackground": "Удалить фон" + "removeBackground": "Удалить фон", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "Сцена", @@ -160,7 +192,9 @@ "cursor": "Курсор", "webcam": "Веб-камера", "frame": "Рамка", - "crop": "Обрезать видео" + "crop": "Обрезать видео", + "settings": "Настройки", + "extensions": "Расширения" }, "captions": { "selectOnTimeline": "Выберите субтитры на таймлайне, чтобы отредактировать их.", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "Загрузить", "uploadSuccess": "Изображение загружено.", "uploadError": "Загрузите JPG или JPEG.", - "uploadErrorDescription": "Поддерживаются только JPG и JPEG." + "uploadErrorDescription": "Поддерживаются только JPG и JPEG.", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "Экспорт", @@ -239,7 +294,24 @@ "saveProject": "Сохранить проект", "exportVideo": "Экспортировать {{format}}", "reportBug": "Сообщить об ошибке", - "starOnGithub": "Оценить на GitHub" + "starOnGithub": "Оценить на GitHub", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "Аудио", @@ -257,5 +329,8 @@ "experimental": "Экспериментальные обновления", "saveFailed": "Не удалось изменить канал обновлений.", "experimentalDescription": "Вы включили экспериментальные обновления, поэтому можете протестировать последнее обновление Recordly до его широкого выпуска." + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index 3e54412bc..2fc0c0fb5 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "Добавить зум", + "splitClip": "Разделить клип", "addTrim": "Обрезать", "addSpeed": "Увеличить скорость", "addAnnotation": "Добавить аннотацию", diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 5b89d4667..f1273e29d 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "Нельзя добавить зум здесь", "existsOrNoSpace": "Зум уже есть или недостаточно места для его добавления.", + "suggestUnavailable": "Подсказки зума недоступны при включённом цикле курсора.", "suggestHandlerUnavailable": "Подсказки недоступны", "noTelemetry": "Нет данных о курсоре", "recordFirst": "Запишите видео для генерации подсказок на основе курсора.", @@ -33,9 +34,40 @@ "addAnnotation": "Добавить аннотацию (A)" }, "audio": { - "label": "Аудио" + "label": "Аудио", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "Скорость (S)", "resizeLeft": "Изменить размер слева", - "resizeRight": "Изменить размер справа" + "resizeRight": "Изменить размер справа", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index b35ca3d8c..4974a0d0c 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly 编辑器", "subtitle": "屏幕录制与编辑", "language": "语言", - "manageRecordings": "打开录制文件夹" + "manageRecordings": "打开录制文件夹", + "discord": "加入 Discord" }, "actions": { "cancel": "取消", @@ -22,5 +23,18 @@ "invalidFileType": "无效的文件类型", "failedToUploadImage": "上传图片失败", "fileReadError": "读取文件时出错。" - } + }, + "loading": "正在刷新...", + "light": "浅色", + "dark": "深色", + "system": "跟随系统", + "announcements": { + "carousel": "公告", + "dismiss": "关闭", + "next": "下一条公告", + "openFailed": "打开链接失败。", + "previous": "上一条公告", + "show": "显示公告" + }, + "close": "关闭" } diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 9642d2e96..487ade87b 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -24,7 +24,7 @@ "addFont": { "title": "添加 Google 字体", "heading": "添加 Google 字体", - "description": "从 Google Fonts 添加自定义字体用于注释。", + "description": "从 Google Fonts 添加自定义字体用于标注。", "urlLabel": "Google Fonts 导入 URL", "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", "urlHelp": "从 Google Fonts 获取:选择字体 → 点击「获取字体」→ 复制 @import URL", @@ -40,7 +40,9 @@ "addSuccess": "字体 \"{{name}}\" 添加成功", "addFailed": "添加字体失败", "loadTimeout": "字体加载超时。请检查 URL 后重试。", - "loadFailed": "无法加载字体。请确认 Google Fonts URL 是否正确。" + "loadFailed": "无法加载字体。请确认 Google Fonts URL 是否正确。", + "alreadyAdded": "此字体已添加。", + "cancel": "取消" }, "shortcutsConfig": { "title": "键盘快捷键", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index bb799ed11..843cf7b1a 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -8,14 +8,14 @@ "volume": "预览音量" }, "annotations": { - "settings": "注释设置", + "settings": "标注设置", "active": "活动", "text": "文本", "image": "图片", "arrow": "箭头", "blur": "模糊", "textContent": "文本内容", - "textPlaceholder": "输入文本...", + "textPlaceholder": "输入文本", "fontStyle": "字体样式", "selectStyle": "选择样式", "size": "大小", @@ -34,24 +34,34 @@ "arrowDirection": "箭头方向", "strokeWidth": "描边宽度:{{width}}px", "arrowColor": "箭头颜色", - "deleteAnnotation": "删除注释", + "deleteAnnotation": "删除标注", "shortcutsAndTips": "快捷键与提示", - "tipSelectAnnotation": "将播放头移动到注释重叠区域并选择项目。", - "tipCycleForward": "使用 Tab 循环切换重叠项目。", - "tipCycleBackward": "使用 Shift+Tab 反向循环切换。", + "tipSelectAnnotation": "将播放头移动到标注重叠区域并选择项目。", + "tipCycleForward": "按 Tab 选择下一个重叠标注。", + "tipCycleBackward": "按 Shift+Tab 选择上一个重叠标注。", "imageUploadSuccess": "图片上传成功!", "imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。", "blurStrength": "模糊强度: {{strength}}", - "solidColor": "纯色 (审查)", - "borderRadius": "边框半径" + "solidColor": "纯色 (遮挡)", + "borderRadius": "边框半径", + "arrowDirectionOption": "箭头方向:{{direction}}", + "imageUploadErrorDescription": "仅支持 JPG、PNG、GIF 或 WebP 图片。", + "imageUploadFailed": "图片上传失败", + "imageUploadFailedDescription": "读取图片文件时出错。", + "customFonts": "自定义字体", + "uploadedImageAlt": "已上传的标注", + "noImage": "无图片", + "noArrowData": "无箭头数据", + "colorBlack": "黑色", + "colorWhite": "白色", + "colorCustom": "自定义颜色" }, - "fontStyles": { "classic": "经典", "editor": "编辑器", - "strong": "加粗", + "strong": "粗体", "typewriter": "打字机", - "deco": "装饰", + "deco": "艺术装饰", "simple": "简约", "modern": "现代", "clean": "清爽" @@ -66,23 +76,23 @@ "frameRate": "帧率", "outputSize": "输出尺寸", "outputDimensions": "输出:{{width}} × {{height}}px", - "loopAnimation": "循环动画", - "loopDescription": "GIF 将持续播放" + "loopAnimation": "循环播放", + "loopDescription": "GIF 将无缝循环播放" }, "tutorial": { - "howTrimmingWorks": "修剪的工作方式", - "title": "修剪的工作方式", - "understanding": "了解如何裁剪视频中不需要的部分。", - "descriptionP1": "修剪工具通过定义要", + "howTrimmingWorks": "分割的工作方式", + "title": "分割的工作方式", + "understanding": "了解如何删除视频中不需要的部分。", + "descriptionP1": "分割工具通过定义要", "descriptionRemove": "移除", "descriptionP2": "的片段来工作。", - "descriptionP3": "时间线上被红色修剪区域覆盖的部分将在导出时被剪掉。", - "visualExample": "视觉示例", + "descriptionP3": "时间线上被红色分割区域覆盖的部分将在导出时被剪掉。", + "visualExample": "效果示意", "removed": "已移除", "kept": "保留", "finalVideo": "最终视频", "part": "第 {{number}} 部分", - "addTrimStep": "1. 添加修剪", + "addTrimStep": "1. 添加分割", "addTrimDesc": "按 T 或点击剪刀图标标记要移除的部分。", "adjustStep": "2. 调整", "adjustDesc": "拖动红色区域的边缘,精确覆盖要剪掉的部分。" @@ -90,10 +100,10 @@ "feedback": { "trigger": "反馈", "title": "反馈与联系", - "description": "如果有问题或缺失功能,可以直接联系或提交 issue。", + "description": "如果有问题或缺失功能,欢迎直接联系我们或提交 Issue。", "emailLabel": "邮箱", "xLabel": "X", - "reportIssue": "报告问题 / 提交反馈", + "reportIssue": "反馈问题 / 提交反馈", "openFailed": "打开链接失败。" }, "keyboardShortcuts": { @@ -104,29 +114,54 @@ "customize": "自定义", "panTimeline": "平移时间线", "zoomTimeline": "缩放时间线", - "cycleAnnotations": "循环切换注释", + "cycleAnnotations": "切换重叠标注", "tab": "Tab" }, "actions": { - "saveAgain": "再次保存", + "saveAgain": "重新保存", "showInFolder": "在文件夹中显示" }, "project": { "untitled": "未命名", "unsavedChangesTitle": "未保存的更改", - "unsavedChangesDescription": "在{{action}}之前保存当前项目吗?", + "unsavedChangesDescription": "在{{action}}之前,是否保存当前项目?", "discardChanges": "放弃更改", - "saveProject": "保存项目" + "saveProject": "保存项目", + "projects": "打开项目", + "renameInput": "项目名称", + "renameTitle": "重命名项目", + "saveDescription": "为项目命名。项目将保存到 Recordly 项目文件夹。", + "saveNameLabel": "项目名称", + "saveTitle": "保存项目", + "saving": "正在保存...", + "noVideoLoaded": "未加载视频", + "saveCanceled": "已取消保存项目", + "saveFailed": "项目保存失败", + "savedTo": "项目已保存到 {{path}}", + "saved": "项目已保存", + "nameRequired": "必须填写项目名称", + "loadFailed": "项目加载失败", + "invalidFormat": "项目文件格式无效", + "loadedFrom": "项目已从 {{path}} 加载", + "loaded": "项目已加载", + "importFailed": "文件导入失败", + "noMediaSelected": "未选择媒体文件", + "mediaImported": "媒体已导入", + "openAnotherProject": "打开其他项目", + "importFile": "导入文件" }, "exportTips": { "autoZooms": "提示:可在设置中关闭自动应用的缩放", - "experimentalBuilds": "提示:可在设置中开启实验版本访问权限", + "experimentalBuilds": "提示:可在设置中开启测试版访问权限", "cursorAppearance": "提示:你可以自定义光标外观" }, - "account": { "title": "账户", "comingSoon": "账户功能即将推出" }, + "account": { + "title": "账户", + "comingSoon": "账户功能即将推出" + }, "nativeCaptureUnavailable": { - "title": "没有出错,但我们无法渲染动画光标叠加层。", - "description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。", + "title": "功能运行正常,但目前无法渲染平滑光标图层。", + "description": "您的设备不支持原生捕获,这可能受尚未确定的系统环境限制影响。此问题不会影响 Recordly 的基本功能,但光标平滑效果将无法生效。", "confirm": "好的" }, "exportStatus": { @@ -136,18 +171,107 @@ "completePercent": "已完成 {{percent}}%", "issue": "导出问题", "complete": "导出完成", - "savedSuccessfully": "文件已成功保存。" + "savedSuccessfully": "文件已成功保存。", + "copyError": "复制错误信息", + "errorCopied": "已复制错误信息", + "errorCopyFailed": "复制失败。请手动选择错误文本并复制。", + "finalizingPercent": "正在完成 {{percent}}%", + "muxingAndSaving": "正在合并音频并保存文件...", + "muxingAndSavingPercent": "正在合并并保存 {{percent}}%", + "renderSpeed": "渲染速度 {{fps}} FPS", + "renderingAudio": "正在渲染音频 {{percent}}%", + "saving": "正在打开保存对话框...", + "legacySlow": "导出速度过慢?可取消并尝试极速导出!", + "path": "路径:{{path}}", + "done": "完成", + "noVideoLoaded": "未加载视频", + "videoNotReady": "视频未就绪", + "metadataLoading": "视频元数据仍在加载", + "exportCanceled": "导出已取消", + "saveDialogCanceled": "保存已取消。点击“再次保存”即可直接保存,无需重新渲染。", + "saveCanceledTryAgain": "保存已取消。你可以重试。", + "saveCanceledWithoutReexport": "保存已取消。你可以直接再次保存,无需重新导出。", + "noPendingExport": "没有待保存的导出任务", + "failedToSaveVideo": "视频保存失败", + "failedToSaveGif": "GIF 保存失败", + "gifExportFailed": "GIF 导出失败", + "exportFailed": "导出失败", + "successToPath": "已成功导出到 {{path}}", + "showInFolder": "在文件夹中显示", + "revealFailed": "无法在文件夹中定位该文件。", + "revealError": "定位文件时发生错误:{{error}}", + "unknownError": "未知错误", + "exportFailedWithDetail": "导出失败:{{error}}" }, "export": { - "processingAudioEdits": "正在处理带有速度/叠加编辑的音频" + "processingAudioEdits": "正在处理包含变速/重叠剪辑的音频" }, "toolbar": { "addLayer": "添加图层", - "splitClip": "拆分片段 (C)" + "splitClip": "分割片段 (C)" }, "timeline": { "expand": "展开时间轴", - "collapse": "折叠时间轴" + "collapse": "折叠时间轴", + "speedClipOverlap": "变速区域会与下一个片段重叠。在减速前,请先移动或分割片段。", + "speedZoomOverlap": "变速操作将会与其他缩放区域重叠。请先移动或删除重叠的缩放。", + "unsupportedSpeed": "此设备不支持对此速度进行预览。" + }, + "openRecordingsFolder": "打开录制文件夹", + "extensions": { + "title": "扩展", + "unavailableTitle": "扩展功能已不可用", + "unavailableDescription": "扩展安装和市场访问已禁用。此区域仅作为现有项目和导航的占位内容。" + }, + "projectBrowser": { + "title": "项目", + "import": "导入", + "noPreview": "暂无预览", + "current": "当前项目", + "empty": "暂无已保存的项目" + }, + "loadingVideo": "正在加载视频...", + "openProjects": "打开项目", + "presets": { + "open": "打开预设", + "label": "预设", + "saveCurrentAs": "将当前设置保存为预设", + "namePlaceholder": "预设名称", + "savedList": "已保存的预设", + "empty": "暂无预设。", + "deleteAriaLabel": "删除预设 {{name}}", + "toasts": { + "applied": "已应用预设“{{name}}”", + "saved": "已保存预设“{{name}}”", + "deleted": "已删除预设“{{name}}”" + }, + "errors": { + "nameRequired": "请输入预设名称。", + "duplicateName": "已存在同名预设。", + "saveFailed": "无法保存该预设。请检查浏览器存储设置后重试。", + "deleteFailed": "无法删除该预设。请检查浏览器存储设置后重试。" + } + }, + "theme": { + "appearance": "外观", + "light": "浅色", + "dark": "深色", + "system": "跟随系统" + }, + "audio": { + "fallbackUnavailable": "无法加载备用音频源", + "fallbackPlaybackHint": "播放和导出可能会缺失麦克风声音。", + "fallbackLoadError": "无法加载备用音频来源" }, - "openRecordingsFolder": "打开录制文件夹" + "captions": { + "whisperExecutableSelected": "已选择 Whisper 可执行文件", + "downloadModelFailed": "下载 Whisper small 模型失败", + "whisperModelSelected": "已选择 Whisper 模型", + "deleteModelFailed": "删除 Whisper small 模型失败", + "whisperSmallModelDeleted": "已删除 Whisper small 模型", + "noSourceVideo": "未加载源视频", + "selectModel": "请选择 Whisper 模型,或先下载 small 模型", + "generateFailed": "生成字幕失败", + "generatedCount": "已生成 {{count}} 条字幕" + } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 1d85c91e9..1840db03d 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -4,45 +4,49 @@ "enableSystemAudio": "启用系统音频", "disableMicrophone": "禁用麦克风", "enableMicrophone": "启用麦克风", - "micToggleDisabledTip": "录制过程中无法切换麦克风", - "disableWebcam": "禁用摄像头叠加", - "enableWebcam": "启用摄像头叠加", - "countdownDelay": "倒计时延迟", - "noDelay": "无延迟", + "micToggleDisabledTip": "录制期间无法切换麦克风", + "systemAudioToggleDisabledTip": "录制期间无法切换系统音频", + "disableWebcam": "关闭摄像头画面", + "enableWebcam": "开启摄像头画面", + "countdownDelay": "倒计时", + "noDelay": "无倒计时", "record": "录制", "recordingFolder": "录制文件夹:{{path}}", "chooseRecordingsFolder": "选择录制文件夹", "folderPath": "路径:/{{name}}/", "openVideoFile": "打开视频文件", "openProject": "打开项目", - "hideHudFromVideo": "从录制中隐藏 HUD", - "showHudInVideo": "在录制中显示 HUD", + "hideHudFromVideo": "录制时隐藏 HUD", + "showHudInVideo": "录制时显示 HUD", "hideHud": "隐藏 HUD", "closeApp": "关闭应用", "screens": "屏幕", "windows": "窗口", "screen": "屏幕", "window": "窗口", - "noSourcesFound": "未找到源", + "folder": "文件夹", + "display": "显示器 {{index}}", + "primaryDisplay": "显示器 {{index}}(主显示器)", + "noSourcesFound": "未找到录制源", "microphone": "麦克风", "systemAudio": "系统音频", "turnOffMicrophone": "关闭麦克风", "turnOffSystemAudio": "关闭系统音频", - "selectMicToEnable": "选择一个麦克风以启用", - "selectAudioOutputToEnable": "选择一个系统音频输出设备以启用", + "selectMicToEnable": "选择要启用的麦克风", + "selectAudioOutputToEnable": "选择要启用的系统音频输出设备", "noMicrophonesFound": "未找到麦克风", "noAudioOutputsFound": "未找到系统音频输出设备", "webcam": "摄像头", "turnOffWebcam": "关闭摄像头", - "hideFloatingWebcamPreview": "隐藏悬浮预览", - "showFloatingWebcamPreview": "显示悬浮预览", - "selectWebcamToEnable": "选择一个摄像头以启用", + "hideFloatingWebcamPreview": "隐藏摄像头悬浮预览", + "showFloatingWebcamPreview": "显示摄像头悬浮预览", + "selectWebcamToEnable": "选择要启用的摄像头", "noWebcamsFound": "未找到摄像头", - "recordingsFolder": "录制文件夹", + "recordingsFolder": "选择录制文件夹", "language": "语言", "paused": "已暂停", "rec": "录制中", - "resume": "恢复", + "resume": "继续", "pause": "暂停", "stop": "停止", "cancel": "取消", @@ -55,45 +59,50 @@ "downloadingTitle": "正在下载更新...", "errorTitle": "检查更新失败。点击重试。", "upToDateTitle": "Recordly {{version}} 已是最新版本。", - "availableTitle": "Recordly {{version}} 可更新。", - "availableGenericTitle": "有可用更新。" - } + "availableTitle": "可更新Recordly至{{version}}。", + "availableGenericTitle": "有新版本可供更新。" + }, + "preparing": "正在准备录制", + "preparingSubtitle": "即将打开编辑器", + "previewUpdateUi": "更新界面预览", + "appearance": "外观" }, "sourceSelector": { - "loadingSources": "正在加载源...", + "loadingSources": "正在加载录制源...", "screens": "屏幕", "windows": "窗口", "noScreensAvailable": "没有可用的屏幕", "noWindowsAvailable": "没有可用的窗口", - "windowsNote": "仅可录制可见(非最小化)窗口。", + "windowsNote": "仅可录制可见(未最小化)窗口。", "windowPlaceholder": "窗口", "cancel": "取消", "share": "共享" }, "permissions": { - "screenRecordingNeeded": "Recordly 需要屏幕录制权限才能开始。系统设置已打开。启用后请退出并重新打开 Recordly。", - "screenRecordingMissing": "屏幕录制权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", - "accessibilityNeeded": "Recordly 还需要辅助功能权限以跟踪光标。系统设置已打开。启用后请退出并重新打开 Recordly。", - "accessibilityMissing": "辅助功能权限仍然缺失。系统设置已再次打开。请启用权限,然后退出并重新打开 Recordly。", + "screenRecordingNeeded": "Recordly 需要获取屏幕录制权限才能开始。系统设置已打开,开启权限后请退出并重新打开 Recordly。", + "screenRecordingMissing": "屏幕录制权限仍然缺失。已再次打开系统设置。请启用权限,并在录制前退出并重新打开 Recordly。", + "accessibilityNeeded": "Recordly 还需要辅助功能权限以追踪光标。系统设置已打开。开启权限后请退出并重新打开 Recordly。", + "accessibilityMissing": "辅助功能权限仍然缺失。已再次打开系统设置。请启用权限,并在录制前重新打开 Recordly。", "selectSource": "请选择要录制的源", - "systemAudioUnavailable": "此源不支持系统音频。将继续录制但不包含系统音频。", - "microphoneDenied": "麦克风访问被拒绝。将继续录制但不包含麦克风音频。", - "failedToStart": "录制启动失败:{{error}}", - "failedToStartGeneric": "录制启动失败" + "systemAudioUnavailable": "此录制源不支持系统音频。将不包含系统音频继续录制。", + "microphoneDenied": "麦克风访问被拒绝。将不包含麦克风音继续录制频。", + "failedToStart": "启动录制失败:{{error}}", + "failedToStartGeneric": "启动录制失败" }, "updateToast": { "availableTitle": "有可用更新", - "experimentalAvailableTitle": "有可用的实验性更新", - "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "experimentalAvailableTitle": "有测试版更新可用", + "experimentalDescription": "已开启测试版更新,可在 Recordly 正式版公开发布前优先试用最新版本", "downloadingTitle": "正在下载更新", "readyTitle": "已准备好重新启动", "checkErrorTitle": "无法检查更新", "downloadErrorTitle": "无法下载更新", - "experimentalBadge": "实验性", - "previewBadge": "预览", - "notNow": "暂不", + "experimentalBadge": "测试版", + "previewBadge": "预览版", + "notNow": "暂不更新", "updateNow": "立即更新", - "restartToUpdate": "重新启动以更新", - "tryAgain": "重试" + "restartToUpdate": "重启并更新", + "tryAgain": "重试", + "ariaLabel": "Recordly 更新通知" } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 053d10951..39d9676a4 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -6,16 +6,18 @@ "modeAuto": "自动", "modeManual": "手动", "modeManualDescription": "为此缩放设置固定焦点", - "modeAutoDescription": "镜头会自动跟随光标" + "modeAutoDescription": "镜头会自动跟随光标", + "globalSettings": "动画" }, "trim": { - "deleteRegion": "删除修剪区域" + "deleteRegion": "删除分割区域" }, "speed": { "playbackSpeed": "播放速度", "selectRegion": "选择变速区域以调整", "deleteRegion": "删除变速区域", - "label": "速度" + "label": "速度", + "unsupported": "此设备不支持预览" }, "clip": { "title": "片段", @@ -37,7 +39,7 @@ "tahoe-inverted": "Tahoe 反相", "windows11": "Windows 11", "dot": "圆点", - "figma": "Minimal", + "figma": "极简", "lavender": "Lavender", "parched": "Parched", "chooper": "Chooper", @@ -46,7 +48,7 @@ }, "backgroundBlur": "模糊", "zoomMotionBlur": "缩放运动模糊", - "auto": "Auto", + "auto": "自动", "connectZooms": "连接缩放", "connectZoomsDescription": "将连续的缩放区域平滑连接为一次连续的镜头移动。", "autoApplyFreshRecordingZooms": "自动为新录制应用缩放", @@ -90,9 +92,9 @@ }, "cursorSize": "光标大小", "cursorSmoothing": "光标平滑", - "cursorSpringStiffness": "光标弹簧刚度", - "cursorSpringDamping": "光标弹簧阻尼", - "cursorSpringMass": "光标弹簧质量", + "cursorSpringStiffness": "光标弹性系数 ", + "cursorSpringDamping": "光标弹性阻尼", + "cursorSpringMass": "光标弹性质量", "off": "关", "cursorClickEffects": { "title": "Click Effects", @@ -104,20 +106,20 @@ "opacity": "Effect Opacity", "duration": "Effect Duration", "none": { - "label": "Off", - "description": "No click graphic. Only the cursor motion changes when you click." + "label": "关闭", + "description": "不显示点击图形,仅在点击时改变光标运动。" }, "ripple": { - "label": "Ripple", - "description": "Expanding rings radiate from each click so taps read clearly in motion." + "label": "波纹", + "description": "从每次点击处扩散圆环,让点击动作在视频中更清晰。" }, "spotlight": { - "label": "Spotlight", - "description": "A soft halo flashes around the pointer to emphasize the clicked area." + "label": "聚光灯", + "description": "指针周围短暂闪现柔和光晕,突出点击区域。" }, "echo": { - "label": "Echo", - "description": "A pair of soft rings that spread outward with a cleaner pulse." + "label": "回声", + "description": "两道柔和圆环向外扩散,形成更干净的点击脉冲。" } }, "cursorMotionBlur": "光标运动模糊", @@ -137,7 +139,7 @@ "webcamHeight": "摄像头高度", "webcamCrop": "摄像头裁剪", "webcamReactToZoom": "摄像头随缩放变化", - "webcamMirror": "镜像摄像头", + "webcamMirror": "摄像头镜像", "webcamRoundness": "摄像头圆角", "webcamShadow": "摄像头阴影", "shadow": "阴影", @@ -150,7 +152,37 @@ "paddingBottom": "下", "paddingLeft": "左", "paddingRight": "右", - "removeBackground": "移除背景" + "removeBackground": "移除背景", + "paddingAdvanced": "高级", + "paddingAdvancedHide": "隐藏高级内边距控制", + "paddingAdvancedShow": "显示高级内边距控制", + "cameraDebugTuning": "镜头调试调节", + "cameraDebugTuningHint": "仅用于开发的镜头运动弹性调节控件。", + "cameraSpringStiffnessMultiplier": "镜头弹性系数", + "cameraSpringDampingMultiplier": "镜头弹性阻尼", + "cameraSpringMassMultiplier": "镜头质量", + "cursorDebugTuning": "光标调试调节", + "cursorDebugTuningHint": "仅用于开发的弹性调节控件。", + "cursorSpringStiffnessMultiplier": "弹性系数", + "cursorSpringDampingMultiplier": "弹性阻尼", + "cursorSpringMassMultiplier": "弹性质量", + "cursorDebugMovedToDev": "光标弹性调节位于“设置 > 开发”。", + "devSection": "开发", + "devSectionHint": "用于原生捕获和运动调节的临时测试控件。", + "nativeCaptureWarningTester": "原生捕获警告", + "nativeCaptureWarningTesterUnavailable": "当前项目标记为不支持原生捕获。", + "nativeCaptureWarningTesterAvailable": "当前项目未标记为不支持,但仍可打开此窗口测试界面。", + "openNativeCaptureWarning": "打开警告", + "classicZoom": "经典动画", + "webcamPosition": "位置", + "webcamCustomPosition": "自定义位置", + "webcamHorizontal": "水平", + "webcamVertical": "垂直", + "webcamMargin": "边距", + "pickColor": "选择", + "devBadge": "开发", + "customColorPicker": "自定义颜色选择器", + "customEffectColorPicker": "自定义效果颜色选择器" }, "sections": { "scene": "场景", @@ -160,12 +192,14 @@ "cursor": "光标", "webcam": "摄像头", "frame": "边框", - "crop": "裁剪" + "crop": "裁剪", + "settings": "设置", + "extensions": "扩展" }, "captions": { "selectOnTimeline": "在时间轴上选择字幕进行编辑。", "enabled": "显示", - "timelineQuickAdd": "悬停以在时间轴上添加", + "timelineQuickAdd": "悬停即可在时间轴添加字幕", "language": "语言", "downloading": "下载中...", "deleteModel": "删除模型", @@ -191,12 +225,27 @@ "backgroundOpacity": "背景透明度", "textColor": "文字颜色", "editor": { - "text": "Text", - "start": "Start", - "end": "End", - "split": "Split", - "merge": "Merge", - "delete": "Delete" + "text": "文本", + "start": "开始", + "end": "结束", + "split": "拆分", + "merge": "合并", + "delete": "删除" + }, + "editSaved": "字幕已更新", + "selectModel": "选择模型", + "generatingStatus": "正在生成字幕,这可能需要一些时间。", + "languages": { + "auto": "自动检测", + "en": "英语", + "es": "西班牙语", + "fr": "法语", + "de": "德语", + "it": "意大利语", + "pt": "葡萄牙语", + "zh": "简体中文", + "ja": "日语", + "ko": "韩语" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "上传自定义", "uploadSuccess": "自定义图片上传成功!", "uploadError": "请上传 JPG 或 JPEG 图片文件。", - "uploadErrorDescription": "仅支持 JPG 和 JPEG 图片。" + "uploadErrorDescription": "仅支持 JPG 和 JPEG 图片。", + "video": "视频", + "uploadCustomVideo": "上传视频", + "unsupportedFormat": "不支持的格式", + "videoFormatDescription": "请选择视频文件(mp4、webm、mov 等)", + "videoAdded": "视频背景已添加", + "videoImportFailed": "视频背景导入失败" }, "export": { "title": "导出", @@ -237,9 +292,26 @@ "sizePresetLargeShort": "大", "loadProject": "加载项目", "saveProject": "保存项目", - "exportVideo": "导出{{format}}", + "exportVideo": "导出视频", "reportBug": "报告问题", - "starOnGithub": "在 GitHub 上点赞" + "starOnGithub": "在 GitHub 上 star", + "encodingTitle": "编码", + "encoding": { + "fast": "快速", + "balanced": "平衡", + "quality": "质量" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "实验性", + "hint": "在此 Windows 设备上尝试 GPU 导出。", + "toggle": "启用实验性 NVIDIA CUDA 导出" + }, + "captionSidecar": { + "title": "导出字幕文件", + "hint": "将 .srt 和 .vtt 文件保存到导出视频旁边。", + "toggle": "导出字幕附加文件" + } }, "audio": { "title": "音频", @@ -253,9 +325,12 @@ "deleteRegion": "删除音频" }, "updates": { - "experimentalDescription": "你已选择接收实验性更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", + "experimentalDescription": "你已选择接收测试版更新,因此可以在 Recordly 最新更新广泛发布之前选择先行测试。", "title": "更新", - "experimental": "实验性更新", + "experimental": "测试版更新", "saveFailed": "无法更改更新频道。" + }, + "annotation": { + "delete": "删除标注" } } diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json index 9e3f599bf..bf44681f0 100644 --- a/src/i18n/locales/zh-CN/shortcuts.json +++ b/src/i18n/locales/zh-CN/shortcuts.json @@ -1,14 +1,15 @@ { "actions": { "addZoom": "添加缩放", - "addTrim": "添加修剪", + "splitClip": "分割片段", + "addTrim": "添加分割", "addSpeed": "添加变速", - "addAnnotation": "添加注释", + "addAnnotation": "添加标注", "addKeyframe": "添加关键帧", "deleteSelected": "删除选中", "playPause": "播放 / 暂停", - "cycleForward": "向前循环切换注释", - "cycleBackward": "向后循环切换注释", + "cycleForward": "下一个重叠标注", + "cycleBackward": "上一个重叠标注", "deleteSelectedAlt": "删除选中(替代)", "panTimeline": "平移时间线", "zoomTimeline": "缩放时间线" diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 85113ae33..c9ee03c54 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -1,26 +1,27 @@ { "zoom": { "cannotPlace": "无法在此处放置缩放", - "existsOrNoSpace": "此位置已存在缩放或空间不足。", + "existsOrNoSpace": "该位置已存在缩放或空间不足。", + "suggestUnavailable": "启用光标循环时无法使用缩放建议。", "suggestHandlerUnavailable": "缩放建议处理不可用", - "noTelemetry": "无光标遥测数据", - "recordFirst": "请先录制屏幕以生成基于光标的建议。", - "noUsableTelemetry": "无可用的光标遥测数据", + "noTelemetry": "无有效光标轨迹测数据", + "recordFirst": "请先录制屏幕,以生成基于光标轨迹的缩放建议。", + "noUsableTelemetry": "无可用的光标轨迹数据", "notEnoughMovement": "录制中未包含足够的光标移动数据。", "noInteractionMoments": "未找到明显的交互时刻", - "tryRecording": "尝试在关键操作附近有停顿或点击的录制。", + "tryRecording": "建议在关键操作附近增加停顿或点击后重新录制。", "noAutoZoomSlots": "没有可用的自动缩放位置", "dwellPointsOverlap": "检测到的停留点与现有缩放区域重叠。", "addedSuggestions": "已添加 {{count}} 个基于交互的缩放建议", "label": "缩放 {{index}}", "addZoom": "添加缩放 (Z)", - "suggestZooms": "从光标建议缩放" + "suggestZooms": "从光标推荐缩放" }, "trim": { - "cannotPlace": "无法在此处放置修剪", - "existsOrNoSpace": "此位置已存在修剪或空间不足。", - "label": "修剪 {{index}}", - "addTrim": "添加修剪 (T)" + "cannotPlace": "无法在此处添加分割", + "existsOrNoSpace": "此位置已存在分割或空间不足。", + "label": "分割 {{index}}", + "addTrim": "添加分割 (T)" }, "speed": { "cannotPlace": "无法在此处放置变速", @@ -28,14 +29,45 @@ "label": "变速" }, "annotation": { - "label": "注释", + "label": "标注", "image": "图片", - "addAnnotation": "添加注释 (A)" + "addAnnotation": "添加标注 (A)" }, "audio": { - "label": "音频" + "label": "音频", + "cannotRead": "无法读取音频文件", + "cannotReadDescription": "所选文件可能已损坏或格式不受支持。", + "cannotPlace": "无法在此处放置音频", + "noRemainingSpace": "当前播放头位置没有剩余空间。", + "occupied": "此位置已有音频区域,或可用空间不足。" + }, + "caption": { + "cannotPlace": "无法在此处放置字幕", + "exists": "此位置已有字幕。" }, "addSpeed": "添加变速 (S)", "resizeLeft": "向左调整", - "resizeRight": "向右调整" + "resizeRight": "向右调整", + "empty": { + "noVideo": "未加载视频", + "dragDrop": "拖放视频以开始编辑" + }, + "toolbar": { + "custom": "自定义", + "aspectWidth": "自定义宽度", + "aspectHeight": "自定义高度", + "aspectRatioNative": "原始比例", + "set": "设置", + "sideScroll": "侧向滚动", + "pan": "平移", + "zoom": "缩放" + }, + "item": { + "trim": "分割", + "clip": "片段", + "speed": "变速", + "manual": "手动", + "auto": "自动", + "loading": "加载中..." + } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index f65398523..7991aabf2 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -4,7 +4,8 @@ "editorTitle": "Recordly 編輯器", "subtitle": "螢幕錄製與編輯", "language": "語言", - "manageRecordings": "打開錄製影像文件夾" + "manageRecordings": "打開錄製影像文件夾", + "discord": "Join Discord" }, "actions": { "cancel": "取消", @@ -22,5 +23,18 @@ "invalidFileType": "無效的檔案類型", "failedToUploadImage": "上傳圖片失敗", "fileReadError": "讀取檔案時出錯。" - } + }, + "loading": "Refreshing...", + "light": "Light", + "dark": "Dark", + "system": "System", + "announcements": { + "carousel": "Announcements", + "dismiss": "Dismiss", + "next": "Next announcement", + "openFailed": "Failed to open link.", + "previous": "Previous announcement", + "show": "Show announcement" + }, + "close": "Close" } diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index 921299dff..68bf4b251 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -40,7 +40,9 @@ "addSuccess": "已成功新增字型「{{name}}」", "addFailed": "新增字型失敗", "loadTimeout": "字型載入時間過久。請檢查 URL 後再試一次。", - "loadFailed": "無法載入字型。請確認 Google Fonts URL 是否正確。" + "loadFailed": "無法載入字型。請確認 Google Fonts URL 是否正確。", + "alreadyAdded": "This font has already been added.", + "cancel": "Cancel" }, "shortcutsConfig": { "title": "鍵盤快捷鍵", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 0332aed16..13e0c4cf0 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -43,9 +43,19 @@ "imageUploadError": "請上傳 JPG、PNG、GIF 或 WebP 圖片檔案。", "blurStrength": "模糊強度: {{strength}}", "solidColor": "純色 (審查)", - "borderRadius": "邊框半徑" + "borderRadius": "邊框半徑", + "arrowDirectionOption": "Arrow direction: {{direction}}", + "imageUploadErrorDescription": "Only JPG, PNG, GIF, or WebP image files are supported.", + "imageUploadFailed": "Image upload failed", + "imageUploadFailedDescription": "There was an error reading the image file.", + "customFonts": "Custom Fonts", + "uploadedImageAlt": "Uploaded annotation", + "noImage": "No image", + "noArrowData": "No arrow data", + "colorBlack": "Black", + "colorWhite": "White", + "colorCustom": "Custom Color" }, - "fontStyles": { "classic": "經典", "editor": "編輯器", @@ -116,14 +126,39 @@ "unsavedChangesTitle": "未儲存的變更", "unsavedChangesDescription": "要在{{action}}之前儲存目前的專案嗎?", "discardChanges": "捨棄變更", - "saveProject": "儲存專案" + "saveProject": "儲存專案", + "projects": "Open projects", + "renameInput": "Project name", + "renameTitle": "Rename project", + "saveDescription": "Name this project. It will be saved in your Recordly Projects folder.", + "saveNameLabel": "Project name", + "saveTitle": "Save Project", + "saving": "Saving...", + "noVideoLoaded": "No video loaded", + "saveCanceled": "Project save canceled", + "saveFailed": "Failed to save project", + "savedTo": "Project saved to {{path}}", + "saved": "Project saved", + "nameRequired": "Project name is required", + "loadFailed": "Failed to load project", + "invalidFormat": "Invalid project file format", + "loadedFrom": "Project loaded from {{path}}", + "loaded": "Project loaded", + "importFailed": "Failed to import file", + "noMediaSelected": "No media file selected", + "mediaImported": "Media imported", + "openAnotherProject": "open another project", + "importFile": "import a file" }, "exportTips": { "autoZooms": "提示:可在設定中關閉自動套用的縮放", "experimentalBuilds": "提示:可在設定中開啟實驗版本存取權", "cursorAppearance": "提示:你可以自訂游標外觀" }, - "account": { "title": "帳號", "comingSoon": "帳號功能即將推出" }, + "account": { + "title": "帳號", + "comingSoon": "帳號功能即將推出" + }, "nativeCaptureUnavailable": { "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", "description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。", @@ -136,7 +171,37 @@ "completePercent": "已完成 {{percent}}%", "issue": "匯出問題", "complete": "匯出完成", - "savedSuccessfully": "檔案已成功保存。" + "savedSuccessfully": "檔案已成功保存。", + "copyError": "Copy error", + "errorCopied": "Error copied", + "errorCopyFailed": "Couldn't copy. Select the error text and copy it manually.", + "finalizingPercent": "Finalizing {{percent}}%", + "muxingAndSaving": "Muxing audio and saving file...", + "muxingAndSavingPercent": "Muxing and saving {{percent}}%", + "renderSpeed": "Render speed {{fps}} FPS", + "renderingAudio": "Rendering audio {{percent}}%", + "saving": "Opening save dialog...", + "legacySlow": "Export too slow? Cancel and try Lightning export!", + "path": "Path: {{path}}", + "done": "Done", + "noVideoLoaded": "No video loaded", + "videoNotReady": "Video not ready", + "metadataLoading": "Video metadata is still loading", + "exportCanceled": "Export canceled", + "saveDialogCanceled": "Save dialog canceled. Click Save Again to save without re-rendering.", + "saveCanceledTryAgain": "Save canceled. You can try again.", + "saveCanceledWithoutReexport": "Save canceled. You can save again without re-exporting.", + "noPendingExport": "No pending export to save", + "failedToSaveVideo": "Failed to save video", + "failedToSaveGif": "Failed to save GIF", + "gifExportFailed": "GIF export failed", + "exportFailed": "Export failed", + "successToPath": "Exported successfully to {{path}}", + "showInFolder": "Show in Folder", + "revealFailed": "Failed to reveal item in folder.", + "revealError": "Error revealing in folder: {{error}}", + "unknownError": "Unknown error", + "exportFailedWithDetail": "Export failed: {{error}}" }, "export": { "processingAudioEdits": "正在處理帶有速度/疊加編輯的音訊" @@ -147,7 +212,66 @@ }, "timeline": { "expand": "展開時間軸", - "collapse": "摺疊時間軸" + "collapse": "摺疊時間軸", + "speedClipOverlap": "Speed change would overlap the next clip. Move or split clips before slowing this section.", + "speedZoomOverlap": "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", + "unsupportedSpeed": "This speed is not supported for preview on this device." + }, + "openRecordingsFolder": "打開錄製資料夾", + "extensions": { + "title": "Extensions", + "unavailableTitle": "Extensions are no longer available", + "unavailableDescription": "Extension installation and marketplace access have been disabled. This area is kept as a placeholder for existing projects and navigation." + }, + "projectBrowser": { + "title": "Projects", + "import": "Import", + "noPreview": "No preview yet", + "current": "Current", + "empty": "No saved projects yet" + }, + "loadingVideo": "Loading video...", + "openProjects": "Open Projects", + "presets": { + "open": "Open presets", + "label": "Presets", + "saveCurrentAs": "Save current preset as", + "namePlaceholder": "Preset name", + "savedList": "Saved presets", + "empty": "No presets yet.", + "deleteAriaLabel": "Delete preset {{name}}", + "toasts": { + "applied": "Applied preset \"{{name}}\"", + "saved": "Saved preset \"{{name}}\"", + "deleted": "Deleted preset \"{{name}}\"" + }, + "errors": { + "nameRequired": "Enter a preset name.", + "duplicateName": "A preset with that name already exists.", + "saveFailed": "Could not save that preset. Check your browser storage settings and try again.", + "deleteFailed": "Could not delete that preset. Check your browser storage settings and try again." + } + }, + "theme": { + "appearance": "Appearance", + "light": "Light", + "dark": "Dark", + "system": "System" + }, + "audio": { + "fallbackUnavailable": "Could not load companion audio sources", + "fallbackPlaybackHint": "Playback and export may miss microphone audio.", + "fallbackLoadError": "Could not load companion audio source" }, - "openRecordingsFolder": "打開錄製資料夾" + "captions": { + "whisperExecutableSelected": "Whisper executable selected", + "downloadModelFailed": "Failed to download Whisper small model", + "whisperModelSelected": "Whisper model selected", + "deleteModelFailed": "Failed to delete Whisper small model", + "whisperSmallModelDeleted": "Whisper small model deleted", + "noSourceVideo": "No source video is loaded", + "selectModel": "Select a Whisper model or download the small model first", + "generateFailed": "Failed to generate captions", + "generatedCount": "Generated {{count}} captions" + } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index aa279e49c..1def1bd6b 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -5,6 +5,7 @@ "disableMicrophone": "停用麥克風", "enableMicrophone": "啟用麥克風", "micToggleDisabledTip": "錄製過程中無法切換麥克風", + "systemAudioToggleDisabledTip": "錄製過程中無法切換系統音訊", "disableWebcam": "停用網路攝影機疊加", "enableWebcam": "啟用網路攝影機疊加", "countdownDelay": "倒數延遲", @@ -23,6 +24,9 @@ "windows": "視窗", "screen": "螢幕", "window": "視窗", + "folder": "資料夾", + "display": "螢幕 {{index}}", + "primaryDisplay": "螢幕 {{index}}(主螢幕)", "noSourcesFound": "找不到可錄製的來源", "microphone": "麥克風", "systemAudio": "系統音訊", @@ -57,7 +61,11 @@ "upToDateTitle": "Recordly {{version}} 已是最新版本。", "availableTitle": "Recordly {{version}} 已推出新版本。", "availableGenericTitle": "有可用更新。" - } + }, + "preparing": "Preparing recording", + "preparingSubtitle": "Opening the editor in a moment", + "previewUpdateUi": "Preview Update UI", + "appearance": "Appearance" }, "sourceSelector": { "loadingSources": "正在載入來源...", @@ -94,6 +102,7 @@ "notNow": "暫不", "updateNow": "立即更新", "restartToUpdate": "重新啟動以更新", - "tryAgain": "重試" + "tryAgain": "重試", + "ariaLabel": "Recordly update" } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 7de885204..c8bd2cdee 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -6,7 +6,8 @@ "modeAuto": "自動", "modeManual": "手動", "modeManualDescription": "為此縮放設定固定的對焦點", - "modeAutoDescription": "相機會自動跟隨游標" + "modeAutoDescription": "相機會自動跟隨游標", + "globalSettings": "Animation" }, "trim": { "deleteRegion": "刪除修剪區域" @@ -15,7 +16,8 @@ "playbackSpeed": "播放速度", "selectRegion": "選取要調整的速度區域", "deleteRegion": "刪除速度區域", - "label": "速度" + "label": "速度", + "unsupported": "Not supported for preview on this device" }, "clip": { "title": "片段", @@ -150,7 +152,37 @@ "paddingBottom": "下", "paddingLeft": "左", "paddingRight": "右", - "removeBackground": "移除背景" + "removeBackground": "移除背景", + "paddingAdvanced": "Advanced", + "paddingAdvancedHide": "Hide advanced padding controls", + "paddingAdvancedShow": "Show advanced padding controls", + "cameraDebugTuning": "Camera Debug Tuning", + "cameraDebugTuningHint": "Development-only spring tuning controls for camera motion.", + "cameraSpringStiffnessMultiplier": "Camera stiffness", + "cameraSpringDampingMultiplier": "Camera damping", + "cameraSpringMassMultiplier": "Camera mass", + "cursorDebugTuning": "Cursor Debug Tuning", + "cursorDebugTuningHint": "Development-only spring tuning controls.", + "cursorSpringStiffnessMultiplier": "Spring stiffness", + "cursorSpringDampingMultiplier": "Spring damping", + "cursorSpringMassMultiplier": "Spring mass", + "cursorDebugMovedToDev": "Cursor spring tuning is available in Settings > Dev.", + "devSection": "Dev", + "devSectionHint": "Temporary testing controls for native capture and motion tuning.", + "nativeCaptureWarningTester": "Native capture warning", + "nativeCaptureWarningTesterUnavailable": "This project is currently marked as native capture unavailable.", + "nativeCaptureWarningTesterAvailable": "This project is not marked as unsupported, but you can still open the modal for UI testing.", + "openNativeCaptureWarning": "Open warning", + "classicZoom": "Classic Animation", + "webcamPosition": "Position", + "webcamCustomPosition": "Custom position", + "webcamHorizontal": "Horizontal", + "webcamVertical": "Vertical", + "webcamMargin": "Margin", + "pickColor": "Pick", + "devBadge": "DEV", + "customColorPicker": "Custom color picker", + "customEffectColorPicker": "Custom effect color picker" }, "sections": { "scene": "場景", @@ -160,7 +192,9 @@ "cursor": "游標", "webcam": "網路攝影機", "frame": "外框", - "crop": "裁切" + "crop": "裁切", + "settings": "設定", + "extensions": "擴充功能" }, "captions": { "selectOnTimeline": "在時間軸上選擇字幕進行編輯。", @@ -197,6 +231,21 @@ "split": "Split", "merge": "Merge", "delete": "Delete" + }, + "editSaved": "Caption updated", + "selectModel": "Select Model", + "generatingStatus": "Generating captions. This can take a moment.", + "languages": { + "auto": "Auto Detect", + "en": "English", + "es": "Spanish", + "fr": "French", + "de": "German", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean" } }, "crop": { @@ -217,7 +266,13 @@ "uploadCustom": "上傳自訂圖片", "uploadSuccess": "自訂圖片已成功上傳!", "uploadError": "請上傳 JPG 或 JPEG 圖片檔。", - "uploadErrorDescription": "僅支援 JPG 與 JPEG 圖片。" + "uploadErrorDescription": "僅支援 JPG 與 JPEG 圖片。", + "video": "Video", + "uploadCustomVideo": "Upload Video", + "unsupportedFormat": "Unsupported format", + "videoFormatDescription": "Please select a video file (mp4, webm, mov, etc.)", + "videoAdded": "Video background added", + "videoImportFailed": "Failed to import video background" }, "export": { "title": "匯出", @@ -239,7 +294,24 @@ "saveProject": "儲存專案", "exportVideo": "匯出 {{format}}", "reportBug": "回報錯誤", - "starOnGithub": "在 GitHub 按讚" + "starOnGithub": "在 GitHub 按讚", + "encodingTitle": "Encoding", + "encoding": { + "fast": "Fast", + "balanced": "Balanced", + "quality": "Quality" + }, + "nvidiaCuda": { + "title": "NVIDIA CUDA", + "badge": "Experimental", + "hint": "Try GPU export on this Windows device.", + "toggle": "Enable experimental NVIDIA CUDA export" + }, + "captionSidecar": { + "title": "Export captions file", + "hint": "Save .srt and .vtt files next to your exported video.", + "toggle": "Export captions sidecar files" + } }, "audio": { "title": "音訊", @@ -257,5 +329,8 @@ "title": "更新", "experimental": "實驗性更新", "saveFailed": "無法變更更新頻道。" + }, + "annotation": { + "delete": "Delete Annotation" } } diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index ea0b8a388..69432b581 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -1,6 +1,7 @@ { "actions": { "addZoom": "新增縮放", + "splitClip": "分割片段", "addTrim": "新增修剪", "addSpeed": "新增速度", "addAnnotation": "新增註解", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 0ddf65de6..2efac487a 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -2,6 +2,7 @@ "zoom": { "cannotPlace": "無法在此處新增縮放", "existsOrNoSpace": "此位置已存在縮放,或可用空間不足。", + "suggestUnavailable": "啟用游標循環時無法使用縮放建議。", "suggestHandlerUnavailable": "無法使用縮放建議功能", "noTelemetry": "沒有可用的游標追蹤資料", "recordFirst": "請先錄製螢幕影片,以產生基於游標的建議。", @@ -33,9 +34,40 @@ "addAnnotation": "新增註解(A)" }, "audio": { - "label": "音訊" + "label": "音訊", + "cannotRead": "Could not read audio file", + "cannotReadDescription": "The selected file may be corrupted or in an unsupported format.", + "cannotPlace": "Cannot place audio here", + "noRemainingSpace": "There is no remaining space at the current playhead position.", + "occupied": "Audio region already exists at this location or not enough space available." + }, + "caption": { + "cannotPlace": "Cannot place caption here", + "exists": "A caption already exists at this position." }, "addSpeed": "新增速度(S)", "resizeLeft": "向左調整大小", - "resizeRight": "向右調整大小" + "resizeRight": "向右調整大小", + "empty": { + "noVideo": "No Video Loaded", + "dragDrop": "Drag and drop a video to start editing" + }, + "toolbar": { + "custom": "Custom", + "aspectWidth": "Custom aspect width", + "aspectHeight": "Custom aspect height", + "aspectRatioNative": "Native", + "set": "Set", + "sideScroll": "Side Scroll", + "pan": "Pan", + "zoom": "Zoom" + }, + "item": { + "trim": "Trim", + "clip": "Clip", + "speed": "Speed", + "manual": "Manual", + "auto": "Auto", + "loading": "Loading..." + } } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d43846930..17e091b96 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -21,29 +21,47 @@ export type ShortcutsConfig = Record; export interface FixedShortcut { label: string; + translationKey: string; display: string; bindings: ShortcutBinding[]; } export const FIXED_SHORTCUTS: FixedShortcut[] = [ - { label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] }, + { + label: "Cycle Annotations Forward", + translationKey: "cycleForward", + display: "Tab", + bindings: [{ key: "tab" }], + }, { label: "Cycle Annotations Backward", + translationKey: "cycleBackward", display: "Shift + Tab", bindings: [{ key: "tab", shift: true }], }, { label: "Delete Selected (alt)", + translationKey: "deleteSelectedAlt", display: "Del / ⌫", bindings: [{ key: "delete" }, { key: "backspace" }], }, - { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, - { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, + { + label: "Pan Timeline", + translationKey: "panTimeline", + display: "Shift + Scroll", + bindings: [], + }, + { + label: "Zoom Timeline", + translationKey: "zoomTimeline", + display: "Ctrl + Scroll", + bindings: [], + }, ]; export type ShortcutConflict = | { type: "configurable"; action: ShortcutAction } - | { type: "fixed"; label: string }; + | { type: "fixed"; label: string; translationKey: string }; export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { return ( @@ -61,7 +79,7 @@ export function findConflict( ): ShortcutConflict | null { for (const fixed of FIXED_SHORTCUTS) { if (fixed.bindings.some((b) => bindingsEqual(b, binding))) { - return { type: "fixed", label: fixed.label }; + return { type: "fixed", label: fixed.label, translationKey: fixed.translationKey }; } } for (const action of SHORTCUT_ACTIONS) { @@ -90,6 +108,15 @@ export const SHORTCUT_LABELS: Record = { playPause: "Play / Pause", }; +export const SHORTCUT_LABEL_KEYS: Record = { + addZoom: "addZoom", + splitClip: "splitClip", + addAnnotation: "addAnnotation", + addKeyframe: "addKeyframe", + deleteSelected: "deleteSelected", + playPause: "playPause", +}; + export function matchesShortcut( e: KeyboardEvent, binding: ShortcutBinding, From 15de62b983b84a7a8e04e3945e5cc413c7ee8046 Mon Sep 17 00:00:00 2001 From: OrangeChange <95136820+OrangeChange@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:19:48 +0800 Subject: [PATCH 4/4] fix(audio): harden system audio level monitoring --- electron/ipc/audioOutputMonitor.test.ts | 32 ++++++- electron/ipc/audioOutputMonitor.ts | 89 +++++++++++------- .../bin/win32-x64/helpers-manifest.json | 66 ++++++------- electron/native/bin/win32-x64/wgc-capture.exe | Bin 121856 -> 138752 bytes .../wgc-capture/src/audio_level_monitor.cpp | 22 ++++- src/hooks/audioOutputDevices.test.ts | 8 ++ src/hooks/audioOutputDevices.ts | 14 ++- 7 files changed, 159 insertions(+), 72 deletions(-) diff --git a/electron/ipc/audioOutputMonitor.test.ts b/electron/ipc/audioOutputMonitor.test.ts index a1435b6e7..ac8c18c53 100644 --- a/electron/ipc/audioOutputMonitor.test.ts +++ b/electron/ipc/audioOutputMonitor.test.ts @@ -52,6 +52,7 @@ describe("audio output level monitor lifecycle", () => { it("starts one helper, broadcasts events, and writes stop only once", async () => { const process = new FakeMonitorProcess(); const spawn = vi.fn(() => process); + const write = vi.spyOn(process.stdin, "write"); const broadcasts: unknown[] = []; const manager = createAudioOutputLevelMonitorManager({ isWindows: () => true, @@ -71,7 +72,6 @@ describe("audio output level monitor lifecycle", () => { { deviceId: "dev-1", rms: 0.2, peak: 0.4, level: 40 }, ]); - const write = vi.spyOn(process.stdin, "write"); const stopPromise = manager.stop(); await manager.stop(); process.emit("close", 0); @@ -79,4 +79,34 @@ describe("audio output level monitor lifecycle", () => { expect(write).toHaveBeenCalledTimes(1); expect(write).toHaveBeenCalledWith("stop\n"); }); + + it("serializes overlapping starts and waits before stopping", async () => { + const process = new FakeMonitorProcess(); + const spawn = vi.fn(() => process); + const write = vi.spyOn(process.stdin, "write"); + let resolveAccess!: () => void; + const accessPromise = new Promise((resolve) => { + resolveAccess = resolve; + }); + const manager = createAudioOutputLevelMonitorManager({ + isWindows: () => true, + getHelperPath: () => "helper.exe", + access: () => accessPromise, + spawn, + }); + + const firstStart = manager.start(); + const secondStart = manager.start(); + const stop = manager.stop(); + + resolveAccess(); + await expect(Promise.all([firstStart, secondStart, stop])).resolves.toEqual([ + { success: true }, + { success: true }, + { success: true }, + ]); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("stop\n"); + }); }); diff --git a/electron/ipc/audioOutputMonitor.ts b/electron/ipc/audioOutputMonitor.ts index 9b681823a..0d48b72b8 100644 --- a/electron/ipc/audioOutputMonitor.ts +++ b/electron/ipc/audioOutputMonitor.ts @@ -99,6 +99,7 @@ export function createAudioOutputLevelMonitorManager( let monitorProcess: MonitorChildProcess | null = null; let outputBuffer = ""; let stopping: Promise<{ success: boolean }> | null = null; + let starting: Promise<{ success: boolean; error?: string }> | null = null; const clearProcess = (processToClear: MonitorChildProcess) => { if (monitorProcess !== processToClear) return; @@ -108,6 +109,13 @@ export function createAudioOutputLevelMonitorManager( const stop = async (): Promise<{ success: boolean }> => { if (stopping) return stopping; + if (starting) { + try { + await starting; + } catch { + // A failed startup leaves no process to stop. + } + } const current = monitorProcess; if (!current) return { success: true }; @@ -153,45 +161,57 @@ export function createAudioOutputLevelMonitorManager( const start = async (): Promise<{ success: boolean; error?: string }> => { if (stopping) await stopping; if (monitorProcess) return { success: true }; - if (!isWindows()) return { success: false, error: "System audio level monitoring is Windows-only" }; + if (starting) return starting; - const helperPath = getHelperPath(); - try { - await access(helperPath, fsConstants.F_OK); - } catch { - console.warn("Windows audio output level monitor helper missing:", helperPath); - return { success: false, error: "Audio output level monitor helper is unavailable" }; - } + starting = (async () => { + if (!isWindows()) { + return { success: false, error: "System audio level monitoring is Windows-only" }; + } - let child: MonitorChildProcess; - try { - child = spawnMonitor(helperPath, ["--monitor-audio-outputs"], { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, + const helperPath = getHelperPath(); + try { + await access(helperPath, fsConstants.F_OK); + } catch { + console.warn("Windows audio output level monitor helper missing:", helperPath); + return { success: false, error: "Audio output level monitor helper is unavailable" }; + } + + let child: MonitorChildProcess; + try { + child = spawnMonitor(helperPath, ["--monitor-audio-outputs"], { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + } catch (error) { + console.warn("Failed to spawn audio output level monitor:", error); + return { success: false, error: String(error) }; + } + + monitorProcess = child; + outputBuffer = ""; + child.stdout.on("data", (chunk) => { + outputBuffer += chunk.toString(); + const result = splitAudioOutputMonitorLines(outputBuffer); + outputBuffer = result.remainder; + result.events.forEach(broadcast); }); - } catch (error) { - console.warn("Failed to spawn audio output level monitor:", error); - return { success: false, error: String(error) }; - } + child.stderr.on("data", () => { + // Drain stderr so helper diagnostics cannot block stdout telemetry. + }); + child.once("error", (error) => { + console.warn("Audio output level monitor process error:", error); + clearProcess(child); + }); + child.once("close", () => clearProcess(child)); - monitorProcess = child; - outputBuffer = ""; - child.stdout.on("data", (chunk) => { - outputBuffer += chunk.toString(); - const result = splitAudioOutputMonitorLines(outputBuffer); - outputBuffer = result.remainder; - result.events.forEach(broadcast); - }); - child.stderr.on("data", () => { - // Drain stderr so helper diagnostics cannot block stdout telemetry. - }); - child.once("error", (error) => { - console.warn("Audio output level monitor process error:", error); - clearProcess(child); - }); - child.once("close", () => clearProcess(child)); + return { success: true }; + })(); - return { success: true }; + try { + return await starting; + } finally { + starting = null; + } }; return { start, stop }; @@ -206,4 +226,3 @@ export function registerAudioOutputMonitorHandlers() { ipcMain.handle("start-audio-output-level-monitor", () => defaultMonitorManager.start()); ipcMain.handle("stop-audio-output-level-monitor", () => defaultMonitorManager.stop()); } - diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 0172a0c30..4cfc5d066 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,35 +1,35 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "a5a9c0c417144a834af3206b60bae32d0c21d55deb8a3f5446831621cfdfa7b3", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "c65b6eb2230be7db9b49aac48eba52a9eac469028ebb242e10c16c51c86b3220", - "updatedAt": "2026-09-05T02:09:18.163Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", - "updatedAt": "2026-07-11T11:58:56.534Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", - "updatedAt": "2026-07-11T11:58:51.659Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", - "updatedAt": "2026-05-27T11:29:32.957Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "9e66a43a435cc4e1970f3785ef7752a6624af6af8fec19fb684a6cbed8d8c6a5", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "48552063374ce144d9da07547eae315a324313b589b717d34d04f9742009e088", + "updatedAt": "2026-09-18T06:03:52.729Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", + "updatedAt": "2026-07-11T11:58:56.534Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "49a2ac588206305d0129e6263ce4be49780c50a9dc4efc7df63aad09178a919f", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "37a5842eba63cdeccfddd02cc278a98207248fb0aa6b56153fb85ed152c908c1", + "updatedAt": "2026-07-11T11:58:51.659Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", + "updatedAt": "2026-05-27T11:29:32.957Z" + } + } } diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index efa45c37dcfc551a2c4f1878e35f8433d01e6bec..52ab0badd6090021a204393839b5c6fc4cd84c6a 100644 GIT binary patch delta 61694 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 delta 46658 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 OM5gq{pH~~F>i+|1V$T); diff --git a/electron/native/wgc-capture/src/audio_level_monitor.cpp b/electron/native/wgc-capture/src/audio_level_monitor.cpp index 273a9d0a0..75b9d1172 100644 --- a/electron/native/wgc-capture/src/audio_level_monitor.cpp +++ b/electron/native/wgc-capture/src/audio_level_monitor.cpp @@ -33,6 +33,7 @@ struct OutputMonitorDevice { IAudioClient* audioClient = nullptr; IAudioCaptureClient* captureClient = nullptr; WAVEFORMATEX* mixFormat = nullptr; + UINT32 bufferFrameCount = 0; bool isDefault = false; }; @@ -199,6 +200,9 @@ bool initializeMonitorDevice(OutputMonitorDevice& monitor) { nullptr); if (FAILED(hr)) return false; + hr = monitor.audioClient->GetBufferSize(&monitor.bufferFrameCount); + if (FAILED(hr) || monitor.bufferFrameCount == 0) return false; + hr = monitor.audioClient->GetService( __uuidof(IAudioCaptureClient), reinterpret_cast(&monitor.captureClient)); @@ -305,6 +309,22 @@ int runAudioOutputLevelMonitor() { } enumerator->Release(); + if (monitors.empty()) { + std::cerr << "ERROR: No audio output monitor could be initialized" << std::endl; + if (shouldUninitialize) CoUninitialize(); + return 1; + } + + DWORD pollIntervalMs = 50; + for (const OutputMonitorDevice& monitor : monitors) { + if (!monitor.mixFormat || monitor.mixFormat->nSamplesPerSec == 0) continue; + const double bufferDurationMs = + static_cast(monitor.bufferFrameCount) * 1000.0 / + static_cast(monitor.mixFormat->nSamplesPerSec); + const DWORD monitorIntervalMs = static_cast((std::max)(1.0, bufferDurationMs / 2.0)); + pollIntervalMs = (std::min)(pollIntervalMs, monitorIntervalMs); + } + std::atomic stopRequested{false}; std::thread stdinThread([&stopRequested]() { std::string line; @@ -328,7 +348,7 @@ int runAudioOutputLevelMonitor() { if (monitor.isDefault) writeLevel("default", result); } std::cout.flush(); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(std::chrono::milliseconds(pollIntervalMs)); } if (stdinThread.joinable()) stdinThread.join(); diff --git a/src/hooks/audioOutputDevices.test.ts b/src/hooks/audioOutputDevices.test.ts index 206101e43..29a49169e 100644 --- a/src/hooks/audioOutputDevices.test.ts +++ b/src/hooks/audioOutputDevices.test.ts @@ -3,6 +3,7 @@ import { type AudioOutputDevice, enrichNativeAudioOutputLabels, getDefaultAudioOutputLabel, + isLatestAudioOutputDeviceRequest, resolveAudioOutputDeviceSelection, } from "./audioOutputDevices"; @@ -53,6 +54,13 @@ describe("resolveAudioOutputDeviceSelection", () => { }); }); +describe("audio output device request ordering", () => { + it("only accepts the latest request result", () => { + expect(isLatestAudioOutputDeviceRequest(1, 2)).toBe(false); + expect(isLatestAudioOutputDeviceRequest(2, 2)).toBe(true); + }); +}); + describe("enrichNativeAudioOutputLabels", () => { it("uses the browser label with the USB vendor and product ID", () => { const nativeDevices = [{ deviceId: "native-yeti", label: "扬声器 (Yeti Nano)" }]; diff --git a/src/hooks/audioOutputDevices.ts b/src/hooks/audioOutputDevices.ts index 2a20830e9..4611dde5d 100644 --- a/src/hooks/audioOutputDevices.ts +++ b/src/hooks/audioOutputDevices.ts @@ -16,6 +16,13 @@ export interface NativeAudioOutputDevice { label: string; } +export function isLatestAudioOutputDeviceRequest( + requestId: number, + latestRequestId: number, +): boolean { + return requestId === latestRequestId; +} + const DEFAULT_OUTPUT_DEVICE: AudioOutputDevice = { deviceId: "default", label: "Default output", @@ -150,7 +157,9 @@ export function useAudioOutputDevices( } let mounted = true; + let latestRequestId = 0; const loadDevices = async () => { + const requestId = ++latestRequestId; try { setIsLoading(true); setError(null); @@ -177,7 +186,7 @@ export function useAudioOutputDevices( ]; } - if (!mounted) { + if (!mounted || !isLatestAudioOutputDeviceRequest(requestId, latestRequestId)) { return; } @@ -192,7 +201,7 @@ export function useAudioOutputDevices( }); setIsLoading(false); } catch (loadError) { - if (!mounted) { + if (!mounted || !isLatestAudioOutputDeviceRequest(requestId, latestRequestId)) { return; } const message = @@ -213,6 +222,7 @@ export function useAudioOutputDevices( return () => { mounted = false; + latestRequestId += 1; navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); }; }, [enabled, preferredDeviceId, preferredLabel]);