From 0727b61de73dd83dffc5c2576c0af016afd6376d Mon Sep 17 00:00:00 2001 From: Prayas Lashkari Date: Sun, 15 Mar 2026 02:07:39 -0400 Subject: [PATCH 01/14] feat: add restart recording functionality in LaunchWindow and useScreenRecorder --- src/components/launch/LaunchWindow.tsx | 24 +++++++++++++++++++++++- src/hooks/useScreenRecorder.ts | 15 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b456dd64..ef3c3b3a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -4,7 +4,15 @@ import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; -import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; +import { + MdMic, + MdMicOff, + MdMonitor, + MdRestartAlt, + MdVideoFile, + MdVolumeOff, + MdVolumeUp, +} from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -24,6 +32,7 @@ const ICON_CONFIG = { micOn: { icon: MdMic, size: ICON_SIZE }, micOff: { icon: MdMicOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, + restart: { icon: MdRestartAlt, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -51,6 +60,7 @@ export function LaunchWindow() { const { recording, toggleRecording, + restartRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -256,6 +266,18 @@ export function LaunchWindow() { )} + {/* Restart recording */} + {recording && ( + + + + )} + {/* Open video file */} + {/* Record/Stop group */} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b3bdb8da..a3509854 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -17,6 +17,7 @@ import { type GifSizePreset, VideoExporter, } from "@/lib/exporter"; +import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; @@ -26,6 +27,7 @@ import { deriveNextId, fromFileUrl, normalizeProjectEditor, + resolveProjectMedia, toFileUrl, validateProjectData, } from "./projectPersistence"; @@ -79,6 +81,8 @@ export default function VideoEditor() { // ── Non-undoable state const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); + const [webcamVideoPath, setWebcamVideoPath] = useState(null); + const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -111,6 +115,19 @@ export default function VideoEditor() { const nextAnnotationZIndexRef = useRef(1); const exporterRef = useRef(null); + const currentProjectMedia = useMemo(() => { + const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + if (!screenVideoPath) { + return null; + } + + const webcamSourcePath = + webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null); + return webcamSourcePath + ? { screenVideoPath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath }; + }, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]); + const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { if (!validateProjectData(candidate)) { @@ -118,7 +135,12 @@ export default function VideoEditor() { } const project = candidate; - const sourcePath = fromFileUrl(project.videoPath); + const media = resolveProjectMedia(project); + if (!media) { + return false; + } + const sourcePath = fromFileUrl(media.screenVideoPath); + const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null; const normalizedEditor = normalizeProjectEditor(project.editor); try { @@ -133,6 +155,8 @@ export default function VideoEditor() { setError(null); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(webcamSourcePath); + setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(path ?? null); pushState({ @@ -182,19 +206,27 @@ export default function VideoEditor() { 0, ) + 1; - setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); + setLastSavedSnapshot( + JSON.stringify( + createProjectData( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + normalizedEditor, + ), + ), + ); return true; }, [pushState], ); const currentProjectSnapshot = useMemo(() => { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); - if (!sourcePath) { + if (!currentProjectMedia) { return null; } return JSON.stringify( - createProjectData(sourcePath, { + createProjectData(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -215,8 +247,7 @@ export default function VideoEditor() { }), ); }, [ - videoPath, - videoSourcePath, + currentProjectMedia, wallpaper, shadowIntensity, showBlur, @@ -257,11 +288,29 @@ export default function VideoEditor() { } } + const currentSessionResult = await window.electronAPI.getCurrentRecordingSession(); + if (currentSessionResult.success && currentSessionResult.session) { + const session = currentSessionResult.session; + const sourcePath = fromFileUrl(session.screenVideoPath); + const webcamSourcePath = session.webcamVideoPath + ? fromFileUrl(session.webcamVideoPath) + : null; + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(webcamSourcePath); + setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); + setCurrentProjectPath(null); + setLastSavedSnapshot(null); + return; + } + const result = await window.electronAPI.getCurrentVideoPath(); if (result.success && result.path) { const sourcePath = fromFileUrl(result.path); setVideoSourcePath(sourcePath); setVideoPath(toFileUrl(sourcePath)); + setWebcamVideoSourcePath(null); + setWebcamVideoPath(null); setCurrentProjectPath(null); setLastSavedSnapshot(null); } else { @@ -284,13 +333,12 @@ export default function VideoEditor() { return false; } - const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); - if (!sourcePath) { + if (!currentProjectMedia) { toast.error("Unable to determine source video path"); return false; } - const projectData = createProjectData(sourcePath, { + const projectData = createProjectData(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -311,7 +359,7 @@ export default function VideoEditor() { }); const fileNameBase = - sourcePath + currentProjectMedia.screenVideoPath .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; @@ -341,8 +389,7 @@ export default function VideoEditor() { return true; }, [ - videoPath, - videoSourcePath, + currentProjectMedia, currentProjectPath, wallpaper, shadowIntensity, @@ -361,6 +408,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, + videoPath, ], ); @@ -420,7 +468,7 @@ export default function VideoEditor() { let mounted = true; async function loadCursorTelemetry() { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + const sourcePath = currentProjectMedia?.screenVideoPath ?? null; if (!sourcePath) { if (mounted) { @@ -447,7 +495,7 @@ export default function VideoEditor() { return () => { mounted = false; }; - }, [videoPath, videoSourcePath]); + }, [currentProjectMedia]); function togglePlayPause() { const playback = videoPlaybackRef.current; @@ -921,6 +969,7 @@ export default function VideoEditor() { // GIF Export const gifExporter = new GifExporter({ videoUrl: videoPath, + webcamVideoUrl: webcamVideoPath || undefined, width: settings.gifConfig.width, height: settings.gifConfig.height, frameRate: settings.gifConfig.frameRate, @@ -1048,6 +1097,7 @@ export default function VideoEditor() { const exporter = new VideoExporter({ videoUrl: videoPath, + webcamVideoUrl: webcamVideoPath || undefined, width: exportWidth, height: exportHeight, frameRate: 60, @@ -1115,6 +1165,7 @@ export default function VideoEditor() { }, [ videoPath, + webcamVideoPath, wallpaper, zoomRegions, trimRegions, @@ -1251,10 +1302,11 @@ export default function VideoEditor() { }} > void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -98,6 +100,7 @@ const VideoPlayback = forwardRef( ( { videoPath, + webcamVideoPath, onDurationChange, onTimeUpdate, currentTime, @@ -129,7 +132,9 @@ const VideoPlayback = forwardRef( ref, ) => { const videoRef = useRef(null); + const webcamVideoRef = useRef(null); const containerRef = useRef(null); + const stageRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); const videoContainerRef = useRef(null); @@ -139,6 +144,11 @@ const VideoPlayback = forwardRef( const [videoReady, setVideoReady] = useState(false); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); + const [webcamLayout, setWebcamLayout] = useState(null); + const [webcamDimensions, setWebcamDimensions] = useState<{ + width: number; + height: number; + } | null>(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -901,6 +911,90 @@ const VideoPlayback = forwardRef( const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + setWebcamDimensions(null); + return; + } + + const handleLoadedMetadata = () => { + if (webcamVideo.videoWidth > 0 && webcamVideo.videoHeight > 0) { + setWebcamDimensions({ + width: webcamVideo.videoWidth, + height: webcamVideo.videoHeight, + }); + } + }; + + webcamVideo.addEventListener("loadedmetadata", handleLoadedMetadata); + handleLoadedMetadata(); + return () => { + webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + }, [webcamVideoPath]); + + useEffect(() => { + const stage = stageRef.current; + if (!stage || !webcamDimensions) { + setWebcamLayout(null); + return; + } + + const updateLayout = () => { + const layout = computeWebcamOverlayLayout({ + stageWidth: stage.clientWidth, + stageHeight: stage.clientHeight, + videoWidth: webcamDimensions.width, + videoHeight: webcamDimensions.height, + }); + setWebcamLayout(layout); + }; + + updateLayout(); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(updateLayout); + observer.observe(stage); + return () => observer.disconnect(); + }, [webcamDimensions]); + + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + return; + } + + if (!isPlaying) { + webcamVideo.pause(); + if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) { + webcamVideo.currentTime = currentTime; + } + return; + } + + if (Math.abs(webcamVideo.currentTime - currentTime) > 0.15) { + webcamVideo.currentTime = currentTime; + } + + webcamVideo.play().catch(() => { + // Ignore webcam autoplay restoration failures. + }); + }, [currentTime, isPlaying, webcamVideoPath]); + + useEffect(() => { + const webcamVideo = webcamVideoRef.current; + if (!webcamVideo || !webcamVideoPath) { + return; + } + + webcamVideo.pause(); + webcamVideo.currentTime = 0; + }, [webcamVideoPath]); + useEffect(() => { let mounted = true; (async () => { @@ -975,6 +1069,7 @@ const VideoPlayback = forwardRef( return (
( : "none", }} /> + {webcamVideoPath && ( +