diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index b79308df3..caee37c8e 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { RecStage } from "./RecStage"; @@ -9,6 +10,10 @@ vi.mock("@/contexts/I18nContext", () => ({ useScopedT: () => (key: string) => key, })); +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + // Whether the native helper can leave the system cursor out of the pixels. Its own answer (and // the HUD's) is tested with the function it calls; here it is a switch. const editableCursor = vi.hoisted(() => ({ available: true })); @@ -105,6 +110,17 @@ function stubRecordingPrefs( return { getRecordingPrefs, setRecordingPrefs }; } +/** Camera access granted, and the OS listing these devices. Call after `stubRecordingPrefs`. */ +function stubCameras(devices: Array>) { + Object.assign(window.electronAPI as object, { + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), + }); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => devices) }, + }); +} + function renderRecStage() { const onStartRecording = vi.fn(); const view = render( @@ -221,6 +237,48 @@ describe("RecStage controls", () => { expect(button).toHaveAttribute("aria-pressed", "false"); }); + // The HUD toggle refuses a camera the OS does not list (#967). This row stored it as on + // anyway, beside its own "No camera found" (#998). + it("keeps the camera off, and never stores it on, when the OS lists no camera", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound")); + expect(pill("rec.camera")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ camEnabled: true }); + }); + + it("turns a listed camera on, and stores it", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([{ kind: "videoinput", deviceId: "cam-1", label: "FaceTime HD Camera" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(pill("rec.camera")).toHaveAttribute("aria-pressed", "true")); + expect(setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: true }); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("runs one camera check for a double click", async () => { + const { getRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound")); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(window.electronAPI?.requestCameraAccess).toHaveBeenCalledTimes(1); + }); + // A settings file written before the preference existed has no key, and every such // installation has been getting auto-zoom — so absent must read as on, not off. it("defaults on when a stored prefs blob has no autoZoomEnabled key", async () => { diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 658ed6b4d..5888be49f 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -22,6 +22,7 @@ import { useCameraPreviewStream } from "@/hooks/useCameraPreviewStream"; import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; +import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -133,6 +134,21 @@ export function RecStage({ }); }; + // Through the HUD toggle's own check: a camera that cannot be opened is never stored as on. + const toggleCamera = () => { + if (prefs.camEnabled) { + updatePrefs({ camEnabled: false }); + return; + } + void canTurnCameraOn(t) + .then((ok) => { + if (ok) updatePrefs({ camEnabled: true }); + }) + .catch((err) => { + console.warn("[rec-stage] camera check failed:", err); + }); + }; + const micDevices = useMicrophoneDevices( prefs.micEnabled, prefs.micDeviceId ?? undefined, @@ -471,7 +487,7 @@ export function RecStage({ className={`${styles.recToggleBtn}${prefs.camEnabled ? ` ${styles.on}` : ""}`} aria-labelledby={rowLabelId("camera")} aria-pressed={prefs.camEnabled} - onClick={() => updatePrefs({ camEnabled: !prefs.camEnabled })} + onClick={toggleCamera} > {prefs.camEnabled ? t("rec.on") : t("rec.off")} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 915fcf959..c20170c15 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { mixAudioTracks, nativeMicrophoneGain } from "@/lib/audioMix"; +import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { type NativeLinuxRecordingRequest, portalOwnsSourceSelection, @@ -17,7 +18,6 @@ import { parseWindowHandleFromSourceId, } from "@/lib/nativeWindowsRecording"; import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; -import { requestCameraAccess } from "@/lib/requestCameraAccess"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { canRecordMicrophone } from "@/utils/platformUtils"; import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; @@ -156,21 +156,6 @@ type NativeLinuxRecordingHandle = { webcamOffsetMs: number | null; }; -/** - * Whether the OS lists any camera at all. A Mac with no camera still grants - * camera access, so the permission check alone lets the toggle report success - * for a camera that can never open (#967). A failed enumeration answers `true`: - * not knowing is not "none", and the acquire reports the real failure. - */ -async function hasCameraDevice(): Promise { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.some((device) => device.kind === "videoinput"); - } catch { - return true; - } -} - /** * How far AHEAD of the native screen recording the browser-recorded webcam * started, in whole milliseconds (negative, since the webcam always starts @@ -491,24 +476,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } - const accessResult = await requestCameraAccess(); - if (!accessResult.success) { - toast.error(t("recording.failedCameraAccess")); - return false; - } - - if (!accessResult.granted) { - toast.error(t("recording.cameraBlocked")); - return false; - } - - if (!(await hasCameraDevice())) { - toast.error(t("recording.cameraNotFound")); - // The toggle stores nothing on failure, so clear an "on" left behind by a - // camera that was unplugged while it was in use. - void window.electronAPI?.setRecordingPrefs?.({ camEnabled: false }).catch((error) => { - console.warn("Failed to persist the camera preference:", error); - }); + if (!(await canTurnCameraOn(t))) { return false; } diff --git a/src/lib/cameraAvailability.ts b/src/lib/cameraAvailability.ts new file mode 100644 index 000000000..075f62de7 --- /dev/null +++ b/src/lib/cameraAvailability.ts @@ -0,0 +1,61 @@ +import { toast } from "sonner"; +import { requestCameraAccess } from "./requestCameraAccess"; + +/** + * Whether the OS lists any camera at all. A Mac with no camera still grants + * camera access, so the permission check alone lets the toggle report success + * for a camera that can never open (#967). A failed enumeration answers `true`: + * not knowing is not "none", and the acquire reports the real failure. + */ +async function hasCameraDevice(): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some((device) => device.kind === "videoinput"); + } catch { + return true; + } +} + +let pendingCheck: Promise | null = null; + +/** + * Whether the camera may be switched on, with a toast saying why not. + * + * The one check for every surface that turns the camera on (the HUD toggle, and the editor's + * Record mode), so neither stores `camEnabled: true` for a camera the other refuses (#998). + * A call made while one is pending, a double click, shares its answer: one access request, + * one toast. + * + * @param t The `editor` namespace translator. + */ +export function canTurnCameraOn(t: (key: string) => string): Promise { + pendingCheck ??= checkCameraCanTurnOn(t).finally(() => { + pendingCheck = null; + }); + return pendingCheck; +} + +async function checkCameraCanTurnOn(t: (key: string) => string): Promise { + const accessResult = await requestCameraAccess(); + if (!accessResult.success) { + toast.error(t("recording.failedCameraAccess")); + return false; + } + + if (!accessResult.granted) { + toast.error(t("recording.cameraBlocked")); + return false; + } + + if (!(await hasCameraDevice())) { + toast.error(t("recording.cameraNotFound")); + // The toggles store nothing on failure, so clear an "on" left behind by a + // camera that was unplugged while it was in use. + void window.electronAPI?.setRecordingPrefs?.({ camEnabled: false }).catch((error) => { + console.warn("Failed to persist the camera preference:", error); + }); + return false; + } + + return true; +}