Merge pull request #32 from kmr-varun/feature/countdown-timer

feat: add countdown timer before recording starts
This commit is contained in:
webadderall
2026-03-16 17:03:18 +11:00
committed by GitHub
11 changed files with 335 additions and 14 deletions
+6
View File
@@ -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
}
}
+92
View File
@@ -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 }
})
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+37 -2
View File
@@ -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 ? (
+47 -3
View File
@@ -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,
};
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -4,6 +4,8 @@
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
"enableMicrophone": "启用麦克风",
"countdownDelay": "倒计时延迟",
"noDelay": "无延迟",
"record": "录制",
"recordingFolder": "录制文件夹:{{path}}",
"chooseRecordingsFolder": "选择录制文件夹",