fix: sync webcam preview playback speed

This commit is contained in:
Marcus Schiesser
2026-03-17 19:37:12 +08:00
parent 2fb5b3b574
commit e4263d4597
8 changed files with 106 additions and 7 deletions
+6
View File
@@ -29,6 +29,12 @@ interface Window {
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
+42 -1
View File
@@ -1,7 +1,16 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import {
app,
BrowserWindow,
desktopCapturer,
dialog,
ipcMain,
screen,
shell,
systemPreferences,
} from "electron";
import {
normalizeProjectMedia,
normalizeRecordingSession,
@@ -185,6 +194,38 @@ export function registerIpcHandlers(
return selectedSource;
});
ipcMain.handle("request-camera-access", async () => {
if (process.platform !== "darwin") {
return { success: true, granted: true, status: "granted" };
}
try {
const status = systemPreferences.getMediaAccessStatus("camera");
if (status === "granted") {
return { success: true, granted: true, status };
}
if (status === "not-determined") {
const granted = await systemPreferences.askForMediaAccess("camera");
return {
success: true,
granted,
status: granted ? "granted" : systemPreferences.getMediaAccessStatus("camera"),
};
}
return { success: true, granted: false, status };
} catch (error) {
console.error("Failed to request camera access:", error);
return {
success: false,
granted: false,
status: "unknown",
error: String(error),
};
}
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
+2 -2
View File
@@ -333,12 +333,12 @@ app.on("activate", () => {
app.whenReady().then(async () => {
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone"];
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone"];
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
callback(allowed.includes(permission));
});
+3
View File
@@ -27,6 +27,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getSelectedSource: () => {
return ipcRenderer.invoke("get-selected-source");
},
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
+5 -1
View File
@@ -248,7 +248,11 @@ export function LaunchWindow() {
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setWebcamEnabled(!webcamEnabled)}
onClick={() => {
if (!recording) {
void setWebcamEnabled(!webcamEnabled);
}
}}
disabled={recording}
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
>
@@ -968,6 +968,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
const activeSpeedRegion =
speedRegions.find(
(region) =>
currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
) ?? null;
webcamVideo.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
if (!isPlaying) {
webcamVideo.pause();
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) {
@@ -983,7 +990,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.play().catch(() => {
// Ignore webcam autoplay restoration failures.
});
}, [currentTime, isPlaying, webcamVideoPath]);
}, [currentTime, isPlaying, speedRegions, webcamVideoPath]);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
+34 -2
View File
@@ -47,7 +47,7 @@ type UseScreenRecorderReturn = {
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
setWebcamEnabled: (enabled: boolean) => void;
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
};
type RecorderHandle = {
@@ -82,7 +82,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const stream = useRef<MediaStream | null>(null);
@@ -146,6 +146,38 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await window.electronAPI.requestCameraAccess();
if (!accessResult.success) {
toast.error("Failed to request camera access.");
return false;
}
if (!accessResult.granted) {
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
return false;
}
try {
const probeStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
probeStream.getTracks().forEach((track) => track.stop());
setWebcamEnabledState(true);
return true;
} catch (error) {
console.warn("Failed to preflight webcam access:", error);
toast.error("Camera access denied. Webcam overlay will stay disabled.");
return false;
}
}, []);
const stopRecording = useRef(() => {
const activeScreenRecorder = screenRecorder.current;
if (activeScreenRecorder?.recorder.state !== "recording") {
+6
View File
@@ -22,6 +22,12 @@ interface Window {
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,