Merge pull request #460 from Galactic99/feat/countdown-before-record-start

feat:add countdown before record start
This commit is contained in:
Sid
2026-04-20 08:25:30 -07:00
committed by GitHub
9 changed files with 478 additions and 19 deletions
+4
View File
@@ -135,6 +135,10 @@ interface Window {
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
+150 -4
View File
@@ -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<typeof setTimeout> | 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,
};
}
});
+32 -3
View File
@@ -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();
+14
View File
@@ -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> | boolean) => {
const listener = async () => {
try {
+52
View File
@@ -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;
}
+14 -5
View File
@@ -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 <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "countdown-overlay":
return <CountdownOverlay />;
case "editor":
return (
<ShortcutsProvider>
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export function CountdownOverlay() {
const [value, setValue] = useState<number | null>(null);
useEffect(() => {
const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => {
setValue(nextValue);
});
return () => unsubscribe();
}, []);
if (value === null) {
return null;
}
return (
<div className="w-screen h-screen bg-transparent flex items-center justify-center pointer-events-none select-none">
<div className="flex items-center justify-center w-40 h-40 rounded-full bg-black/50">
<div
className="text-white/90 text-[80px] font-bold leading-none tabular-nums"
style={{ textShadow: "0 4px 24px rgba(0, 0, 0, 0.65)" }}
>
{value}
</div>
</div>
</div>
);
}
+171 -7
View File
@@ -110,6 +110,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(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<void>((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 {
+11
View File
@@ -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(
<React.StrictMode>
<I18nProvider>