Skip to content

Commit cccb966

Browse files
Merge pull request #460 from Galactic99/feat/countdown-before-record-start
feat:add countdown before record start
2 parents ae6b6ca + c033984 commit cccb966

9 files changed

Lines changed: 478 additions & 19 deletions

File tree

electron/electron-env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ interface Window {
135135
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
136136
hudOverlayHide: () => void;
137137
hudOverlayClose: () => void;
138+
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
139+
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
140+
hideCountdownOverlay: (runId: number) => Promise<void>;
141+
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
138142
setMicrophoneExpanded: (expanded: boolean) => void;
139143
setHasUnsavedChanges: (hasChanges: boolean) => void;
140144
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;

electron/ipc/handlers.ts

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,15 +352,163 @@ function sampleCursorPoint() {
352352
export function registerIpcHandlers(
353353
createEditorWindow: () => void,
354354
createSourceSelectorWindow: () => BrowserWindow,
355+
createCountdownOverlayWindow: () => BrowserWindow,
355356
getMainWindow: () => BrowserWindow | null,
356357
getSourceSelectorWindow: () => BrowserWindow | null,
358+
getCountdownOverlayWindow: () => BrowserWindow | null,
357359
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
358360
switchToHud?: () => void,
359361
) {
362+
const supportsWindowOpacity = process.platform !== "linux";
363+
const countdownOverlayState = {
364+
visible: false,
365+
value: null as number | null,
366+
activeRunId: null as number | null,
367+
hideCommitId: 0,
368+
hideCommitTimer: null as ReturnType<typeof setTimeout> | null,
369+
};
370+
const COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS = 1200;
371+
372+
const clearCountdownOverlayHideCommit = () => {
373+
if (countdownOverlayState.hideCommitTimer) {
374+
clearTimeout(countdownOverlayState.hideCommitTimer);
375+
countdownOverlayState.hideCommitTimer = null;
376+
}
377+
};
378+
379+
const commitCountdownOverlayHide = (win: BrowserWindow, hideCommitId: number) => {
380+
if (win.isDestroyed()) {
381+
return;
382+
}
383+
384+
if (countdownOverlayState.visible || countdownOverlayState.hideCommitId !== hideCommitId) {
385+
return;
386+
}
387+
388+
win.hide();
389+
if (supportsWindowOpacity) {
390+
// Reset baseline opacity for the next show cycle.
391+
win.setOpacity(1);
392+
}
393+
};
394+
395+
const flushCountdownOverlayState = (win: BrowserWindow) => {
396+
if (win.isDestroyed()) {
397+
return;
398+
}
399+
400+
clearCountdownOverlayHideCommit();
401+
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
402+
if (!countdownOverlayState.visible) {
403+
return;
404+
}
405+
406+
if (win.isVisible()) {
407+
if (supportsWindowOpacity) {
408+
win.setOpacity(1);
409+
}
410+
return;
411+
}
412+
413+
setTimeout(() => {
414+
if (!win.isDestroyed() && countdownOverlayState.visible && !win.isVisible()) {
415+
if (supportsWindowOpacity) {
416+
win.setOpacity(0);
417+
}
418+
win.showInactive();
419+
420+
if (supportsWindowOpacity) {
421+
setTimeout(() => {
422+
if (!win.isDestroyed() && countdownOverlayState.visible && win.isVisible()) {
423+
win.setOpacity(1);
424+
}
425+
}, 0);
426+
}
427+
}
428+
}, 16);
429+
};
430+
431+
ipcMain.handle("countdown-overlay-show", (_, value: number, runId: number) => {
432+
countdownOverlayState.activeRunId = runId;
433+
countdownOverlayState.visible = true;
434+
countdownOverlayState.value = value;
435+
436+
const win = getCountdownOverlayWindow() ?? createCountdownOverlayWindow();
437+
if (win.isDestroyed()) {
438+
return;
439+
}
440+
441+
if (win.webContents.isLoading()) {
442+
win.webContents.once("did-finish-load", () => {
443+
if (!win.isDestroyed()) {
444+
flushCountdownOverlayState(win);
445+
}
446+
});
447+
} else {
448+
flushCountdownOverlayState(win);
449+
}
450+
});
451+
452+
ipcMain.handle("countdown-overlay-set-value", (_, value: number, runId: number) => {
453+
if (countdownOverlayState.activeRunId !== runId || !countdownOverlayState.visible) {
454+
return;
455+
}
456+
457+
countdownOverlayState.value = value;
458+
459+
const win = getCountdownOverlayWindow();
460+
if (!win || win.isDestroyed()) {
461+
return;
462+
}
463+
464+
if (win.webContents.isLoading()) {
465+
return;
466+
}
467+
468+
win.webContents.send("countdown-overlay-value", value);
469+
});
470+
471+
ipcMain.handle("countdown-overlay-hide", (_, runId: number) => {
472+
if (countdownOverlayState.activeRunId !== runId) {
473+
return;
474+
}
475+
476+
countdownOverlayState.visible = false;
477+
countdownOverlayState.hideCommitId += 1;
478+
const hideCommitId = countdownOverlayState.hideCommitId;
479+
clearCountdownOverlayHideCommit();
480+
481+
const win = getCountdownOverlayWindow();
482+
if (!win || win.isDestroyed()) {
483+
countdownOverlayState.value = null;
484+
return;
485+
}
486+
487+
if (supportsWindowOpacity) {
488+
// Hide visually immediately to avoid hide/show compositor flashes on rapid restart.
489+
win.setOpacity(0);
490+
}
491+
492+
countdownOverlayState.value = null;
493+
if (!win.webContents.isLoading()) {
494+
win.webContents.send("countdown-overlay-value", countdownOverlayState.value);
495+
}
496+
497+
if (!supportsWindowOpacity) {
498+
win.hide();
499+
return;
500+
}
501+
502+
countdownOverlayState.hideCommitTimer = setTimeout(() => {
503+
countdownOverlayState.hideCommitTimer = null;
504+
commitCountdownOverlayHide(win, hideCommitId);
505+
}, COUNTDOWN_OVERLAY_HIDE_DEBOUNCE_MS);
506+
});
507+
360508
ipcMain.handle("switch-to-hud", () => {
361509
if (switchToHud) switchToHud();
362510
});
363-
ipcMain.handle("start-new-recording", async () => {
511+
ipcMain.handle("start-new-recording", () => {
364512
try {
365513
setCurrentRecordingSessionState(null);
366514
if (switchToHud) {
@@ -518,9 +666,8 @@ export function registerIpcHandlers(
518666
});
519667

520668
ipcMain.handle("read-binary-file", async (_, inputPath: string) => {
521-
let normalizedPath: string | null = null;
522669
try {
523-
normalizedPath = normalizeVideoSourcePath(inputPath);
670+
const normalizedPath = normalizeVideoSourcePath(inputPath);
524671
if (!normalizedPath) {
525672
return { success: false, message: "Invalid file path" };
526673
}
@@ -545,7 +692,6 @@ export function registerIpcHandlers(
545692
success: false,
546693
message: "Failed to read binary file",
547694
error: String(error),
548-
path: normalizedPath,
549695
};
550696
}
551697
});

electron/main.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import {
1414
} from "electron";
1515
import { mainT, setMainLocale } from "./i18n";
1616
import { registerIpcHandlers } from "./ipc/handlers";
17-
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
17+
import {
18+
createCountdownOverlayWindow,
19+
createEditorWindow,
20+
createHudOverlayWindow,
21+
createSourceSelectorWindow,
22+
} from "./windows";
1823

1924
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2025

@@ -60,6 +65,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
6065
// Window references
6166
let mainWindow: BrowserWindow | null = null;
6267
let sourceSelectorWindow: BrowserWindow | null = null;
68+
let countdownOverlayWindow: BrowserWindow | null = null;
6369
let tray: Tray | null = null;
6470
let selectedSourceName = "";
6571
const isMac = process.platform === "darwin";
@@ -322,6 +328,18 @@ function createSourceSelectorWindowWrapper() {
322328
return sourceSelectorWindow;
323329
}
324330

331+
function createCountdownOverlayWindowWrapper() {
332+
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
333+
return countdownOverlayWindow;
334+
}
335+
336+
countdownOverlayWindow = createCountdownOverlayWindow();
337+
countdownOverlayWindow.on("closed", () => {
338+
countdownOverlayWindow = null;
339+
});
340+
return countdownOverlayWindow;
341+
}
342+
325343
// On macOS, applications and their menu bar stay active until the user quits
326344
// explicitly with Cmd + Q.
327345
app.on("window-all-closed", () => {
@@ -331,8 +349,17 @@ app.on("window-all-closed", () => {
331349
app.on("activate", () => {
332350
// On OS X it's common to re-create a window in the app when the
333351
// dock icon is clicked and there are no other windows open.
334-
if (BrowserWindow.getAllWindows().length === 0) {
335-
createWindow();
352+
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
353+
if (window.isDestroyed() || !window.isVisible()) {
354+
return false;
355+
}
356+
357+
const url = window.webContents.getURL();
358+
const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay");
359+
return !isCountdownOverlayWindow;
360+
});
361+
if (!hasVisibleWindow) {
362+
showMainWindow();
336363
}
337364
});
338365

@@ -386,8 +413,10 @@ app.whenReady().then(async () => {
386413
registerIpcHandlers(
387414
createEditorWindowWrapper,
388415
createSourceSelectorWindowWrapper,
416+
createCountdownOverlayWindowWrapper,
389417
() => mainWindow,
390418
() => sourceSelectorWindow,
419+
() => countdownOverlayWindow,
391420
(recording: boolean, sourceName: string) => {
392421
selectedSourceName = sourceName;
393422
if (!tray) createTray();

electron/preload.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,20 @@ contextBridge.exposeInMainWorld("electronAPI", {
130130
setHasUnsavedChanges: (hasChanges: boolean) => {
131131
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
132132
},
133+
showCountdownOverlay: (value: number, runId: number) => {
134+
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
135+
},
136+
setCountdownOverlayValue: (value: number, runId: number) => {
137+
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
138+
},
139+
hideCountdownOverlay: (runId: number) => {
140+
return ipcRenderer.invoke("countdown-overlay-hide", runId);
141+
},
142+
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
143+
const listener = (_event: unknown, value: number | null) => callback(value);
144+
ipcRenderer.on("countdown-overlay-value", listener);
145+
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
146+
},
133147
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
134148
const listener = async () => {
135149
try {

electron/windows.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,55 @@ export function createSourceSelectorWindow(): BrowserWindow {
177177

178178
return win;
179179
}
180+
181+
/**
182+
* Creates a centered transparent countdown overlay window that sits above the
183+
* HUD while recording pre-roll is running.
184+
*/
185+
export function createCountdownOverlayWindow(): BrowserWindow {
186+
const { workArea } = screen.getPrimaryDisplay();
187+
const overlayWidth = 420;
188+
const overlayHeight = 260;
189+
190+
const win = new BrowserWindow({
191+
width: overlayWidth,
192+
height: overlayHeight,
193+
minWidth: overlayWidth,
194+
maxWidth: overlayWidth,
195+
minHeight: overlayHeight,
196+
maxHeight: overlayHeight,
197+
x: Math.round(workArea.x + (workArea.width - overlayWidth) / 2),
198+
y: Math.round(workArea.y + (workArea.height - overlayHeight) / 2),
199+
frame: false,
200+
resizable: false,
201+
alwaysOnTop: true,
202+
skipTaskbar: true,
203+
focusable: false,
204+
transparent: true,
205+
backgroundColor: "#00000000",
206+
hasShadow: false,
207+
show: false,
208+
webPreferences: {
209+
preload: path.join(__dirname, "preload.mjs"),
210+
nodeIntegration: false,
211+
contextIsolation: true,
212+
backgroundThrottling: false,
213+
},
214+
});
215+
216+
win.setIgnoreMouseEvents(true);
217+
218+
if (process.platform === "darwin") {
219+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
220+
}
221+
222+
if (VITE_DEV_SERVER_URL) {
223+
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown-overlay");
224+
} else {
225+
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
226+
query: { windowType: "countdown-overlay" },
227+
});
228+
}
229+
230+
return win;
231+
}

src/App.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useState } from "react";
2+
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
23
import { LaunchWindow } from "./components/launch/LaunchWindow";
34
import { SourceSelector } from "./components/launch/SourceSelector";
45
import { Toaster } from "./components/ui/sonner";
@@ -9,18 +10,24 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext";
910
import { loadAllCustomFonts } from "./lib/customFonts";
1011

1112
export default function App() {
12-
const [windowType, setWindowType] = useState("");
13+
const [windowType, setWindowType] = useState(
14+
() => new URLSearchParams(window.location.search).get("windowType") || "",
15+
);
1316

1417
useEffect(() => {
15-
const params = new URLSearchParams(window.location.search);
16-
const type = params.get("windowType") || "";
17-
setWindowType(type);
18-
if (type === "hud-overlay" || type === "source-selector") {
18+
const type = new URLSearchParams(window.location.search).get("windowType") || "";
19+
if (type !== windowType) {
20+
setWindowType(type);
21+
}
22+
23+
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
1924
document.body.style.background = "transparent";
2025
document.documentElement.style.background = "transparent";
2126
document.getElementById("root")?.style.setProperty("background", "transparent");
2227
}
28+
}, [windowType]);
2329

30+
useEffect(() => {
2431
// Load custom fonts on app initialization
2532
loadAllCustomFonts().catch((error) => {
2633
console.error("Failed to load custom fonts:", error);
@@ -33,6 +40,8 @@ export default function App() {
3340
return <LaunchWindow />;
3441
case "source-selector":
3542
return <SourceSelector />;
43+
case "countdown-overlay":
44+
return <CountdownOverlay />;
3645
case "editor":
3746
return (
3847
<ShortcutsProvider>

0 commit comments

Comments
 (0)