From 20b0899c053478becda870debc1b7f91abadf6ee Mon Sep 17 00:00:00 2001 From: dheerajmr01 Date: Sat, 4 Apr 2026 01:37:27 -0500 Subject: [PATCH 1/9] 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 f1b66b8..8fd8934 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 0c418c1..ba95e60 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 6fdc310..8acd181 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 99adc78..5834622 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 5d27bef..0723c88 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 2494224..cfcd4d1 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/9] 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 70cc387..199ff44 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 ba95e60..8448b82 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 8acd181..e0cf4fd 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 5834622..7956b75 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 0723c88..2360fe8 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/9] 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/9] 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 8448b82..5564716 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/9] 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 5564716..f4a624c 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); } } From 36453d740fa1c96c60132b1c8c19e2a5f8f32cfa Mon Sep 17 00:00:00 2001 From: imAaryash Date: Mon, 6 Apr 2026 08:18:40 +0530 Subject: [PATCH 6/9] Update LaunchWindow.tsx --- src/components/launch/LaunchWindow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77..0cfef9e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -228,7 +228,7 @@ export function LaunchWindow() { }; return ( -
+
{/* Language switcher — top-left, beside traffic lights */}
Date: Sun, 19 Apr 2026 02:49:17 -0700 Subject: [PATCH 7/9] fix(annotations): wrap CJK text at character boundaries in export renderer renderText split each line on whitespace, which works for Latin text but leaves CJK strings as a single unbreakable token because CJK scripts have no word-separating whitespace. Result: CJK annotation text overflows the clipped annotation box even though the editor's HTML preview wraps it correctly via CSS word-break: break-word. Replace the ad-hoc whitespace split with a tokenizeForWrap helper that emits each CJK character (Hiragana, Katakana, Hangul Syllables, CJK Unified Ideographs + Extension A, and CJK Compatibility Ideographs) as its own token, while keeping Latin words + whitespace intact. The existing width-measurement wrap loop then handles CJK per-character, matching the editor's behavior. Closes #449 --- src/lib/exporter/annotationRenderer.ts | 41 +++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index b0c4948..0b895a0 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -10,6 +10,39 @@ import { let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; +// Matches a single code point in Hiragana, Katakana, CJK Unified Ideographs +// Extension A, CJK Unified Ideographs, Hangul Syllables, or CJK Compatibility +// Ideographs. Used to split CJK text at character boundaries during wrap, +// since CJK scripts have no word-separating whitespace. +const CJK_CHAR = + /[\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/u; + +function tokenizeForWrap(line: string): string[] { + // Split Latin text on whitespace (preserving the whitespace as its own token, + // matching the original behavior), and split CJK runs into individual + // characters so each one becomes a breakable unit. This mirrors the editor's + // CSS `word-break: break-word` handling for CJK content. + const tokens: string[] = []; + let buffer = ""; + const chars = Array.from(line); + const flushBuffer = () => { + if (buffer) { + tokens.push(...buffer.split(/(\s+)/).filter((s) => s.length > 0)); + buffer = ""; + } + }; + for (const ch of chars) { + if (CJK_CHAR.test(ch)) { + flushBuffer(); + tokens.push(ch); + } else { + buffer += ch; + } + } + flushBuffer(); + return tokens; +} + // SVG path data for each arrow direction const ARROW_PATHS: Record = { up: ["M 50 20 L 50 80", "M 50 20 L 35 35", "M 50 20 L 65 35"], @@ -249,13 +282,13 @@ function renderText( lines.push(""); continue; } - const words = rawLine.split(/(\s+)/); + const tokens = tokenizeForWrap(rawLine); let current = ""; - for (const word of words) { - const test = current + word; + for (const token of tokens) { + const test = current + token; if (current && ctx.measureText(test).width > availableWidth) { lines.push(current); - current = word.trimStart(); + current = token.trimStart(); } else { current = test; } From dd622f83c1e410e987587cab7568ac6bc06b5f74 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sun, 19 Apr 2026 10:05:48 -0700 Subject: [PATCH 8/9] fix(annotations): use Unicode script properties for CJK detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on #471 from @coderabbitai. The BMP-only codepoint ranges missed two classes of characters: - Non-BMP Han extensions (CJK Unified Ideographs Extension B, C, D, E, F) such as 𠀀. A long string of Extension-B characters would still be tokenized as a single unbreakable unit and overflow the box. - Halfwidth Katakana (U+FF65-U+FF9F) such as カ. Same failure mode. Switch to Unicode script property escapes (\\p{Script=Han}, \\p{Script=Hiragana}, \\p{Script=Katakana}, \\p{Script=Hangul}) which cover these cases without enumerating ranges. tsconfig target is ES2020; property escapes require ES2018+ so this is safe. Verified coverage: 漢 あ ア 가 𠀀 カ all match; A and digits do not. --- src/lib/exporter/annotationRenderer.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index 0b895a0..c0d5657 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -10,12 +10,12 @@ import { let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; -// Matches a single code point in Hiragana, Katakana, CJK Unified Ideographs -// Extension A, CJK Unified Ideographs, Hangul Syllables, or CJK Compatibility -// Ideographs. Used to split CJK text at character boundaries during wrap, -// since CJK scripts have no word-separating whitespace. -const CJK_CHAR = - /[\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff]/u; +// Matches a single code point whose script is Han (including non-BMP +// Extension A-F), Hiragana, Katakana (including halfwidth forms), or +// Hangul. Used to split CJK text at character boundaries during wrap, +// since CJK scripts have no word-separating whitespace. Unicode script +// property escapes require ES2018+; tsconfig target is ES2020. +const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; function tokenizeForWrap(line: string): string[] { // Split Latin text on whitespace (preserving the whitespace as its own token, From 0bb14f3a334903265e3023166a0b8b2a7c3e5488 Mon Sep 17 00:00:00 2001 From: Fabien Laurence <86679051+FabLrc@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:07:17 +0200 Subject: [PATCH 9/9] Update src/components/launch/LaunchWindow.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/launch/LaunchWindow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 2914584..05e78a4 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -314,7 +314,7 @@ export function LaunchWindow() { }; return ( -
+
{systemLocaleSuggestion && (