diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index b2a37205..e20cf7ff 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -135,6 +135,10 @@ interface Window { saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; hudOverlayHide: () => void; hudOverlayClose: () => void; + showCountdownOverlay: (value: number, runId: number) => Promise; + setCountdownOverlayValue: (value: number, runId: number) => Promise; + hideCountdownOverlay: (runId: number) => Promise; + onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void; setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index be20fcde..261d93fc 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -352,15 +352,163 @@ function sampleCursorPoint() { export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, + createCountdownOverlayWindow: () => BrowserWindow, getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, + getCountdownOverlayWindow: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, switchToHud?: () => void, ) { + const supportsWindowOpacity = process.platform !== "linux"; + const countdownOverlayState = { + visible: false, + value: null as number | null, + activeRunId: null as number | null, + hideCommitId: 0, + hideCommitTimer: null as ReturnType | null, + }; + const COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS = 1200; + + const clearCountdownOverlayHideCommit = () => { + if (countdownOverlayState.hideCommitTimer) { + clearTimeout(countdownOverlayState.hideCommitTimer); + countdownOverlayState.hideCommitTimer = null; + } + }; + + const commitCountdownOverlayHide = (win: BrowserWindow, hideCommitId: number) => { + if (win.isDestroyed()) { + return; + } + + if (countdownOverlayState.visible || countdownOverlayState.hideCommitId !== hideCommitId) { + return; + } + + win.hide(); + if (supportsWindowOpacity) { + // Reset baseline opacity for the next show cycle. + win.setOpacity(1); + } + }; + + const flushCountdownOverlayState = (win: BrowserWindow) => { + if (win.isDestroyed()) { + return; + } + + clearCountdownOverlayHideCommit(); + win.webContents.send("countdown-overlay-value", countdownOverlayState.value); + if (!countdownOverlayState.visible) { + return; + } + + if (win.isVisible()) { + if (supportsWindowOpacity) { + win.setOpacity(1); + } + return; + } + + setTimeout(() => { + if (!win.isDestroyed() && countdownOverlayState.visible && !win.isVisible()) { + if (supportsWindowOpacity) { + win.setOpacity(0); + } + win.showInactive(); + + if (supportsWindowOpacity) { + setTimeout(() => { + if (!win.isDestroyed() && countdownOverlayState.visible && win.isVisible()) { + win.setOpacity(1); + } + }, 0); + } + } + }, 16); + }; + + ipcMain.handle("countdown-overlay-show", (_, value: number, runId: number) => { + countdownOverlayState.activeRunId = runId; + countdownOverlayState.visible = true; + countdownOverlayState.value = value; + + const win = getCountdownOverlayWindow() ?? createCountdownOverlayWindow(); + if (win.isDestroyed()) { + return; + } + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", () => { + if (!win.isDestroyed()) { + flushCountdownOverlayState(win); + } + }); + } else { + flushCountdownOverlayState(win); + } + }); + + ipcMain.handle("countdown-overlay-set-value", (_, value: number, runId: number) => { + if (countdownOverlayState.activeRunId !== runId || !countdownOverlayState.visible) { + return; + } + + countdownOverlayState.value = value; + + const win = getCountdownOverlayWindow(); + if (!win || win.isDestroyed()) { + return; + } + + if (win.webContents.isLoading()) { + return; + } + + win.webContents.send("countdown-overlay-value", value); + }); + + ipcMain.handle("countdown-overlay-hide", (_, runId: number) => { + if (countdownOverlayState.activeRunId !== runId) { + return; + } + + countdownOverlayState.visible = false; + countdownOverlayState.hideCommitId += 1; + const hideCommitId = countdownOverlayState.hideCommitId; + clearCountdownOverlayHideCommit(); + + const win = getCountdownOverlayWindow(); + if (!win || win.isDestroyed()) { + countdownOverlayState.value = null; + return; + } + + if (supportsWindowOpacity) { + // Hide visually immediately to avoid hide/show compositor flashes on rapid restart. + win.setOpacity(0); + } + + countdownOverlayState.value = null; + if (!win.webContents.isLoading()) { + win.webContents.send("countdown-overlay-value", countdownOverlayState.value); + } + + if (!supportsWindowOpacity) { + win.hide(); + return; + } + + countdownOverlayState.hideCommitTimer = setTimeout(() => { + countdownOverlayState.hideCommitTimer = null; + commitCountdownOverlayHide(win, hideCommitId); + }, COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS); + }); + ipcMain.handle("switch-to-hud", () => { if (switchToHud) switchToHud(); }); - ipcMain.handle("start-new-recording", async () => { + ipcMain.handle("start-new-recording", () => { try { setCurrentRecordingSessionState(null); if (switchToHud) { @@ -518,9 +666,8 @@ export function registerIpcHandlers( }); ipcMain.handle("read-binary-file", async (_, inputPath: string) => { - let normalizedPath: string | null = null; try { - normalizedPath = normalizeVideoSourcePath(inputPath); + const normalizedPath = normalizeVideoSourcePath(inputPath); if (!normalizedPath) { return { success: false, message: "Invalid file path" }; } @@ -545,7 +692,6 @@ export function registerIpcHandlers( success: false, message: "Failed to read binary file", error: String(error), - path: normalizedPath, }; } }); diff --git a/electron/main.ts b/electron/main.ts index c399fd09..ad0a33fc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -14,7 +14,12 @@ import { } from "electron"; import { mainT, setMainLocale } from "./i18n"; import { registerIpcHandlers } from "./ipc/handlers"; -import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows"; +import { + createCountdownOverlayWindow, + createEditorWindow, + createHudOverlayWindow, + createSourceSelectorWindow, +} from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,6 +65,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL // Window references let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; +let countdownOverlayWindow: BrowserWindow | null = null; let tray: Tray | null = null; let selectedSourceName = ""; const isMac = process.platform === "darwin"; @@ -322,6 +328,18 @@ function createSourceSelectorWindowWrapper() { return sourceSelectorWindow; } +function createCountdownOverlayWindowWrapper() { + if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) { + return countdownOverlayWindow; + } + + countdownOverlayWindow = createCountdownOverlayWindow(); + countdownOverlayWindow.on("closed", () => { + countdownOverlayWindow = null; + }); + return countdownOverlayWindow; +} + // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. app.on("window-all-closed", () => { @@ -331,8 +349,17 @@ app.on("window-all-closed", () => { app.on("activate", () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => { + if (window.isDestroyed() || !window.isVisible()) { + return false; + } + + const url = window.webContents.getURL(); + const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay"); + return !isCountdownOverlayWindow; + }); + if (!hasVisibleWindow) { + showMainWindow(); } }); @@ -386,8 +413,10 @@ app.whenReady().then(async () => { registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, + createCountdownOverlayWindowWrapper, () => mainWindow, () => sourceSelectorWindow, + () => countdownOverlayWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; if (!tray) createTray(); diff --git a/electron/preload.ts b/electron/preload.ts index eeca25cd..6aa066fb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -130,6 +130,20 @@ contextBridge.exposeInMainWorld("electronAPI", { setHasUnsavedChanges: (hasChanges: boolean) => { ipcRenderer.send("set-has-unsaved-changes", hasChanges); }, + showCountdownOverlay: (value: number, runId: number) => { + return ipcRenderer.invoke("countdown-overlay-show", value, runId); + }, + setCountdownOverlayValue: (value: number, runId: number) => { + return ipcRenderer.invoke("countdown-overlay-set-value", value, runId); + }, + hideCountdownOverlay: (runId: number) => { + return ipcRenderer.invoke("countdown-overlay-hide", runId); + }, + onCountdownOverlayValue: (callback: (value: number | null) => void) => { + const listener = (_event: unknown, value: number | null) => callback(value); + ipcRenderer.on("countdown-overlay-value", listener); + return () => ipcRenderer.removeListener("countdown-overlay-value", listener); + }, onRequestSaveBeforeClose: (callback: () => Promise | boolean) => { const listener = async () => { try { diff --git a/electron/windows.ts b/electron/windows.ts index dcd9f92b..daa6e5e5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -177,3 +177,55 @@ export function createSourceSelectorWindow(): BrowserWindow { return win; } + +/** + * Creates a centered transparent countdown overlay window that sits above the + * HUD while recording pre-roll is running. + */ +export function createCountdownOverlayWindow(): BrowserWindow { + const { workArea } = screen.getPrimaryDisplay(); + const overlayWidth = 420; + const overlayHeight = 260; + + const win = new BrowserWindow({ + width: overlayWidth, + height: overlayHeight, + minWidth: overlayWidth, + maxWidth: overlayWidth, + minHeight: overlayHeight, + maxHeight: overlayHeight, + x: Math.round(workArea.x + (workArea.width - overlayWidth) / 2), + y: Math.round(workArea.y + (workArea.height - overlayHeight) / 2), + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, + transparent: true, + backgroundColor: "#00000000", + hasShadow: false, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + win.setIgnoreMouseEvents(true); + + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "countdown-overlay" }, + }); + } + + return win; +} diff --git a/src/App.tsx b/src/App.tsx index 9772ef89..4045b5dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; @@ -9,18 +10,24 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; export default function App() { - const [windowType, setWindowType] = useState(""); + const [windowType, setWindowType] = useState( + () => new URLSearchParams(window.location.search).get("windowType") || "", + ); useEffect(() => { - const params = new URLSearchParams(window.location.search); - const type = params.get("windowType") || ""; - setWindowType(type); - if (type === "hud-overlay" || type === "source-selector") { + const type = new URLSearchParams(window.location.search).get("windowType") || ""; + if (type !== windowType) { + setWindowType(type); + } + + if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } + }, [windowType]); + useEffect(() => { // Load custom fonts on app initialization loadAllCustomFonts().catch((error) => { console.error("Failed to load custom fonts:", error); @@ -33,6 +40,8 @@ export default function App() { return ; case "source-selector": return ; + case "countdown-overlay": + return ; case "editor": return ( diff --git a/src/components/launch/CountdownOverlay.tsx b/src/components/launch/CountdownOverlay.tsx new file mode 100644 index 00000000..71d12c50 --- /dev/null +++ b/src/components/launch/CountdownOverlay.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +export function CountdownOverlay() { + const [value, setValue] = useState(null); + + useEffect(() => { + const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => { + setValue(nextValue); + }); + + return () => unsubscribe(); + }, []); + + if (value === null) { + return null; + } + + return ( +
+
+
+ {value} +
+
+
+ ); +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index b059c238..913386c1 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -110,6 +110,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); + const countdownRunId = useRef(0); + const [countdownActive, setCountdownActive] = useState(false); const webcamReady = useRef(false); const webcamAcquireId = useRef(0); @@ -411,7 +413,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } return () => { + const activeRunId = countdownRunId.current; if (cleanup) cleanup(); + countdownRunId.current += 1; + void safeHideCountdownOverlay(activeRunId); allowAutoFinalize.current = false; restarting.current = false; discardRecordingId.current = null; @@ -442,7 +447,117 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; }, [teardownMedia]); - const startRecording = async () => { + const safeShowCountdownOverlay = async (value: number, runId: number) => { + try { + await window.electronAPI.showCountdownOverlay(value, runId); + return true; + } catch (error) { + console.warn("Failed to show countdown overlay:", error); + return false; + } + }; + + const cancelCountdown = () => { + const activeRunId = countdownRunId.current; + countdownRunId.current += 1; + setCountdownActive(false); + void safeHideCountdownOverlay(activeRunId); + }; + + const safeSetCountdownOverlayValue = async (value: number, runId: number) => { + try { + await window.electronAPI.setCountdownOverlayValue(value, runId); + } catch (error) { + console.warn("Failed to update countdown overlay value:", error); + } + }; + + const safeHideCountdownOverlay = async (runId: number) => { + try { + await window.electronAPI.hideCountdownOverlay(runId); + } catch (error) { + console.warn("Failed to hide countdown overlay:", error); + } + }; + + const isCountdownRunActive = (runId?: number) => + runId === undefined || countdownRunId.current === runId; + + const startRecordCountdown = async () => { + if (countdownActive || recording) { + return; + } + + const runId = countdownRunId.current + 1; + countdownRunId.current = runId; + setCountdownActive(true); + + let selectedSource: ProcessedDesktopSource | null = null; + try { + selectedSource = await window.electronAPI.getSelectedSource(); + } catch (error) { + console.warn("Failed to read selected source before countdown:", error); + } + + if (!isCountdownRunActive(runId)) { + return; + } + + if (!selectedSource) { + if (countdownRunId.current === runId) { + setCountdownActive(false); + } + alert(t("recording.selectSource")); + return; + } + + let overlayHiddenBeforeStart = false; + try { + const values = [3, 2, 1]; + const overlayShown = await safeShowCountdownOverlay(values[0], runId); + + if (countdownRunId.current !== runId) { + return; + } + + for (const value of values) { + if (countdownRunId.current !== runId) { + return; + } + + if (overlayShown && value !== values[0]) { + await safeSetCountdownOverlayValue(value, runId); + + if (countdownRunId.current !== runId) { + return; + } + } + + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + } + + if (countdownRunId.current !== runId) { + return; + } + + setCountdownActive(false); + await safeHideCountdownOverlay(runId); + overlayHiddenBeforeStart = true; + + if (countdownRunId.current !== runId) { + return; + } + + await startRecording(runId); + } finally { + if (!overlayHiddenBeforeStart && countdownRunId.current === runId) { + setCountdownActive(false); + await safeHideCountdownOverlay(runId); + } + } + }; + + const startRecording = async (countdownRunToken?: number) => { try { const selectedSource = await window.electronAPI.getSelectedSource(); if (!selectedSource) { @@ -450,6 +565,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + let screenMediaStream: MediaStream; const videoConstraints = { @@ -490,6 +610,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } screenStream.current = screenMediaStream; + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + if (microphoneEnabled) { try { microphoneStream.current = await navigator.mediaDevices.getUserMedia({ @@ -514,6 +639,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + if (webcamEnabled) { if (!webcamReady.current) { await new Promise((resolve) => { @@ -535,6 +665,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + stream.current = new MediaStream(); const videoTrack = screenMediaStream.getVideoTracks()[0]; if (!videoTrack) { @@ -575,6 +710,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); } + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, @@ -594,6 +734,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); const hasAudio = stream.current.getAudioTracks().length > 0; + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + screenRecorder.current = createRecorderHandle(stream.current, { mimeType, videoBitsPerSecond, @@ -705,7 +850,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const toggleRecording = () => { - recording ? stopRecording.current() : startRecording(); + if (recording) { + stopRecording.current(); + return; + } + + if (countdownActive) { + cancelCountdown(); + return; + } + + void startRecordCountdown(); }; const restartRecording = async () => { @@ -769,13 +924,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const cancelRecording = () => { const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; + if ( + activeScreenRecorder?.recorder.state === "recording" || + activeScreenRecorder?.recorder.state === "paused" + ) { + const activeRecordingId = recordingId.current; + discardRecordingId.current = activeRecordingId; + allowAutoFinalize.current = false; - const activeRecordingId = recordingId.current; - discardRecordingId.current = activeRecordingId; - allowAutoFinalize.current = false; + stopRecording.current(); + return; + } - stopRecording.current(); + if (countdownActive) { + cancelCountdown(); + return; + } }; return { diff --git a/src/main.tsx b/src/main.tsx index 670e2b6f..365bdc7b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,17 @@ import App from "./App.tsx"; import { I18nProvider } from "./contexts/I18nContext"; import "./index.css"; +const windowType = new URLSearchParams(window.location.search).get("windowType") || ""; +if ( + windowType === "hud-overlay" || + windowType === "source-selector" || + windowType === "countdown-overlay" +) { + document.body.style.background = "transparent"; + document.documentElement.style.background = "transparent"; + document.getElementById("root")?.style.setProperty("background", "transparent"); +} + ReactDOM.createRoot(document.getElementById("root")!).render(