diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f859957d..371ea789 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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 } } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3d92d84b..44fcda9e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 | null = null let cachedSystemCursorAssetsSourceMtimeMs: number | null = null +let countdownTimer: ReturnType | 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((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 } + }) } diff --git a/electron/preload.ts b/electron/preload.ts index b98420a9..bc282d8b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) + }, }) diff --git a/electron/windows.ts b/electron/windows.ts index 232571d0..30674666 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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; + } +} + diff --git a/src/App.tsx b/src/App.tsx index 2a471f1f..2418ce53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; case 'source-selector': return ; + case 'countdown': + return ; case 'editor': return ( diff --git a/src/components/countdown/CountdownOverlay.tsx b/src/components/countdown/CountdownOverlay.tsx new file mode 100644 index 00000000..50c68e29 --- /dev/null +++ b/src/components/countdown/CountdownOverlay.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; + +export function CountdownOverlay() { + const [countdown, setCountdown] = useState(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 ( +
e.key === "Escape" && handleCancel()} + > +
+ + {countdown} + +
+
+ ); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 20d121b0..06c48067 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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 = { 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(null); const [elapsed, setElapsed] = useState(0); @@ -245,11 +248,43 @@ export function LaunchWindow() {
+ + + + + + {[0, 3, 5, 10].map((delay) => ( + setCountdownDelay(delay)} + className={`text-xs cursor-pointer ${ + countdownDelay === delay ? "text-white font-medium" : "text-white/60" + }`} + > + {delay === 0 ? t('recording.noDelay') : `${delay}s`} + + ))} + + +