From 4e9a217f244fc3fe759b47d8039fdd70d867bcf3 Mon Sep 17 00:00:00 2001 From: Raed Ouerfelli <38401226+ouerf-man@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:06:44 +0100 Subject: [PATCH 1/2] feat(macOS): add persistent landscape camera preview --- electron/cameraPreview.test.ts | 105 +++++++++++++ electron/cameraPreview.ts | 121 +++++++++++++++ electron/cameraPreviewBounds.test.ts | 30 ++++ electron/cameraPreviewBounds.ts | 47 ++++++ electron/electron-env.d.ts | 10 ++ electron/ipc/handlers.ts | 9 +- electron/lingeringOverlay.test.ts | 13 +- electron/lingeringOverlay.ts | 9 ++ electron/main.ts | 23 ++- .../ScreenCaptureRecorder.swift | 6 +- electron/preload.ts | 14 ++ electron/windows.ts | 39 +++++ src/App.tsx | 12 +- .../launch/CameraPreviewWindow.module.css | 42 +++++ .../launch/CameraPreviewWindow.test.tsx | 83 ++++++++++ src/components/launch/CameraPreviewWindow.tsx | 146 ++++++++++++++++++ src/hooks/webcamCaptureTarget.test.ts | 7 + src/hooks/webcamCaptureTarget.ts | 6 +- src/i18n/locales/ar/common.json | 3 +- src/i18n/locales/ar/launch.json | 9 ++ src/i18n/locales/cs/common.json | 3 +- src/i18n/locales/cs/launch.json | 9 ++ src/i18n/locales/de/common.json | 3 +- src/i18n/locales/de/launch.json | 9 ++ src/i18n/locales/en/common.json | 3 +- src/i18n/locales/en/launch.json | 9 ++ src/i18n/locales/es/common.json | 3 +- src/i18n/locales/es/launch.json | 9 ++ src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/fr/launch.json | 9 ++ src/i18n/locales/it/common.json | 3 +- src/i18n/locales/it/launch.json | 9 ++ src/i18n/locales/ja-JP/common.json | 3 +- src/i18n/locales/ja-JP/launch.json | 9 ++ src/i18n/locales/ko-KR/common.json | 3 +- src/i18n/locales/ko-KR/launch.json | 9 ++ src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/pt-BR/launch.json | 9 ++ src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/ru/launch.json | 9 ++ src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/tr/launch.json | 9 ++ src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/vi/launch.json | 9 ++ src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-CN/launch.json | 9 ++ src/i18n/locales/zh-TW/common.json | 3 +- src/i18n/locales/zh-TW/launch.json | 9 ++ src/lib/cameraPreview.ts | 10 ++ .../testing/assets/camera-preview/README.md | 27 ++++ .../assets/camera-preview/controls.png | Bin 0 -> 32964 bytes .../testing/assets/camera-preview/idle.png | Bin 0 -> 27707 bytes .../testing/manual-e2e-checklist.md | 1 + 53 files changed, 912 insertions(+), 28 deletions(-) create mode 100644 electron/cameraPreview.test.ts create mode 100644 electron/cameraPreview.ts create mode 100644 electron/cameraPreviewBounds.test.ts create mode 100644 electron/cameraPreviewBounds.ts create mode 100644 src/components/launch/CameraPreviewWindow.module.css create mode 100644 src/components/launch/CameraPreviewWindow.test.tsx create mode 100644 src/components/launch/CameraPreviewWindow.tsx create mode 100644 src/lib/cameraPreview.ts create mode 100644 technical-documentation/testing/assets/camera-preview/README.md create mode 100644 technical-documentation/testing/assets/camera-preview/controls.png create mode 100644 technical-documentation/testing/assets/camera-preview/idle.png diff --git a/electron/cameraPreview.test.ts b/electron/cameraPreview.test.ts new file mode 100644 index 000000000..da1fef95b --- /dev/null +++ b/electron/cameraPreview.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + events: new Map void>(), + window: { + isDestroyed: vi.fn(() => false), + showInactive: vi.fn(), + hide: vi.fn(), + focus: vi.fn(), + once: vi.fn((event: string, callback: () => void) => mocks.events.set(event, callback)), + on: vi.fn(), + webContents: { send: vi.fn() }, + getBounds: vi.fn(() => ({ x: 50, y: 50, width: 368, height: 214 })), + setBounds: vi.fn(), + }, + create: vi.fn(), +})); +vi.mock("electron", () => ({ + ipcMain: { + handle: (name: string, callback: (event: unknown, command?: unknown) => unknown) => + mocks.handlers.set(name, callback), + }, + screen: { + getCursorScreenPoint: () => ({ x: 200, y: 200 }), + getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1440, height: 900 } }), + }, +})); +vi.mock("./windows", () => ({ + createCameraPreviewWindow: () => { + mocks.create(); + return mocks.window; + }, +})); +const originalPlatform = process.platform; +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.handlers.clear(); + mocks.events.clear(); + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); +}); +afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); +}); +describe("macOS camera preview", () => { + it("precreates one stable window before enabling the camera, and keeps it across device changes", async () => { + const preview = await import("./cameraPreview"); + preview.registerCameraPreview(); + expect(preview.getCameraPreviewWindow()).toBe(mocks.window); + preview.syncCameraPreview({ camEnabled: false }); + expect(mocks.window.showInactive).not.toHaveBeenCalled(); + preview.syncCameraPreview({ camEnabled: true, camDeviceId: "a" }); + expect(mocks.window.showInactive).not.toHaveBeenCalled(); + mocks.events.get("ready-to-show")?.(); + expect(mocks.window.showInactive).toHaveBeenCalled(); + preview.syncCameraPreview({ camEnabled: true, camDeviceId: "b" }); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.window.webContents.send).toHaveBeenLastCalledWith("camera-preview-state", { + enabled: true, + deviceId: "b", + deviceName: undefined, + }); + }); + it("stops a dismissed preview without changing the recording preferences; explicit Show or a camera toggle reopens it", async () => { + const preview = await import("./cameraPreview"); + preview.registerCameraPreview(); + const event = { sender: mocks.window.webContents }; + preview.syncCameraPreview({ camEnabled: true }); + mocks.handlers.get("camera-preview-hide")?.(event); + preview.syncCameraPreview({ camEnabled: true, camDeviceId: "b" }); + expect(mocks.handlers.get("camera-preview-state")?.(event)).toEqual({ + enabled: false, + deviceId: "b", + deviceName: undefined, + }); + preview.showCameraPreview(); + expect(mocks.handlers.get("camera-preview-state")?.(event)).toMatchObject({ enabled: true }); + mocks.handlers.get("camera-preview-hide")?.(event); + preview.syncCameraPreview({ camEnabled: false }); + preview.syncCameraPreview({ camEnabled: true }); + expect(mocks.handlers.get("camera-preview-state")?.(event)).toMatchObject({ enabled: true }); + }); + it("rejects other windows and invalid geometry commands", async () => { + const preview = await import("./cameraPreview"); + preview.registerCameraPreview(); + preview.syncCameraPreview({ camEnabled: true }); + expect(mocks.handlers.get("camera-preview-state")?.({ sender: {} })).toBe(null); + mocks.handlers.get("camera-preview-hide")?.({ sender: {} }); + const transform = mocks.handlers.get("camera-preview-transform"); + const command = { action: "nudge", direction: "move", dx: 5, dy: 0 }; + transform?.({ sender: {} }, command); + transform?.({ sender: mocks.window.webContents }, { ...command, dx: Infinity }); + transform?.({ sender: mocks.window.webContents }, { ...command, direction: "invalid" }); + expect(mocks.window.setBounds).not.toHaveBeenCalled(); + transform?.({ sender: mocks.window.webContents }, command); + expect(mocks.window.setBounds).toHaveBeenCalledWith({ x: 55, y: 50, width: 368, height: 214 }); + }); + it.each(["win32", "linux"])("does not start an unprotected preview on %s", async (platform) => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + const preview = await import("./cameraPreview"); + preview.registerCameraPreview(); + expect(mocks.create).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/cameraPreview.ts b/electron/cameraPreview.ts new file mode 100644 index 000000000..1c1f8e21b --- /dev/null +++ b/electron/cameraPreview.ts @@ -0,0 +1,121 @@ +import { type BrowserWindow, type IpcMainInvokeEvent, ipcMain, screen } from "electron"; +import type { + CameraPreviewCommand, + CameraPreviewState, + CameraPreviewTransform, +} from "../src/lib/cameraPreview"; +import { cameraPreviewBounds } from "./cameraPreviewBounds"; +import { createCameraPreviewWindow } from "./windows"; + +let preview: BrowserWindow | null = null; +let state: CameraPreviewState = { enabled: false }; +let cameraEnabled = false; +let dismissed = false; +let ready = false; +let gesture: { + bounds: Electron.Rectangle; + cursor: Electron.Point; + direction: CameraPreviewTransform; +} | null = null; +const directions = new Set(["move", "nw", "ne", "sw", "se"]); +export function getCameraPreviewWindow() { + return preview; +} + +function publish() { + if (!preview || preview.isDestroyed()) return; + preview.webContents.send("camera-preview-state", state); + if (state.enabled && ready) preview.showInactive(); + else preview.hide(); +} + +/** Only a new camera activation or an explicit Show action overrides a manual dismissal. */ +export function syncCameraPreview(prefs: { + camEnabled: boolean; + camDeviceId?: string | null; + camDeviceName?: string | null; +}) { + if (!preview || preview.isDestroyed()) return; + if (!prefs.camEnabled || !cameraEnabled) dismissed = false; + cameraEnabled = prefs.camEnabled; + state = { + enabled: cameraEnabled && !dismissed, + deviceId: prefs.camDeviceId ?? undefined, + deviceName: prefs.camDeviceName ?? undefined, + }; + publish(); +} +export function showCameraPreview(focus = false) { + if (!preview || preview.isDestroyed()) return; + dismissed = false; + state = { ...state, enabled: cameraEnabled }; + publish(); + if (focus && state.enabled && ready) preview.focus(); +} + +export function registerCameraPreview() { + if (process.platform !== "darwin") return; + // Precreate before a source can be picked, including while Camera is off. + preview = createCameraPreviewWindow(); + preview.once("ready-to-show", () => { + ready = true; + publish(); + }); + preview.once("closed", () => { + preview = null; + gesture = null; + ready = false; + }); + preview.on("blur", () => { + gesture = null; + }); + const isPreview = (event: IpcMainInvokeEvent) => + preview && !preview.isDestroyed() && event.sender === preview.webContents; + ipcMain.handle("camera-preview-state", (event) => (isPreview(event) ? state : null)); + ipcMain.handle("camera-preview-hide", (event) => { + if (!isPreview(event)) return; + dismissed = true; + gesture = null; + state = { ...state, enabled: false }; + publish(); + }); + ipcMain.handle("camera-preview-transform", (event, command: CameraPreviewCommand) => { + if (!isPreview(event) || !preview || !state.enabled || !command) return; + if (command.action === "begin" && directions.has(command.direction)) { + gesture = { + bounds: preview.getBounds(), + cursor: screen.getCursorScreenPoint(), + direction: command.direction, + }; + } else if (command.action === "end") { + gesture = null; + } else if (command.action === "update" && gesture) { + const cursor = screen.getCursorScreenPoint(); + preview.setBounds( + cameraPreviewBounds( + gesture.bounds, + gesture.direction, + { x: cursor.x - gesture.cursor.x, y: cursor.y - gesture.cursor.y }, + screen.getDisplayNearestPoint(cursor).workArea, + ), + ); + } else if ( + command.action === "nudge" && + directions.has(command.direction) && + Number.isFinite(command.dx) && + Number.isFinite(command.dy) && + Math.abs(command.dx) <= 40 && + Math.abs(command.dy) <= 40 + ) { + const bounds = preview.getBounds(); + preview.setBounds( + cameraPreviewBounds( + bounds, + command.direction, + { x: command.dx, y: command.dy }, + screen.getDisplayMatching(bounds).workArea, + ), + ); + } + }); +} diff --git a/electron/cameraPreviewBounds.test.ts b/electron/cameraPreviewBounds.test.ts new file mode 100644 index 000000000..4aa6cdedb --- /dev/null +++ b/electron/cameraPreviewBounds.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { cameraPreviewBounds } from "./cameraPreviewBounds"; + +const bounds = { x: 400, y: 300, width: 368, height: 214 }; +const area = { x: 0, y: 0, width: 1440, height: 900 }; +describe("camera preview geometry", () => { + it("moves across displays with negative origins and clamps into the work area", () => { + expect( + cameraPreviewBounds(bounds, "move", { x: -3000, y: 3000 }, { ...area, x: -1440 }), + ).toEqual({ ...bounds, x: -1440, y: 686 }); + }); + it.each(["nw", "ne", "sw", "se"] as const)("anchors the opposite corner for %s", (direction) => { + const result = cameraPreviewBounds(bounds, direction, { x: 64, y: 32 }, area); + expect((result.width - 16) / (result.height - 16)).toBeCloseTo(16 / 9, 1); + expect(direction.includes("w") ? result.x + result.width : result.x).toBe( + direction.includes("w") ? bounds.x + bounds.width : bounds.x, + ); + expect(direction.includes("n") ? result.y + result.height : result.y).toBe( + direction.includes("n") ? bounds.y + bounds.height : bounds.y, + ); + }); + it("limits both very small and very large resizes", () => { + const small = cameraPreviewBounds(bounds, "se", { x: -10000, y: -10000 }, area); + const large = cameraPreviewBounds(bounds, "se", { x: 10000, y: 10000 }, area); + expect(small.width).toBe(256); + expect(large.width).toBe(736); + expect(large.x + large.width).toBeLessThanOrEqual(area.width); + expect(large.y + large.height).toBeLessThanOrEqual(area.height); + }); +}); diff --git a/electron/cameraPreviewBounds.ts b/electron/cameraPreviewBounds.ts new file mode 100644 index 000000000..c76273442 --- /dev/null +++ b/electron/cameraPreviewBounds.ts @@ -0,0 +1,47 @@ +import type { CameraPreviewTransform } from "../src/lib/cameraPreview"; + +type Rect = { x: number; y: number; width: number; height: number }; +type Point = { x: number; y: number }; +export const CAMERA_PREVIEW_GUTTER = 16; +const ASPECT = 16 / 9; + +/** Keep the opposite corner anchored and the image (excluding shadow) in 16:9. */ +export function cameraPreviewBounds( + bounds: Rect, + direction: CameraPreviewTransform, + delta: Point, + area: Rect, +): Rect { + let { x, y, width, height } = bounds; + if (direction === "move") { + x += delta.x; + y += delta.y; + } else { + const west = direction.includes("w"); + const north = direction.includes("n"); + const dx = west ? -delta.x : delta.x; + const dy = (north ? -delta.y : delta.y) * ASPECT; + const change = Math.abs(dx) >= Math.abs(dy) ? dx : dy; + const maxWidth = + Math.min( + 720, + area.width - CAMERA_PREVIEW_GUTTER, + (area.height - CAMERA_PREVIEW_GUTTER) * ASPECT, + ) + CAMERA_PREVIEW_GUTTER; + width = Math.round( + Math.max( + Math.min(240 + CAMERA_PREVIEW_GUTTER, maxWidth), + Math.min(maxWidth, bounds.width + change), + ), + ); + height = Math.round((width - CAMERA_PREVIEW_GUTTER) / ASPECT) + CAMERA_PREVIEW_GUTTER; + if (west) x += bounds.width - width; + if (north) y += bounds.height - height; + } + return { + x: Math.round(Math.max(area.x, Math.min(x, area.x + area.width - width))), + y: Math.round(Math.max(area.y, Math.min(y, area.y + area.height - height))), + width, + height, + }; +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index fc6b5f911..8f86b0411 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -70,6 +70,16 @@ interface Window { onSelectedSourceChanged: ( callback: (source: ProcessedDesktopSource | null) => void, ) => () => void; + getCameraPreviewState?: () => Promise< + import("../src/lib/cameraPreview").CameraPreviewState | null + >; + onCameraPreviewState?: ( + callback: (state: import("../src/lib/cameraPreview").CameraPreviewState) => void, + ) => () => void; + hideCameraPreview?: () => Promise; + transformCameraPreview?: ( + command: import("../src/lib/cameraPreview").CameraPreviewCommand, + ) => Promise; getRecordingPrefs: () => Promise; setRecordingPrefs: ( prefs: Partial, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1724d695d..92c21e1e5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -70,6 +70,7 @@ import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { StylePresetService } from "../ai-edition/style-preset-service"; import { AppSettingsStore } from "../app-settings"; +import { getCameraPreviewWindow, registerCameraPreview, syncCameraPreview } from "../cameraPreview"; import { createRunnerHost } from "../cli/runnerHost"; import { hideCountdownOverlay } from "../countdownOverlay"; import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; @@ -2011,6 +2012,7 @@ export function registerIpcHandlers( mcpAppRecorder.recordingChanged(recording); onRecordingStateChange?.(recording, sourceName); }; + registerCameraPreview(); const appSettings = new AppSettingsStore(app.getPath("userData")); const broadcastSelectedSource = (source: SelectedSource | null) => { for (const window of BrowserWindow.getAllWindows()) { @@ -2175,7 +2177,7 @@ export function registerIpcHandlers( * pick would record them unless the picker is told to leave them out. */ function macPickerExcludedWindowIds() { - const appWindowSourceIds = [getMainWindow(), getNotesWindow()] + const appWindowSourceIds = [getMainWindow(), getNotesWindow(), getCameraPreviewWindow()] .filter((window): window is BrowserWindow => !!window && !window.isDestroyed()) .map((window) => window.getMediaSourceId()); return collectMacCaptureExcludedWindowIds(appWindowSourceIds); @@ -2330,6 +2332,7 @@ export function registerIpcHandlers( getMainWindow, () => BrowserWindow.getAllWindows(), (previous, next) => { + syncCameraPreview(next); // Apple's picker bakes the exclusions into the filter it hands back, so a pick made // before "Hide desktop icons" changed would still record the desktop the old way. // Dropping it makes the HUD ask for a new pick instead of ignoring the toggle. @@ -2350,6 +2353,8 @@ export function registerIpcHandlers( }, ); + syncCameraPreview(recordingPrefs.get()); + // Asked before every take starts. A disk that fills up mid-take used to be found only // when the take was saved, and the take was lost with it. ipcMain.handle("check-recording-storage", async () => { @@ -3226,7 +3231,7 @@ export function registerIpcHandlers( pick?.bounds ?? request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds(); const captureExcludedWindowSourceIds: string[] = []; if (request.source.type === "display") { - for (const window of [getMainWindow(), getNotesWindow()]) { + for (const window of [getMainWindow(), getNotesWindow(), getCameraPreviewWindow()]) { if (window && !window.isDestroyed()) { captureExcludedWindowSourceIds.push(window.getMediaSourceId()); } diff --git a/electron/lingeringOverlay.test.ts b/electron/lingeringOverlay.test.ts index 37700033f..30809138f 100644 --- a/electron/lingeringOverlay.test.ts +++ b/electron/lingeringOverlay.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isOnlyLingeringOverlay } from "./lingeringOverlay"; +import { isOnlyLingeringOverlay, onlyLingeringOverlays } from "./lingeringOverlay"; const window = (destroyed = false) => ({ isDestroyed: () => destroyed }); @@ -21,3 +21,14 @@ describe("isOnlyLingeringOverlay", () => { expect(isOnlyLingeringOverlay([gone], gone)).toBe(false); }); }); + +describe("onlyLingeringOverlays", () => { + it("closes both hidden overlays when no main window remains", () => { + const countdown = window(); + const camera = window(); + expect(onlyLingeringOverlays([countdown, camera], [countdown, camera])).toBe(true); + expect(onlyLingeringOverlays([camera], [null, camera])).toBe(true); + expect(onlyLingeringOverlays([camera, window()], [countdown, camera])).toBe(false); + expect(onlyLingeringOverlays([], [null, camera])).toBe(false); + }); +}); diff --git a/electron/lingeringOverlay.ts b/electron/lingeringOverlay.ts index 35d64e64d..a70c50e35 100644 --- a/electron/lingeringOverlay.ts +++ b/electron/lingeringOverlay.ts @@ -16,3 +16,12 @@ export function isOnlyLingeringOverlay( const alive = open.filter((window) => !window.isDestroyed()); return alive.length === 1 && alive[0] === overlay; } + +/** All remaining windows are disposable overlays, including the live camera. */ +export function onlyLingeringOverlays( + open: readonly LingeringWindow[], + overlays: readonly (LingeringWindow | null)[], +): boolean { + const alive = open.filter((window) => !window.isDestroyed()); + return alive.length > 0 && alive.every((window) => overlays.includes(window)); +} diff --git a/electron/main.ts b/electron/main.ts index 5876d266c..7d4af3ab3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -36,6 +36,7 @@ import { shouldStartBackgroundUpdateTimer, type UpdateMode, } from "./background-update"; +import { getCameraPreviewWindow, showCameraPreview } from "./cameraPreview"; import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; import { disableHttpCacheForDevServer } from "./dev-http-cache"; @@ -58,7 +59,7 @@ import { getSelectedDesktopSource, registerIpcHandlers, } from "./ipc/handlers"; -import { isOnlyLingeringOverlay } from "./lingeringOverlay"; +import { onlyLingeringOverlays } from "./lingeringOverlay"; import { installMainProcessErrorGuards } from "./main-process-errors"; import { serveAppChannel } from "./mcp/app-channel"; import { showMessageBoxOver } from "./messageBox"; @@ -367,6 +368,14 @@ function setupApplicationMenu() { { label: mainT("common", "actions.view") || "View", submenu: [ + ...(isMac + ? [ + { + label: mainT("common", "actions.showCameraPreview"), + click: () => showCameraPreview(true), + }, + ] + : []), { role: "reload", label: mainT("common", "actions.reload") || "Reload", @@ -1076,6 +1085,7 @@ function createEditorWindowWrapper() { mainWindow = null; } mainWindow = createEditorWindow(); + showCameraPreview(); editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { @@ -1166,14 +1176,17 @@ if (!cliCommand) { app.on("child-process-gone", (_event, details) => { console.error(describeChildProcessGone(details)); }); - // The countdown overlay hides between takes instead of closing, so it would keep - // `window-all-closed` from ever firing (#961). Close it with the last other window. Every + // The countdown and camera preview hide between uses instead of closing, so they keep + // `window-all-closed` from ever firing (#961). Close them with the last other window. Every // HUD/editor switch opens the next window before the previous one's `closed` arrives, so // this only fires when the user really closed the last one. app.on("browser-window-created", (_, win) => { win.once("closed", () => { - if (isOnlyLingeringOverlay(BrowserWindow.getAllWindows(), countdownOverlayWindow)) { - countdownOverlayWindow?.close(); + const overlays = [countdownOverlayWindow, getCameraPreviewWindow()]; + if (onlyLingeringOverlays(BrowserWindow.getAllWindows(), overlays)) { + for (const overlay of overlays) { + if (overlay && !overlay.isDestroyed()) overlay.close(); + } } }); }); diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 1450f130d..db815da5c 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -217,7 +217,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } else { let content = try await SCShareableContent.excludingDesktopWindows( false, - onScreenWindowsOnly: true + // Hidden camera previews must stay excluded when shown during a take. + onScreenWindowsOnly: false ) target = try makeCaptureTarget(from: content) if recordsSystemAudioFromDisplay, let display = content.displays.first { @@ -671,7 +672,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { guard let self, let stream = self.stream, let content = try? await SCShareableContent.excludingDesktopWindows( false, - onScreenWindowsOnly: true + // Hidden camera previews must stay excluded when shown during a take. + onScreenWindowsOnly: false ), let display = content.displays.first(where: { $0.displayID == displayID }) else { diff --git a/electron/preload.ts b/electron/preload.ts index 23160bcaa..5361afb71 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -178,6 +178,20 @@ contextBridge.exposeInMainWorld("electronAPI", { getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + getCameraPreviewState: () => ipcRenderer.invoke("camera-preview-state"), + onCameraPreviewState: ( + callback: (state: import("../src/lib/cameraPreview").CameraPreviewState) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + state: import("../src/lib/cameraPreview").CameraPreviewState, + ) => callback(state); + ipcRenderer.on("camera-preview-state", listener); + return () => ipcRenderer.removeListener("camera-preview-state", listener); + }, + hideCameraPreview: () => ipcRenderer.invoke("camera-preview-hide"), + transformCameraPreview: (command: import("../src/lib/cameraPreview").CameraPreviewCommand) => + ipcRenderer.invoke("camera-preview-transform", command), getRecordingPrefs: () => { return ipcRenderer.invoke("get-recording-prefs"); }, diff --git a/electron/windows.ts b/electron/windows.ts index 8db7d9cd4..69a78f8ff 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -933,3 +933,42 @@ export function createRunnerWindow(windowType: string): BrowserWindow { } return win; } + +/** macOS native capture excludes this stable window ID even while it is hidden. */ +export function createCameraPreviewWindow(): BrowserWindow { + const area = screen.getPrimaryDisplay().workArea; + const win = new BrowserWindow({ + x: area.x + 24, + y: area.y + area.height - 290, + width: 368, + height: 214, + frame: false, + transparent: true, + hasShadow: false, + resizable: false, + maximizable: false, + fullscreenable: false, + minimizable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + title: "OpenScreen - Camera", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + applyContentProtection(win, "Camera preview"); + followAcrossSpaces(win); + if (VITE_DEV_SERVER_URL) { + void win.loadURL(VITE_DEV_SERVER_URL + "?windowType=camera-preview"); + } else { + void win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "camera-preview" }, + }); + } + return win; +} diff --git a/src/App.tsx b/src/App.tsx index 681bb5993..efc8de72e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { installBrowserShims } from "./native/browserShim"; installBrowserShims(); +import { CameraPreviewWindow } from "./components/launch/CameraPreviewWindow"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { NotesWindow } from "./components/launch/NotesWindow.tsx"; @@ -52,7 +53,12 @@ export default function App() { setWindowType(type); } - if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") { + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "countdown-overlay" || + type === "camera-preview" + ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); @@ -60,7 +66,7 @@ export default function App() { // HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow // so the renderer can't introduce scrollbars (see issue #305). - if (type === "hud-overlay") { + if (type === "hud-overlay" || type === "camera-preview") { document.documentElement.style.height = "100%"; document.documentElement.style.overflow = "hidden"; document.body.style.height = "100%"; @@ -83,6 +89,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.module.css b/src/components/launch/CameraPreviewWindow.module.css new file mode 100644 index 000000000..8205f76c4 --- /dev/null +++ b/src/components/launch/CameraPreviewWindow.module.css @@ -0,0 +1,42 @@ + +.shell { height: 100%; padding: 8px; box-sizing: border-box; user-select: none; } +.camera { height: 100%; position: relative; border-radius: 22px; background: #101012; + box-shadow: 0 2px 7px #0005; color: white; touch-action: none; } +.picture { position: absolute; inset: 0; border-radius: inherit; overflow: hidden; } +.picture::after { content: ""; position: absolute; inset: 0; border-radius: inherit; + box-shadow: inset 0 0 0 1px #ffffff24; pointer-events: none; } +.picture video { display: block; width: 100%; height: 100%; object-fit: contain; + transform: scaleX(-1); pointer-events: none; } +.message { position: absolute; inset: 0; align-content: center; padding: 16px; + text-align: center; color: #bfc0c4; font-size: 12px; } +.camera button { position: absolute; display: grid; place-items: center; border: 1px solid #ffffff26; + background: #171719aa; color: white; backdrop-filter: blur(12px); outline: none; touch-action: none; } +.camera button:focus-visible { box-shadow: 0 0 0 2px white; } +.camera button:hover { background: #27272cdd; } +.camera .surface { inset: 0; width: 100%; height: 100%; background: transparent; + border: none; border-radius: inherit; cursor: grab; backdrop-filter: none; } +.camera .surface:hover { background: transparent; } +.camera .surface:active, .move:active { cursor: grabbing; } +.controls, .corner { opacity: 0; transition: opacity 140ms ease; } +.camera:hover .controls, .camera:hover .corner, +.camera:focus-within .controls, .camera:focus-within .corner, +.adjusting .controls, .adjusting .corner { opacity: 1; } +.controls { position: absolute; inset: 0; pointer-events: none; } +.controls button { pointer-events: auto; } +.move { top: 9px; left: 50%; transform: translateX(-50%); width: 40px; height: 24px; + border-radius: 12px; cursor: grab; } +.hide { top: 9px; right: 9px; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; } +.camera .corner { z-index: 2; width: 23px; height: 23px; padding: 0; border: none; + background: transparent; backdrop-filter: none; filter: drop-shadow(0 1px 2px #000a); } +.corner::before { content: ""; position: absolute; width: 12px; height: 12px; + border: 2px solid #fffffff0; } +.camera .corner:hover { background: transparent; } +.nw { top: -6px; left: -6px; cursor: nwse-resize; } +.ne { top: -6px; right: -6px; cursor: nesw-resize; } +.sw { bottom: -6px; left: -6px; cursor: nesw-resize; } +.se { bottom: -6px; right: -6px; cursor: nwse-resize; } +.nw::before { top: 5px; left: 5px; border-right: 0; border-bottom: 0; border-radius: 5px 0 0 0; } +.ne::before { top: 5px; right: 5px; border-left: 0; border-bottom: 0; border-radius: 0 5px 0 0; } +.sw::before { bottom: 5px; left: 5px; border-right: 0; border-top: 0; border-radius: 0 0 0 5px; } +.se::before { bottom: 5px; right: 5px; border-left: 0; border-top: 0; border-radius: 0 0 5px 0; } +@media (prefers-reduced-motion: reduce) { .controls, .corner { transition: none; } } diff --git a/src/components/launch/CameraPreviewWindow.test.tsx b/src/components/launch/CameraPreviewWindow.test.tsx new file mode 100644 index 000000000..b9fca0a69 --- /dev/null +++ b/src/components/launch/CameraPreviewWindow.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CameraPreviewState } from "../../lib/cameraPreview"; +import { CameraPreviewWindow } from "./CameraPreviewWindow"; + +const mocks = vi.hoisted(() => ({ + cameras: vi.fn(), + stream: vi.fn(), + hide: vi.fn(), + transform: vi.fn(), +})); +vi.mock("../../contexts/I18nContext", () => ({ useScopedT: () => (key: string) => key })); +vi.mock("../../hooks/useCameraDevices", () => ({ useCameraDevices: mocks.cameras })); +vi.mock("../../hooks/useCameraPreviewStream", () => ({ useCameraPreviewStream: mocks.stream })); +let publish: (state: CameraPreviewState) => void; +let resolveInitial: (state: CameraPreviewState) => void; +let unsubscribe: ReturnType; +beforeEach(() => { + vi.clearAllMocks(); + mocks.cameras.mockReturnValue({ isReady: false, selectedDeviceId: "", error: null }); + mocks.stream.mockReturnValue({ stream: null, error: null }); + unsubscribe = vi.fn(); + vi.stubGlobal("electronAPI", undefined); + Object.defineProperty(window, "electronAPI", { + configurable: true, + value: { + getCameraPreviewState: () => + new Promise((resolve) => { + resolveInitial = resolve; + }), + onCameraPreviewState: (callback: (state: CameraPreviewState) => void) => { + publish = callback; + return unsubscribe; + }, + hideCameraPreview: mocks.hide, + transformCameraPreview: mocks.transform, + }, + }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +describe("floating camera preview", () => { + it("waits for the live camera identity and ignores a stale initial IPC snapshot", async () => { + render(); + await act(async () => { + publish({ enabled: true, deviceId: "new-id", deviceName: "Chosen camera" }); + resolveInitial({ enabled: false, deviceId: "old-id" }); + }); + expect(mocks.cameras).toHaveBeenLastCalledWith(true, "new-id", "Chosen camera"); + expect(mocks.stream).toHaveBeenLastCalledWith({ enabled: false, deviceId: undefined }); + mocks.cameras.mockReturnValue({ isReady: true, selectedDeviceId: "resolved-id", error: null }); + await act(async () => { + publish({ enabled: true, deviceId: "new-id", deviceName: "Chosen camera" }); + }); + expect(mocks.stream).toHaveBeenLastCalledWith({ enabled: true, deviceId: "resolved-id" }); + }); + it("hides independently of the recording and exposes keyboard resize controls", () => { + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "preview.hide" })); + expect(mocks.hide).toHaveBeenCalledOnce(); + fireEvent.keyDown(screen.getByRole("button", { name: "preview.se" }), { key: "ArrowRight" }); + expect(mocks.transform).toHaveBeenCalledWith({ + action: "nudge", + direction: "se", + dx: 5, + dy: 0, + }); + view.unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + it("shows a failure rather than waiting forever when no camera is connected", async () => { + mocks.cameras.mockReturnValue({ isReady: true, selectedDeviceId: "", error: null }); + render(); + await act(async () => { + resolveInitial({ enabled: true }); + }); + expect(screen.getByRole("status").textContent).toBe("webcam.unavailable"); + expect(mocks.stream).toHaveBeenLastCalledWith({ enabled: false, deviceId: undefined }); + }); +}); diff --git a/src/components/launch/CameraPreviewWindow.tsx b/src/components/launch/CameraPreviewWindow.tsx new file mode 100644 index 000000000..b6ae058e0 --- /dev/null +++ b/src/components/launch/CameraPreviewWindow.tsx @@ -0,0 +1,146 @@ +import { GripHorizontal, X } from "lucide-react"; +import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"; +import { useScopedT } from "../../contexts/I18nContext"; +import { useCameraDevices } from "../../hooks/useCameraDevices"; +import { useCameraPreviewStream } from "../../hooks/useCameraPreviewStream"; +import type { CameraPreviewState, CameraPreviewTransform } from "../../lib/cameraPreview"; +import styles from "./CameraPreviewWindow.module.css"; + +const corners = ["nw", "ne", "sw", "se"] as const; +export function CameraPreviewWindow() { + const t = useScopedT("launch"); + const [state, setState] = useState({ enabled: false }); + const [adjusting, setAdjusting] = useState(false); + const video = useRef(null); + const dragging = useRef(false); + const cameras = useCameraDevices(state.enabled, state.deviceId, state.deviceName); + const { stream, error } = useCameraPreviewStream({ + enabled: state.enabled && cameras.isReady && !!cameras.selectedDeviceId, + deviceId: cameras.selectedDeviceId || undefined, + }); + useEffect(() => { + let active = true; + let revision = 0; + const unsubscribe = window.electronAPI.onCameraPreviewState?.((next) => { + revision++; + if (active) setState(next); + }); + // Subscribe first, and never overwrite an event with an older invoke response. + const initialRevision = revision; + void window.electronAPI.getCameraPreviewState?.().then((next) => { + if (active && next && initialRevision === revision) setState(next); + }); + return () => { + active = false; + unsubscribe?.(); + }; + }, []); + useEffect(() => { + const element = video.current; + if (!element) return; + element.srcObject = stream; + return () => { + element.srcObject = null; + }; + }, [stream]); + const end = () => { + if (!dragging.current) return; + dragging.current = false; + setAdjusting(false); + void window.electronAPI.transformCameraPreview?.({ action: "end" }); + }; + const begin = (event: PointerEvent, direction: CameraPreviewTransform) => { + if (event.button !== 0) return; + event.preventDefault(); + dragging.current = true; + setAdjusting(true); + event.currentTarget.setPointerCapture(event.pointerId); + void window.electronAPI.transformCameraPreview?.({ action: "begin", direction }); + }; + const nudge = (event: KeyboardEvent, direction: CameraPreviewTransform) => { + const step = event.shiftKey ? 20 : 5; + const deltas: Record = { + ArrowLeft: [-step, 0], + ArrowRight: [step, 0], + ArrowUp: [0, -step], + ArrowDown: [0, step], + }; + const delta = deltas[event.key]; + if (!delta) return; + event.preventDefault(); + void window.electronAPI.transformCameraPreview?.({ + action: "nudge", + direction, + dx: delta[0], + dy: delta[1], + }); + }; + return ( +
+
{ + if (dragging.current) + void window.electronAPI.transformCameraPreview?.({ action: "update" }); + }} + onPointerUp={end} + onPointerCancel={end} + onLostPointerCapture={end} + > +
+
+ + +
+ {corners.map((corner) => ( +