diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index fe32edf7..30c1ae3b 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3,6 +3,7 @@ import { FolderOpen, Languages, Save, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -76,6 +77,46 @@ import { } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; +const AUTOSAVE_STORAGE_KEY = "openscreen_editor_recovery_draft"; +const AUTOSAVE_DELAY_MS = 750; + +interface RecoveryDraft { + savedAt: number; + projectPath: string | null; + lastSavedSnapshot: string | null; + project: unknown; +} + +function readRecoveryDraft(): RecoveryDraft | null { + try { + const raw = localStorage.getItem(AUTOSAVE_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed.project || !validateProjectData(parsed.project)) { + localStorage.removeItem(AUTOSAVE_STORAGE_KEY); + return null; + } + return { + savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : Date.now(), + projectPath: typeof parsed.projectPath === "string" ? parsed.projectPath : null, + lastSavedSnapshot: + typeof parsed.lastSavedSnapshot === "string" ? parsed.lastSavedSnapshot : null, + project: parsed.project, + }; + } catch { + localStorage.removeItem(AUTOSAVE_STORAGE_KEY); + return null; + } +} + +function clearRecoveryDraft() { + try { + localStorage.removeItem(AUTOSAVE_STORAGE_KEY); + } catch { + // Ignore storage errors. + } +} + export default function VideoEditor() { const { state: editorState, @@ -112,6 +153,7 @@ export default function VideoEditor() { const [webcamVideoPath, setWebcamVideoPath] = useState(null); const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); + const [recoveryDraft, setRecoveryDraft] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -193,7 +235,11 @@ export default function VideoEditor() { }, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]); const applyLoadedProject = useCallback( - async (candidate: unknown, path?: string | null) => { + async ( + candidate: unknown, + path?: string | null, + options?: { lastSavedSnapshot?: string | null }, + ) => { if (!validateProjectData(candidate)) { return false; } @@ -240,6 +286,7 @@ export default function VideoEditor() { webcamMaskShape: normalizedEditor.webcamMaskShape, webcamSizePreset: normalizedEditor.webcamSizePreset, webcamPosition: normalizedEditor.webcamPosition, + cursorHighlight: normalizedEditor.cursorHighlight, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); @@ -275,14 +322,13 @@ export default function VideoEditor() { 0, ) + 1; - setLastSavedSnapshot( - createProjectSnapshot( - webcamSourcePath - ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } - : { screenVideoPath: sourcePath }, - normalizedEditor, - ), + const loadedSnapshot = createProjectSnapshot( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + normalizedEditor, ); + setLastSavedSnapshot(options?.lastSavedSnapshot ?? loadedSnapshot); return true; }, [pushState], @@ -307,12 +353,14 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, + cursorHighlight, }); }, [ currentProjectMedia, @@ -330,18 +378,112 @@ export default function VideoEditor() { aspectRatio, webcamLayoutPreset, webcamMaskShape, + webcamSizePreset, webcamPosition, exportQuality, exportFormat, gifFrameRate, gifLoop, gifSizePreset, + cursorHighlight, ]); const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot); + useEffect(() => { + if (loading) return; + if (!currentProjectMedia || !currentProjectSnapshot) { + if (!recoveryDraft) { + clearRecoveryDraft(); + } + return; + } + if (recoveryDraft && recoveryDraft.projectPath !== currentProjectPath) { + clearRecoveryDraft(); + setRecoveryDraft(null); + return; + } + if (!hasUnsavedChanges) { + if (!recoveryDraft) { + clearRecoveryDraft(); + } + return; + } + + const editorState = { + wallpaper, + shadowIntensity, + showBlur, + motionBlurAmount, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + webcamLayoutPreset, + webcamMaskShape, + webcamSizePreset, + webcamPosition, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + cursorHighlight, + }; + const draft: RecoveryDraft = { + savedAt: Date.now(), + projectPath: currentProjectPath, + lastSavedSnapshot, + project: createProjectData(currentProjectMedia, editorState), + }; + const timeout = window.setTimeout(() => { + try { + localStorage.setItem(AUTOSAVE_STORAGE_KEY, JSON.stringify(draft)); + } catch (error) { + console.warn("Failed to autosave recovery draft:", error); + } + }, AUTOSAVE_DELAY_MS); + + return () => window.clearTimeout(timeout); + }, [ + currentProjectMedia, + currentProjectSnapshot, + currentProjectPath, + lastSavedSnapshot, + hasUnsavedChanges, + recoveryDraft, + wallpaper, + shadowIntensity, + showBlur, + motionBlurAmount, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + webcamLayoutPreset, + webcamMaskShape, + webcamSizePreset, + webcamPosition, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + cursorHighlight, + loading, + ]); + useEffect(() => { async function loadInitialData() { + const pendingRecoveryDraft = readRecoveryDraft(); try { const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); if (currentProjectResult.success && currentProjectResult.project) { @@ -397,6 +539,9 @@ export default function VideoEditor() { } catch (err) { setError("Error loading video: " + String(err)); } finally { + if (pendingRecoveryDraft) { + setRecoveryDraft(pendingRecoveryDraft); + } setLoading(false); } } @@ -537,6 +682,25 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + const handleRestoreRecoveryDraft = useCallback(async () => { + if (!recoveryDraft) return; + const restored = await applyLoadedProject(recoveryDraft.project, recoveryDraft.projectPath, { + lastSavedSnapshot: recoveryDraft.lastSavedSnapshot, + }); + if (restored) { + clearRecoveryDraft(); + setRecoveryDraft(null); + toast.success("Recovered unsaved edits"); + } else { + toast.error("Could not restore recovery draft"); + } + }, [applyLoadedProject, recoveryDraft]); + + const handleDiscardRecoveryDraft = useCallback(() => { + clearRecoveryDraft(); + setRecoveryDraft(null); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -1712,6 +1876,35 @@ export default function VideoEditor() { } }, []); + const keepRecoveryDialogOpen = useCallback(() => undefined, []); + const recoveryDraftDialog = ( + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > + + Restore unsaved edits? + + OpenScreen recovered unsaved editor changes from{" "} + {recoveryDraft + ? new Date(recoveryDraft.savedAt).toLocaleString() + : "a previous session"} + . + + + + + + + + + ); + if (loading) { return (
@@ -1732,6 +1925,7 @@ export default function VideoEditor() { {ts("project.load")}
+ {recoveryDraftDialog} ); } @@ -2093,6 +2287,7 @@ export default function VideoEditor() { exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } /> + {recoveryDraftDialog} ); } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index dc9758fb..f14be62f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -408,6 +408,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }); + const safeHideCountdownOverlay = useCallback(async (runId: number) => { + try { + await window.electronAPI.hideCountdownOverlay(runId); + } catch (error) { + console.warn("Failed to hide countdown overlay:", error); + } + }, []); + useEffect(() => { let cleanup: (() => void) | undefined; @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamRecorder.current = null; teardownMedia(); }; - }, [teardownMedia]); + }, [teardownMedia, safeHideCountdownOverlay]); const safeShowCountdownOverlay = async (value: number, runId: number) => { try { @@ -477,14 +485,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; - const safeHideCountdownOverlay = async (runId: number) => { - try { - await window.electronAPI.hideCountdownOverlay(runId); - } catch (error) { - console.warn("Failed to hide countdown overlay:", error); - } - }; - const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId;