Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dist-ssr
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win

# Playwright
Expand Down
1 change: 1 addition & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export function LaunchWindow() {
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
disabled={recording}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
Expand Down
115 changes: 89 additions & 26 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const webcamReady = useRef(false);

const selectMimeType = () => {
const preferred = [
Expand Down Expand Up @@ -145,10 +146,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.
Expand Down Expand Up @@ -181,6 +178,78 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
[t],
);

useEffect(() => {
if (!webcamEnabled) return;

let cancelled = false;
let acquiredStream: MediaStream | null = null;
webcamReady.current = false;

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;
webcamReady.current = true;
} catch (cameraError) {
if (!cancelled) {
console.warn("Failed to get webcam access:", cameraError);
setWebcamEnabledState(false);
const isDeviceError =
cameraError instanceof DOMException &&
[
"NotFoundError",
"DevicesNotFoundError",
"OverconstrainedError",
"NotReadableError",
].includes(cameraError.name);
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
webcamReady.current = true;
}
}
};

void acquire();

return () => {
cancelled = true;
webcamReady.current = false;
if (acquiredStream) {
acquiredStream.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
};
}, [webcamEnabled, webcamDeviceId, t]);

const finalizeRecording = useCallback(
(
activeScreenRecorder: RecorderHandle,
Expand Down Expand Up @@ -409,30 +478,24 @@ 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 },
},
if (!webcamReady.current) {
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (webcamReady.current) {
clearInterval(interval);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(interval);
resolve();
}, 5000);
});
} catch (cameraError) {
console.warn("Failed to get webcam access:", cameraError);
if (webcamStream.current) {
webcamStream.current.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
}
if (!webcamStream.current) {
// The useEffect already showed the appropriate error toast
// (cameraNotFound or cameraBlocked), so just disable the state.
setWebcamEnabledState(false);
toast.error(t("recording.cameraDenied"));
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"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.",
"cameraNotFound": "Camera not found.",
"permissionDenied": "Recording permission denied. Please allow screen recording."
}
}
2 changes: 2 additions & 0 deletions src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"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.",
"cameraNotFound": "Cámara no encontrada.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
}
}
2 changes: 2 additions & 0 deletions src/i18n/locales/zh-CN/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
"cameraDisconnected": "摄像头已断开连接。",
"cameraNotFound": "未找到摄像头。",
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
}
}
4 changes: 1 addition & 3 deletions src/lib/requestCameraAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ export async function requestCameraAccess(): Promise<CameraAccessResult> {
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,
Expand Down