From 20b0899c053478becda870debc1b7f91abadf6ee Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 01:37:27 -0500 Subject: [PATCH 1/5] fix: camera light flashes and turns off when clicking webcam button (#308) --- src/components/launch/LaunchWindow.tsx | 1 + src/hooks/useScreenRecorder.ts | 93 +++++++++++++++++--------- src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/lib/requestCameraAccess.ts | 4 +- 6 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b85..8fd89342 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -436,6 +436,7 @@ export function LaunchWindow() { onClick={async () => { await setWebcamEnabled(!webcamEnabled); }} + disabled={recording} title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")} > {webcamEnabled diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0c418c17..ba95e60f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -145,10 +145,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current.getTracks().forEach((track) => track.stop()); microphoneStream.current = null; } - if (webcamStream.current) { - webcamStream.current.getTracks().forEach((track) => track.stop()); - webcamStream.current = null; - } if (mixingContext.current) { mixingContext.current.close().catch(() => { // Ignore close errors during recorder teardown. @@ -181,6 +177,66 @@ export function useScreenRecorder(): UseScreenRecorderReturn { [t], ); + useEffect(() => { + if (!webcamEnabled) return; + + let cancelled = false; + let acquiredStream: MediaStream | null = null; + + const acquire = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: WEBCAM_TARGET_WIDTH }, + height: { ideal: WEBCAM_TARGET_HEIGHT }, + frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, + } + : { + width: { ideal: WEBCAM_TARGET_WIDTH }, + height: { ideal: WEBCAM_TARGET_HEIGHT }, + frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, + }, + }); + + if (cancelled) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + acquiredStream = stream; + stream.getVideoTracks().forEach((track) => { + track.onended = () => { + webcamStream.current = null; + if (!restarting.current) { + setWebcamEnabledState(false); + toast.error(t("recording.cameraDisconnected")); + } + }; + }); + webcamStream.current = stream; + } catch (cameraError) { + if (!cancelled) { + console.warn("Failed to get webcam access:", cameraError); + setWebcamEnabledState(false); + toast.error(t("recording.cameraBlocked")); + } + } + }; + + void acquire(); + + return () => { + cancelled = true; + if (acquiredStream) { + acquiredStream.getTracks().forEach((track) => track.stop()); + webcamStream.current = null; + } + }; + }, [webcamEnabled, webcamDeviceId, t]); + const finalizeRecording = useCallback( ( activeScreenRecorder: RecorderHandle, @@ -408,32 +464,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - if (webcamEnabled) { - try { - webcamStream.current = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, - frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, - } - : { - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, - frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, - }, - }); - } catch (cameraError) { - console.warn("Failed to get webcam access:", cameraError); - if (webcamStream.current) { - webcamStream.current.getTracks().forEach((track) => track.stop()); - webcamStream.current = null; - } - setWebcamEnabledState(false); - toast.error(t("recording.cameraDenied")); - } + if (webcamEnabled && !webcamStream.current) { + setWebcamEnabledState(false); + toast.error(t("recording.cameraDenied")); } stream.current = new MediaStream(); diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 6fdc3107..8acd181a 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -30,6 +30,7 @@ "systemAudioUnavailable": "System audio not available. Recording without system audio.", "microphoneDenied": "Microphone access denied. Recording will continue without audio.", "cameraDenied": "Camera access denied. Recording will continue without webcam.", + "cameraDisconnected": "Webcam disconnected.", "permissionDenied": "Recording permission denied. Please allow screen recording." } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 99adc78e..5834622e 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -30,6 +30,7 @@ "systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.", "microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.", "cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.", + "cameraDisconnected": "Cámara web desconectada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 5d27bef2..0723c88e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -30,6 +30,7 @@ "systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。", "microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。", "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", + "cameraDisconnected": "摄像头已断开连接。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" } } diff --git a/src/lib/requestCameraAccess.ts b/src/lib/requestCameraAccess.ts index 24942240..cfcd4d11 100644 --- a/src/lib/requestCameraAccess.ts +++ b/src/lib/requestCameraAccess.ts @@ -17,9 +17,7 @@ export async function requestCameraAccess(): Promise { if (window.electronAPI?.requestCameraAccess) { try { const electronResult = await window.electronAPI.requestCameraAccess(); - if (!electronResult.success || !electronResult.granted) { - return electronResult; - } + return electronResult; } catch (error) { return { success: false, From 954b99e962dd2c820ddc7f4250bc78b722881500 Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 12:12:00 -0500 Subject: [PATCH 2/5] fix: addresses review - differentiate webcam error types and handle stream acquisition --- .gitignore | 1 + src/hooks/useScreenRecorder.ts | 38 ++++++++++++++++++++++++++---- src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 70cc387d..199ff449 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ dist-ssr *.sw? release/** *.kiro/ +.claude/ # npx electron-builder --mac --win # Playwright diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ba95e60f..8448b820 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -103,6 +103,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); + const webcamReady = useRef(false); const selectMimeType = () => { const preferred = [ @@ -182,6 +183,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let cancelled = false; let acquiredStream: MediaStream | null = null; + webcamReady.current = false; const acquire = async () => { try { @@ -217,11 +219,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; }); webcamStream.current = stream; + webcamReady.current = true; } catch (cameraError) { if (!cancelled) { console.warn("Failed to get webcam access:", cameraError); setWebcamEnabledState(false); - toast.error(t("recording.cameraBlocked")); + const isDeviceError = + cameraError instanceof DOMException && + [ + "NotFoundError", + "DevicesNotFoundError", + "OverconstrainedError", + "NotReadableError", + ].includes(cameraError.name); + toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked")); + webcamReady.current = true; } } }; @@ -230,6 +242,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return () => { cancelled = true; + webcamReady.current = false; if (acquiredStream) { acquiredStream.getTracks().forEach((track) => track.stop()); webcamStream.current = null; @@ -464,9 +477,26 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - if (webcamEnabled && !webcamStream.current) { - setWebcamEnabledState(false); - toast.error(t("recording.cameraDenied")); + if (webcamEnabled) { + if (!webcamReady.current) { + await new Promise((resolve) => { + const interval = setInterval(() => { + if (webcamReady.current) { + clearInterval(interval); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(); + }, 5000); + }); + } + if (!webcamStream.current) { + // The useEffect already showed the appropriate error toast + // (cameraNotFound or cameraBlocked), so just disable the state. + setWebcamEnabledState(false); + } } stream.current = new MediaStream(); diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 8acd181a..e0cf4fd2 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -31,6 +31,7 @@ "microphoneDenied": "Microphone access denied. Recording will continue without audio.", "cameraDenied": "Camera access denied. Recording will continue without webcam.", "cameraDisconnected": "Webcam disconnected.", + "cameraNotFound": "Camera not found.", "permissionDenied": "Recording permission denied. Please allow screen recording." } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 5834622e..7956b755 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -31,6 +31,7 @@ "microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.", "cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.", "cameraDisconnected": "Cámara web desconectada.", + "cameraNotFound": "Cámara no encontrada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 0723c88e..2360fe87 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -31,6 +31,7 @@ "microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。", "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", "cameraDisconnected": "摄像头已断开连接。", + "cameraNotFound": "未找到摄像头。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" } } From b270affb25083e534025d34bbec7977d0ce0d8de Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 12:42:23 -0500 Subject: [PATCH 3/5] trigger re-review From 5ff613922ffa5fb1aa864214698d6f1c9cbedbb0 Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 14:03:26 -0500 Subject: [PATCH 4/5] fix:addresses comments - clear track.onended before intentional stop to prevent disconnect toast --- src/hooks/useScreenRecorder.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8448b820..55647168 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -204,7 +204,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); if (cancelled) { - stream.getTracks().forEach((track) => track.stop()); + stream.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); return; } @@ -244,7 +247,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { cancelled = true; webcamReady.current = false; if (acquiredStream) { - acquiredStream.getTracks().forEach((track) => track.stop()); + acquiredStream.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); webcamStream.current = null; } }; @@ -493,8 +499,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); } if (!webcamStream.current) { - // The useEffect already showed the appropriate error toast - // (cameraNotFound or cameraBlocked), so just disable the state. setWebcamEnabledState(false); } } From 210baee0dac278a6f9e824dc5aeefacfa2e86494 Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 14:25:48 -0500 Subject: [PATCH 5/5] added acquireId guard to prevent stale getUserMedia from repopulating webcamStream --- src/hooks/useScreenRecorder.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 55647168..f4a624c8 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -104,6 +104,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const discardRecordingId = useRef(null); const restarting = useRef(false); const webcamReady = useRef(false); + const webcamAcquireId = useRef(0); const selectMimeType = () => { const preferred = [ @@ -183,6 +184,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let cancelled = false; let acquiredStream: MediaStream | null = null; + const thisAcquireId = ++webcamAcquireId.current; webcamReady.current = false; const acquire = async () => { @@ -203,7 +205,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, }); - if (cancelled) { + if (cancelled || thisAcquireId !== webcamAcquireId.current) { stream.getTracks().forEach((track) => { track.onended = null; track.stop(); @@ -499,6 +501,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); } if (!webcamStream.current) { + webcamAcquireId.current++; setWebcamEnabledState(false); } }