mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge pull request #32 from kmr-varun/feature/countdown-timer
feat: add countdown timer before recording starts
This commit is contained in:
Vendored
+6
@@ -83,6 +83,12 @@ interface Window {
|
||||
muxWgcRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
/** Hide the OS cursor before browser capture starts. */
|
||||
hideOsCursor: () => Promise<{ success: boolean }>
|
||||
/** Countdown timer before recording */
|
||||
getCountdownDelay: () => Promise<{ success: boolean; delay: number }>
|
||||
setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>
|
||||
startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>
|
||||
cancelCountdown: () => Promise<{ success: boolean }>
|
||||
onCountdownTick: (callback: (seconds: number) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { RECORDINGS_DIR } from '../main'
|
||||
import { createCountdownWindow, getCountdownWindow, closeCountdownWindow } from '../windows'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
@@ -17,6 +18,7 @@ const PROJECT_FILE_EXTENSION = 'recordly'
|
||||
const LEGACY_PROJECT_FILE_EXTENSIONS = ['openscreen']
|
||||
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
|
||||
const RECORDINGS_SETTINGS_FILE = path.join(app.getPath('userData'), 'recordings-settings.json')
|
||||
const COUNTDOWN_SETTINGS_FILE = path.join(app.getPath('userData'), 'countdown-settings.json')
|
||||
const AUTO_RECORDING_PREFIX = 'recording-'
|
||||
const AUTO_RECORDING_RETENTION_COUNT = 20
|
||||
const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000
|
||||
@@ -77,6 +79,9 @@ let customRecordingsDir: string | null = null
|
||||
let recordingsDirLoaded = false
|
||||
let cachedSystemCursorAssets: Record<string, SystemCursorAsset> | null = null
|
||||
let cachedSystemCursorAssetsSourceMtimeMs: number | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownCancelled = false
|
||||
let countdownInProgress = false
|
||||
|
||||
type SystemCursorAsset = {
|
||||
dataUrl: string
|
||||
@@ -2767,5 +2772,92 @@ export function registerIpcHandlers(
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Countdown timer before recording
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle('get-countdown-delay', async () => {
|
||||
try {
|
||||
const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(content) as { delay?: number }
|
||||
return { success: true, delay: parsed.delay ?? 3 }
|
||||
} catch {
|
||||
return { success: true, delay: 3 }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('set-countdown-delay', async (_, delay: number) => {
|
||||
try {
|
||||
await fs.writeFile(COUNTDOWN_SETTINGS_FILE, JSON.stringify({ delay }, null, 2), 'utf-8')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to save countdown delay:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('start-countdown', async (_, seconds: number) => {
|
||||
if (countdownInProgress) {
|
||||
return { success: false, error: 'Countdown already in progress' }
|
||||
}
|
||||
|
||||
countdownInProgress = true
|
||||
countdownCancelled = false
|
||||
|
||||
const countdownWin = createCountdownWindow()
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
countdownWin.webContents.once('did-finish-load', () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => {
|
||||
let remaining = seconds
|
||||
|
||||
countdownWin.webContents.send('countdown-tick', remaining)
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
if (countdownCancelled) {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
closeCountdownWindow()
|
||||
countdownInProgress = false
|
||||
resolve({ success: false, cancelled: true })
|
||||
return
|
||||
}
|
||||
|
||||
remaining--
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
closeCountdownWindow()
|
||||
countdownInProgress = false
|
||||
resolve({ success: true })
|
||||
} else {
|
||||
const win = getCountdownWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('countdown-tick', remaining)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('cancel-countdown', () => {
|
||||
countdownCancelled = true
|
||||
countdownInProgress = false
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
closeCountdownWindow()
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -185,5 +185,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
muxWgcRecording: () => ipcRenderer.invoke('mux-wgc-recording'),
|
||||
// Cursor visibility control for cursor-free browser capture fallback
|
||||
hideOsCursor: () => ipcRenderer.invoke('hide-cursor'),
|
||||
// Countdown timer before recording
|
||||
getCountdownDelay: () => ipcRenderer.invoke('get-countdown-delay'),
|
||||
setCountdownDelay: (delay: number) => ipcRenderer.invoke('set-countdown-delay', delay),
|
||||
startCountdown: (seconds: number) => ipcRenderer.invoke('start-countdown', seconds),
|
||||
cancelCountdown: () => ipcRenderer.invoke('cancel-countdown'),
|
||||
onCountdownTick: (callback: (seconds: number) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, seconds: number) => callback(seconds)
|
||||
ipcRenderer.on('countdown-tick', listener)
|
||||
return () => ipcRenderer.removeListener('countdown-tick', listener)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+69
-8
@@ -12,6 +12,7 @@ const RENDERER_DIST = path.join(APP_ROOT, 'dist')
|
||||
const WINDOW_ICON_PATH = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'app-icons', 'recordly-512.png')
|
||||
|
||||
let hudOverlayWindow: BrowserWindow | null = null;
|
||||
let countdownWindow: BrowserWindow | null = null;
|
||||
|
||||
function getScreen() {
|
||||
return nodeRequire('electron').screen as typeof import('electron').screen
|
||||
@@ -28,8 +29,8 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
|
||||
const windowWidth = 600;
|
||||
const windowHeight = 155;
|
||||
const windowWidth = 660;
|
||||
const windowHeight = 170;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
@@ -37,10 +38,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
minWidth: 660,
|
||||
maxWidth: 660,
|
||||
minHeight: 170,
|
||||
maxHeight: 170,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
@@ -176,11 +177,71 @@ export function createSourceSelectorWindow(): BrowserWindow {
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'source-selector' }
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'source-selector' }
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function createCountdownWindow(): BrowserWindow {
|
||||
const primaryDisplay = getScreen().getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
|
||||
const windowSize = 200;
|
||||
const x = Math.floor((width - windowSize) / 2);
|
||||
const y = Math.floor((height - windowSize) / 2);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: windowSize,
|
||||
height: windowSize,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
countdownWindow = win;
|
||||
|
||||
// Show on all workspaces/spaces so it follows the user
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
|
||||
win.on('closed', () => {
|
||||
if (countdownWindow === win) {
|
||||
countdownWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=countdown')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'countdown' }
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function getCountdownWindow(): BrowserWindow | null {
|
||||
return countdownWindow;
|
||||
}
|
||||
|
||||
export function closeCountdownWindow(): void {
|
||||
if (countdownWindow && !countdownWindow.isDestroyed()) {
|
||||
countdownWindow.close();
|
||||
countdownWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CountdownOverlay } from "./components/countdown/CountdownOverlay";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
@@ -16,7 +17,7 @@ export default function App() {
|
||||
const type = params.get('windowType') || '';
|
||||
setWindowType(type);
|
||||
|
||||
if (type === 'hud-overlay' || type === 'source-selector') {
|
||||
if (type === 'hud-overlay' || type === 'source-selector' || type === 'countdown') {
|
||||
document.body.style.background = 'transparent';
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.getElementById('root')?.style.setProperty('background', 'transparent');
|
||||
@@ -39,6 +40,8 @@ export default function App() {
|
||||
return <LaunchWindow />;
|
||||
case 'source-selector':
|
||||
return <SourceSelector />;
|
||||
case 'countdown':
|
||||
return <CountdownOverlay />;
|
||||
case 'editor':
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function CountdownOverlay() {
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onCountdownTick((seconds: number) => {
|
||||
setCountdown(seconds);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
window.electronAPI.cancelCountdown();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleCancel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
if (countdown === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center select-none cursor-pointer"
|
||||
onClick={handleCancel}
|
||||
onKeyDown={(e) => e.key === "Escape" && handleCancel()}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-3xl"
|
||||
style={{
|
||||
width: 180,
|
||||
height: 180,
|
||||
background: "rgba(0, 0, 0, 0.85)",
|
||||
backdropFilter: "blur(20px)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-white font-bold tabular-nums"
|
||||
style={{
|
||||
fontSize: "100px",
|
||||
lineHeight: 1,
|
||||
textShadow: "0 0 30px rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
{countdown}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import { Languages } from "lucide-react";
|
||||
import { Languages, Timer } from "lucide-react";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -31,6 +31,7 @@ export function LaunchWindow() {
|
||||
const LOCALE_LABELS: Record<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
|
||||
const {
|
||||
recording,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
@@ -38,6 +39,8 @@ export function LaunchWindow() {
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
countdownDelay,
|
||||
setCountdownDelay,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -245,11 +248,43 @@ export function LaunchWindow() {
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
disabled={recording}
|
||||
title={t('recording.countdownDelay')}
|
||||
className={`gap-1 text-white/70 hover:bg-transparent px-1 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Timer size={14} />
|
||||
<span>{countdownDelay > 0 ? `${countdownDelay}s` : t('recording.noDelay')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="min-w-[80px] max-h-none overflow-visible bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{[0, 3, 5, 10].map((delay) => (
|
||||
<DropdownMenuItem
|
||||
key={delay}
|
||||
onSelect={() => setCountdownDelay(delay)}
|
||||
className={`text-xs cursor-pointer ${
|
||||
countdownDelay === delay ? "text-white font-medium" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{delay === 0 ? t('recording.noDelay') : `${delay}s`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
disabled={countdownActive || (!hasSelectedSource && !recording)}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
|
||||
@@ -28,6 +28,7 @@ const MIC_GAIN_BOOST = 1.4;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
countdownActive: boolean;
|
||||
toggleRecording: () => void;
|
||||
preparePermissions: (options?: { startup?: boolean }) => Promise<boolean>;
|
||||
isMacOS: boolean;
|
||||
@@ -37,15 +38,19 @@ type UseScreenRecorderReturn = {
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
countdownDelay: number;
|
||||
setCountdownDelay: (delay: number) => void;
|
||||
};
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [countdownActive, setCountdownActive] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [countdownDelay, setCountdownDelayState] = useState(3);
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
const stream = useRef<MediaStream | null>(null);
|
||||
const screenStream = useRef<MediaStream | null>(null);
|
||||
@@ -57,6 +62,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const wgcRecording = useRef(false);
|
||||
const startInFlight = useRef(false);
|
||||
const hasPromptedForReselect = useRef(false);
|
||||
const countdownDelayLoaded = useRef(false);
|
||||
|
||||
const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => {
|
||||
const platform = await window.electronAPI.getPlatform();
|
||||
@@ -194,6 +200,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdownDelayLoaded.current) return;
|
||||
countdownDelayLoaded.current = true;
|
||||
|
||||
void (async () => {
|
||||
const result = await window.electronAPI.getCountdownDelay();
|
||||
if (result.success && typeof result.delay === "number") {
|
||||
setCountdownDelayState(result.delay);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setCountdownDelay = useCallback((delay: number) => {
|
||||
setCountdownDelayState(delay);
|
||||
void window.electronAPI.setCountdownDelay(delay);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
@@ -548,16 +571,35 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
if (starting) {
|
||||
const toggleRecording = async () => {
|
||||
if (starting || countdownActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
if (recording) {
|
||||
stopRecording.current();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start recording with optional countdown
|
||||
if (countdownDelay > 0) {
|
||||
setCountdownActive(true);
|
||||
try {
|
||||
const result = await window.electronAPI.startCountdown(countdownDelay);
|
||||
if (!result.success || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setCountdownActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
startRecording();
|
||||
};
|
||||
|
||||
return {
|
||||
recording,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
preparePermissions,
|
||||
isMacOS,
|
||||
@@ -567,6 +609,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
countdownDelay,
|
||||
setCountdownDelay,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"enableSystemAudio": "Enable system audio",
|
||||
"disableMicrophone": "Disable microphone",
|
||||
"enableMicrophone": "Enable microphone",
|
||||
"countdownDelay": "Countdown delay",
|
||||
"noDelay": "No delay",
|
||||
"record": "Record",
|
||||
"recordingFolder": "Recording folder: {{path}}",
|
||||
"chooseRecordingsFolder": "Choose recordings folder",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"enableSystemAudio": "Activar audio del sistema",
|
||||
"disableMicrophone": "Desactivar micrófono",
|
||||
"enableMicrophone": "Activar micrófono",
|
||||
"countdownDelay": "Retraso de cuenta regresiva",
|
||||
"noDelay": "Sin retraso",
|
||||
"record": "Grabar",
|
||||
"recordingFolder": "Carpeta de grabaciones: {{path}}",
|
||||
"chooseRecordingsFolder": "Elegir carpeta de grabaciones",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableMicrophone": "禁用麦克风",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"countdownDelay": "倒计时延迟",
|
||||
"noDelay": "无延迟",
|
||||
"record": "录制",
|
||||
"recordingFolder": "录制文件夹:{{path}}",
|
||||
"chooseRecordingsFolder": "选择录制文件夹",
|
||||
|
||||
Reference in New Issue
Block a user