From ef9fbcfde7b32b79d1b033a01b345e33eda98996 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 7 Oct 2026 11:53:04 +0200 Subject: [PATCH 1/3] fix(record): never record a microphone the OS does not list (#995) The HUD and Record mode microphone toggles now refuse to turn on when no audio input is listed, as the camera toggle does, and every take resolves the microphone against the listed inputs before building its capture request, so a stored "on" cannot reach the native helper. --- .../ai-edition/v4/RecStage.test.tsx | 39 ++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 18 +++++- src/components/launch/LaunchWindow.test.tsx | 32 +++++++++- src/components/launch/LaunchWindow.tsx | 19 ++++-- ...eenRecorder.nativeMacStartWarning.test.tsx | 33 +++++++++++ src/hooks/useScreenRecorder.ts | 47 +++++++++++---- src/lib/microphoneAvailability.ts | 59 +++++++++++++++++++ 7 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 src/lib/microphoneAvailability.ts diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 47ac87e05..8e6264c7c 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -121,6 +121,14 @@ function stubCameras(devices: Array>) { }); } +/** The OS listing these devices, for the microphone check. */ +function stubMediaDevices(devices: Array>) { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => devices) }, + }); +} + function renderRecStage() { const onStartRecording = vi.fn(); const view = render( @@ -267,6 +275,36 @@ describe("RecStage controls", () => { expect(toast.error).not.toHaveBeenCalled(); }); + // The microphone row stored "on" with no input at all, beside its own "No microphone + // found", and the take after a Restart never started (#995). + it("keeps the microphone off, and never stores it on, when the OS lists no input", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "videoinput", deviceId: "cam-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(pill("rec.microphone")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("rec.noMicrophoneFound")); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + + it("turns a listed microphone on, and stores it", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + + await waitFor(() => expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "true")); + expect(setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); + expect(toast.error).not.toHaveBeenCalled(); + }); + it("runs one camera check for a double click", async () => { const { getRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); stubCameras([]); @@ -447,6 +485,7 @@ describe("RecStage controls", () => { it("ties microphone discovery to the toggle so off then on requests a retry", async () => { stubRecordingPrefs({ micEnabled: true }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); renderRecStage(); await screen.findByText("rec.noMicrophoneFound"); const toggle = pill("rec.microphone"); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 5888be49f..dd2f3bb32 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -23,6 +23,7 @@ import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; +import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -149,6 +150,21 @@ export function RecStage({ }); }; + // The same check as the HUD toggle: no input listed, never stored as on (#995). + const toggleMicrophone = () => { + if (prefs.micEnabled) { + updatePrefs({ micEnabled: false }); + return; + } + void canTurnMicrophoneOn(t) + .then((ok) => { + if (ok) updatePrefs({ micEnabled: true }); + }) + .catch((err) => { + console.warn("[rec-stage] microphone check failed:", err); + }); + }; + const micDevices = useMicrophoneDevices( prefs.micEnabled, prefs.micDeviceId ?? undefined, @@ -438,7 +454,7 @@ export function RecStage({ className={`${styles.recToggleBtn}${prefs.micEnabled ? ` ${styles.on}` : ""}`} aria-labelledby={rowLabelId("microphone")} aria-pressed={prefs.micEnabled} - onClick={() => updatePrefs({ micEnabled: !prefs.micEnabled })} + onClick={toggleMicrophone} > {prefs.micEnabled ? t("rec.on") : t("rec.off")} diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 9036bf774..688a05aa6 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -1606,10 +1606,36 @@ describe("LaunchWindow device buttons", () => { fireEvent.click(await screen.findByTestId("launch-microphone-button")); - expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); + }); expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); }); + // The camera toggle refuses a camera the OS does not list (#967); the microphone let a Mac + // with no input turn it on, and the take after a Restart never started (#995). + it("keeps the microphone off, and never stores it on, when the OS lists no input (#995)", async () => { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => [{ kind: "videoinput", deviceId: "cam-1" }]), + }, + }); + try { + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-microphone-button")); + + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: false }); + }); + expect(recorderState.value.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + } finally { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + }); + it("turns the microphone off with a single click when it is already on", async () => { recorderState.value.microphoneEnabled = true; @@ -1647,8 +1673,10 @@ describe("LaunchWindow device buttons", () => { fireEvent.click(await screen.findByTestId("launch-microphone-button")); + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); + }); expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); }); it("persists turning the microphone off", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 715a941b1..3728a9b58 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { offersEditableCursor } from "@/lib/editableCursorAvailability"; +import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; import { canRecordMicrophone } from "@/utils/platformUtils"; @@ -78,6 +79,8 @@ export function LaunchWindow() { // The update-check label is shared with the app menu and the tray, which read it from // `common`. A second copy under `launch` drifted from it in en and ar before it ever shipped. const tCommon = useScopedT("common"); + // The microphone check's toast is worded where the camera check's is: in `editor`. + const tEditor = useScopedT("editor"); const { locale, setLocale, @@ -891,12 +894,20 @@ export function LaunchWindow() { persistRecordingPrefs({ cursorCaptureMode: next }); }, [controlsLocked, cursorCaptureMode, persistRecordingPrefs, setCursorCaptureMode]); + // Refuses a microphone the OS does not list, as the camera toggle does (#995). const toggleMicrophone = useCallback(() => { if (controlsLocked) return; - const next = !microphoneEnabled; - setMicrophoneEnabled(next); - persistRecordingPrefs({ micEnabled: next }); - }, [controlsLocked, microphoneEnabled, persistRecordingPrefs, setMicrophoneEnabled]); + if (microphoneEnabled) { + setMicrophoneEnabled(false); + persistRecordingPrefs({ micEnabled: false }); + return; + } + void canTurnMicrophoneOn(tEditor).then((ok) => { + if (!ok) return; + setMicrophoneEnabled(true); + persistRecordingPrefs({ micEnabled: true }); + }); + }, [controlsLocked, microphoneEnabled, persistRecordingPrefs, setMicrophoneEnabled, tEditor]); const toggleWebcam = useCallback(() => { if (controlsLocked) return; diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 89158eb72..2fc860d77 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -140,6 +140,39 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(view.result.current.recording).toBe(true); }); + // A Mac with no audio input recorded the first take with the microphone "on", and the + // take after a Restart never started (#995). A stored "on" must not reach the helper. + it("never asks the helper for a microphone the OS does not list, on Restart either", async () => { + api.setRecordingPrefs = vi.fn(async () => undefined); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => []) }, + }); + try { + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + expect(view.result.current.recording).toBe(true); + + await act(async () => { + await view.result.current.restartRecording(); + }); + await settle(); + + expect(api.startNativeMacRecording).toHaveBeenCalledTimes(2); + for (const [request] of api.startNativeMacRecording.mock.calls) { + expect(request.audio.microphone.enabled).toBe(false); + } + expect(api.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: false }); + } finally { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + }); + // macOS 13 and 14 have no `captureMicrophone`: a saved "mic on" would ask for the // microphone and record a take without it (#700). it("does not apply a saved microphone on macOS 14", async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c20170c15..5feea0ace 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { mixAudioTracks, nativeMicrophoneGain } from "@/lib/audioMix"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; +import { shouldRecordMicrophone } from "@/lib/microphoneAvailability"; import { type NativeLinuxRecordingRequest, portalOwnsSourceSelection, @@ -1179,6 +1180,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startNativeWindowsRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, + recordMicrophone: boolean, countdownRunToken?: number, ) => { try { @@ -1245,7 +1247,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: systemAudioEnabled, }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, // Boosted only when the mic has to sit over system audio. @@ -1308,6 +1310,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startNativeMacRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, + recordMicrophone: boolean, countdownRunToken?: number, ) => { try { @@ -1406,7 +1409,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: systemAudioEnabled, }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, // Boosted only over system audio, like the Windows request above. @@ -1494,7 +1497,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { * is the one negotiated at the start, so a divergence in audio or cursor * settings would record something the second call never asked for. */ - const buildNativeLinuxRequest = (recordingId?: number): NativeLinuxRecordingRequest => ({ + const buildNativeLinuxRequest = ( + recordMicrophone: boolean, + recordingId?: number, + ): NativeLinuxRecordingRequest => ({ ...(recordingId === undefined ? {} : { recordingId }), video: { // No bitrate on purpose. TARGET_WIDTH/HEIGHT are the app's 4K ceiling, @@ -1507,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { audio: { system: { enabled: systemAudioEnabled }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, // The device LABEL, not the id. Chromium's deviceId is an opaque // per-origin hash that means nothing to PipeWire, whereas on a // PipeWire system the label IS the node's `node.description` — which @@ -1524,6 +1530,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); const startNativeLinuxRecordingIfAvailable = async ( + recordMicrophone: boolean, countdownRunToken?: number, preparedRecordingId?: number | null, ) => { @@ -1580,7 +1587,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const result = await window.electronAPI.startNativeLinuxRecording( - buildNativeLinuxRequest(activeRecordingId), + buildNativeLinuxRequest(recordMicrophone, activeRecordingId), ); if (!result.success || !result.recordingId) { throw new Error(result.error ?? "Native Linux capture failed."); @@ -1695,7 +1702,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (portalOwnsSource) { try { const prepared = await window.electronAPI.prepareNativeLinuxRecording( - buildNativeLinuxRequest(), + buildNativeLinuxRequest(await shouldRecordMicrophone(microphoneEnabled)), ); if (prepared.success && typeof prepared.recordingId === "number") { preparedRecordingId = prepared.recordingId; @@ -1785,6 +1792,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); return; } + // Resolved once, for whichever path records the take (#995). + const recordMicrophone = await shouldRecordMicrophone(microphoneEnabled); // BEFORE THE SOURCE GATE, on purpose. On Wayland the portal raises its // own picker and is the only thing that can choose a source, so there @@ -1793,7 +1802,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // question this platform never asks the app. It returns false when the // native helper is missing, and the browser fallback below does need a // source, so the gate still guards the path that uses one. - if (await startNativeLinuxRecordingIfAvailable(countdownRunToken, preparedRecordingId)) { + if ( + await startNativeLinuxRecordingIfAvailable( + recordMicrophone, + countdownRunToken, + preparedRecordingId, + ) + ) { return; } @@ -1803,10 +1818,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { + if ( + await startNativeWindowsRecordingIfAvailable( + selectedSource, + recordMicrophone, + countdownRunToken, + ) + ) { return; } - if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { + if ( + await startNativeMacRecordingIfAvailable( + selectedSource, + recordMicrophone, + countdownRunToken, + ) + ) { return; } @@ -1867,7 +1894,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } as unknown as MediaStreamConstraints); })(); - const micCapture: Promise = microphoneEnabled + const micCapture: Promise = recordMicrophone ? (async () => { try { return await navigator.mediaDevices.getUserMedia({ diff --git a/src/lib/microphoneAvailability.ts b/src/lib/microphoneAvailability.ts new file mode 100644 index 000000000..b7efe1ba1 --- /dev/null +++ b/src/lib/microphoneAvailability.ts @@ -0,0 +1,59 @@ +import { toast } from "sonner"; + +/** + * Whether the OS lists any audio input. A Mac with no microphone still records a take + * that asks for one, then fails the next take in the same picker session (#995). A failed + * enumeration answers `true`: not knowing is not "none". + */ +async function hasMicrophoneDevice(): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some((device) => device.kind === "audioinput"); + } catch { + return true; + } +} + +/** The toggles store nothing on failure, so this clears an "on" left by an input now gone. */ +function forgetMicrophone() { + void window.electronAPI?.setRecordingPrefs?.({ micEnabled: false }).catch((error) => { + console.warn("Failed to persist the microphone preference:", error); + }); +} + +let pendingCheck: Promise | null = null; + +/** + * Whether the microphone may be switched on, with a toast saying why not: the camera's + * `canTurnCameraOn`, for the HUD toggle and the editor's Record mode alike. A call made + * while one is pending, a double click, shares its answer and its one toast. + * + * @param t The `editor` namespace translator. + */ +export function canTurnMicrophoneOn(t: (key: string) => string): Promise { + pendingCheck ??= hasMicrophoneDevice() + .then((found) => { + if (!found) { + toast.error(t("rec.noMicrophoneFound")); + forgetMicrophone(); + } + return found; + }) + .finally(() => { + pendingCheck = null; + }); + return pendingCheck; +} + +/** + * Whether a take records the microphone: switched on, and an input listed. A stored "on" + * the toggle never checked (an older build, another Mac, an input unplugged since) must + * not reach the capture helper (#995), and is cleared so the toggles show it off. + */ +export async function shouldRecordMicrophone(enabled: boolean): Promise { + if (!enabled) return false; + if (await hasMicrophoneDevice()) return true; + console.warn("No microphone is listed; recording without it."); + forgetMicrophone(); + return false; +} From b246cb39bcd5887bbb33578b3270325f46ef91e4 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 7 Oct 2026 12:11:44 +0200 Subject: [PATCH 2/3] fix(record): let a second click during the microphone check take the first back --- src/components/ai-edition/v4/RecStage.test.tsx | 14 ++++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 12 +++++++++++- src/components/launch/LaunchWindow.tsx | 13 +++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 8e6264c7c..ca3d7c3e7 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -305,6 +305,20 @@ describe("RecStage controls", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("lets a second click during the microphone check take the first one back", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(pill("rec.microphone")); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + it("runs one camera check for a double click", async () => { const { getRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); stubCameras([]); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index dd2f3bb32..c34ac79b2 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -151,16 +151,26 @@ export function RecStage({ }; // The same check as the HUD toggle: no input listed, never stored as on (#995). + // A second click while the check runs takes the first one back: the last click wins. + const micTurningOn = useRef(false); const toggleMicrophone = () => { if (prefs.micEnabled) { updatePrefs({ micEnabled: false }); return; } + if (micTurningOn.current) { + micTurningOn.current = false; + return; + } + micTurningOn.current = true; void canTurnMicrophoneOn(t) .then((ok) => { - if (ok) updatePrefs({ micEnabled: true }); + const wanted = micTurningOn.current; + micTurningOn.current = false; + if (ok && wanted) updatePrefs({ micEnabled: true }); }) .catch((err) => { + micTurningOn.current = false; console.warn("[rec-stage] microphone check failed:", err); }); }; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3728a9b58..b803eba0d 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -894,7 +894,9 @@ export function LaunchWindow() { persistRecordingPrefs({ cursorCaptureMode: next }); }, [controlsLocked, cursorCaptureMode, persistRecordingPrefs, setCursorCaptureMode]); - // Refuses a microphone the OS does not list, as the camera toggle does (#995). + // Refuses a microphone the OS does not list, as the camera toggle does (#995). A second + // click while the check runs takes the first one back: the last click wins. + const micTurningOn = useRef(false); const toggleMicrophone = useCallback(() => { if (controlsLocked) return; if (microphoneEnabled) { @@ -902,8 +904,15 @@ export function LaunchWindow() { persistRecordingPrefs({ micEnabled: false }); return; } + if (micTurningOn.current) { + micTurningOn.current = false; + return; + } + micTurningOn.current = true; void canTurnMicrophoneOn(tEditor).then((ok) => { - if (!ok) return; + const wanted = micTurningOn.current; + micTurningOn.current = false; + if (!ok || !wanted) return; setMicrophoneEnabled(true); persistRecordingPrefs({ micEnabled: true }); }); From 9f27960add2a094f9f49821061751c2fffcf2875 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 7 Oct 2026 12:24:18 +0200 Subject: [PATCH 3/3] fix(record): drop a pending microphone turn-on once the take starts --- src/components/ai-edition/v4/RecStage.test.tsx | 14 ++++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 10 +++++++++- src/components/launch/LaunchWindow.tsx | 4 ++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index ca3d7c3e7..7ba2d33c3 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -305,6 +305,20 @@ describe("RecStage controls", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("never turns the microphone on after Start recording, when its check ends late", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + const { onStartRecording } = renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(screen.getByRole("button", { name: /rec\.startRecording/ })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onStartRecording).toHaveBeenCalled(); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + it("lets a second click during the microphone check take the first one back", async () => { const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index c34ac79b2..9a87698f8 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -596,7 +596,15 @@ export function RecStage({ {t("rec.cancel")} ) : null} - diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b803eba0d..4a310e07d 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -897,6 +897,10 @@ export function LaunchWindow() { // Refuses a microphone the OS does not list, as the camera toggle does (#995). A second // click while the check runs takes the first one back: the last click wins. const micTurningOn = useRef(false); + // A take that starts while the check runs was decided without the microphone: keep it so. + useEffect(() => { + if (controlsLocked) micTurningOn.current = false; + }, [controlsLocked]); const toggleMicrophone = useCallback(() => { if (controlsLocked) return; if (microphoneEnabled) {