diff --git a/electron/main.ts b/electron/main.ts index c399fd09..a1224e0b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -14,7 +14,13 @@ import { } from "electron"; import { mainT, setMainLocale } from "./i18n"; import { registerIpcHandlers } from "./ipc/handlers"; -import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows"; +import { + closeCameraPreviewWindow, + createCameraPreviewWindow, + createEditorWindow, + createHudOverlayWindow, + createSourceSelectorWindow, +} from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -361,6 +367,14 @@ app.whenReady().then(async () => { ipcMain.on("hud-overlay-close", () => { app.quit(); }); + + // Camera preview window — shown during recording so user can see their face + ipcMain.on("show-camera-preview", (_, deviceId: string) => { + createCameraPreviewWindow(deviceId ?? ""); + }); + ipcMain.on("hide-camera-preview", () => { + closeCameraPreviewWindow(); + }); ipcMain.handle("set-locale", (_, locale: string) => { setMainLocale(locale); setupApplicationMenu(); diff --git a/electron/preload.ts b/electron/preload.ts index eeca25cd..420dac4a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -124,6 +124,12 @@ contextBridge.exposeInMainWorld("electronAPI", { setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, + showCameraPreview: (deviceId: string) => { + ipcRenderer.send("show-camera-preview", deviceId); + }, + hideCameraPreview: () => { + ipcRenderer.send("hide-camera-preview"); + }, setMicrophoneExpanded: (expanded: boolean) => { ipcRenderer.send("hud:setMicrophoneExpanded", expanded); }, diff --git a/electron/windows.ts b/electron/windows.ts index dcd9f92b..938a0b5f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -10,6 +10,7 @@ const RENDERER_DIST = path.join(APP_ROOT, "dist"); const HEADLESS = process.env["HEADLESS"] === "true"; let hudOverlayWindow: BrowserWindow | null = null; +let cameraPreviewWindow: BrowserWindow | null = null; ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { @@ -85,6 +86,77 @@ export function createHudOverlayWindow(): BrowserWindow { return win; } +/** + * Creates a small always-on-top floating window that shows the live webcam + * feed during recording so the user can see their face overlay in real time. + */ +export function createCameraPreviewWindow(deviceId: string): BrowserWindow { + const primaryDisplay = screen.getPrimaryDisplay(); + const { workArea } = primaryDisplay; + + const windowSize = 220; + const margin = 24; + + const x = Math.floor(workArea.x + workArea.width - windowSize - margin); + const y = Math.floor(workArea.y + workArea.height - windowSize - margin - 170); // above HUD + + if (cameraPreviewWindow && !cameraPreviewWindow.isDestroyed()) { + cameraPreviewWindow.close(); + cameraPreviewWindow = null; + } + + const win = new BrowserWindow({ + width: windowSize, + height: windowSize, + x, + y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + show: !HEADLESS, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + cameraPreviewWindow = win; + + win.on("closed", () => { + if (cameraPreviewWindow === win) { + cameraPreviewWindow = null; + } + }); + + const encodedDeviceId = encodeURIComponent(deviceId); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(`${VITE_DEV_SERVER_URL}?windowType=camera-preview&deviceId=${encodedDeviceId}`); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "camera-preview", deviceId }, + }); + } + + return win; +} + +export function closeCameraPreviewWindow(): void { + if (cameraPreviewWindow && !cameraPreviewWindow.isDestroyed()) { + cameraPreviewWindow.close(); + cameraPreviewWindow = null; + } +} + /** * Creates the main editor window. Starts maximised with a hidden title bar on * macOS. This window is not always-on-top and appears in the taskbar/dock. diff --git a/src/App.tsx b/src/App.tsx index 9772ef89..0f0dee67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { CameraPreviewWindow } from "./components/launch/CameraPreviewWindow"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; @@ -15,7 +16,7 @@ export default function App() { const params = new URLSearchParams(window.location.search); const type = params.get("windowType") || ""; setWindowType(type); - if (type === "hud-overlay" || type === "source-selector") { + if (type === "hud-overlay" || type === "source-selector" || type === "camera-preview") { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); @@ -29,6 +30,8 @@ export default function App() { const content = (() => { switch (windowType) { + case "camera-preview": + return ; case "hud-overlay": return ; case "source-selector": diff --git a/src/components/launch/CameraPreviewWindow.tsx b/src/components/launch/CameraPreviewWindow.tsx new file mode 100644 index 00000000..f1afa294 --- /dev/null +++ b/src/components/launch/CameraPreviewWindow.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Floating camera preview window — shown whenever the webcam is enabled, + * both before and during recording. Renders the live feed in a draggable + * circle. On hover, a collapse button appears so the user can tuck it away + * without turning off the webcam. + */ +export function CameraPreviewWindow() { + const videoRef = useRef(null); + const [collapsed, setCollapsed] = useState(false); + const [hovered, setHovered] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const deviceId = params.get("deviceId") ?? ""; + + const constraints: MediaStreamConstraints = { + video: deviceId + ? { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } + : { width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }; + + let stream: MediaStream | null = null; + + navigator.mediaDevices + .getUserMedia(constraints) + .then((s) => { + stream = s; + if (videoRef.current) { + videoRef.current.srcObject = s; + } + }) + .catch(console.error); + + return () => { + stream?.getTracks().forEach((t) => t.stop()); + }; + }, []); + + if (collapsed) { + return ( +
+ {/* Collapsed pill — click to expand */} + +
+ ); + } + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Circle video frame */} +
+
+
+ + {/* Hover controls */} + {hovered && ( + + )} +
+
+ ); +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77d..27eb3425 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -152,6 +152,19 @@ export function LaunchWindow() { } }, [selectedCameraId, setWebcamDeviceId]); + // Show live camera preview as soon as webcam is enabled (before and during recording). + // Only retrigger when webcamEnabled flips — not on every device-list load. + useEffect(() => { + if (!window.electronAPI) return; + if (webcamEnabled) { + const deviceId = webcamDeviceId || selectedCameraId || ""; + window.electronAPI.showCameraPreview(deviceId); + } else { + window.electronAPI.hideCameraPreview(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [webcamEnabled]); + useEffect(() => { if (!import.meta.env.DEV) { return; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 5cbc54a1..f08ddfb4 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -555,6 +555,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); + const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d76ee157..c4f8d3aa 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -118,6 +118,8 @@ interface Window { onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; + showCameraPreview: (deviceId: string) => void; + hideCameraPreview: () => void; setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void;