Merge branch 'main' into feat/countdown-before-record-start

This commit is contained in:
Aaryash Khalkar
2026-04-20 20:52:29 +05:30
committed by GitHub
8 changed files with 143 additions and 34 deletions
+1
View File
@@ -27,6 +27,7 @@ dist-ssr
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win
# Playwright
+2 -1
View File
@@ -314,7 +314,7 @@ export function LaunchWindow() {
};
return (
<div className={`w-screen h-screen overflow-hidden bg-transparent ${styles.electronDrag}`}>
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
{systemLocaleSuggestion && (
<div
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
@@ -526,6 +526,7 @@ export function LaunchWindow() {
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
disabled={recording}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
+96 -26
View File
@@ -112,6 +112,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const restarting = useRef(false);
const countdownRunId = useRef(0);
const [countdownActive, setCountdownActive] = useState(false);
const webcamReady = useRef(false);
const webcamAcquireId = useRef(0);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
@@ -160,10 +162,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.
@@ -196,6 +194,85 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
[t],
);
useEffect(() => {
if (!webcamEnabled) return;
let cancelled = false;
let acquiredStream: MediaStream | null = null;
const thisAcquireId = ++webcamAcquireId.current;
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 || thisAcquireId !== webcamAcquireId.current) {
stream.getTracks().forEach((track) => {
track.onended = null;
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.onended = null;
track.stop();
});
webcamStream.current = null;
}
};
}, [webcamEnabled, webcamDeviceId, t]);
const finalizeRecording = useCallback(
(
activeScreenRecorder: RecorderHandle,
@@ -568,30 +645,23 @@ 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) {
webcamAcquireId.current++;
setWebcamEnabledState(false);
toast.error(t("recording.cameraDenied"));
}
}
+2
View File
@@ -36,6 +36,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
View File
@@ -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
View File
@@ -36,6 +36,8 @@
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
"cameraDisconnected": "摄像头已断开连接。",
"cameraNotFound": "未找到摄像头。",
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
}
}
+37 -4
View File
@@ -10,6 +10,39 @@ import {
let blurScratchCanvas: HTMLCanvasElement | null = null;
let blurScratchCtx: CanvasRenderingContext2D | null = null;
// 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,
// 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<ArrowDirection, string[]> = {
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;
}
+1 -3
View File
@@ -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,