Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
211 changes: 203 additions & 8 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<RecoveryDraft>;
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,
Expand Down Expand Up @@ -112,6 +153,7 @@ export default function VideoEditor() {
const [webcamVideoPath, setWebcamVideoPath] = useState<string | null>(null);
const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [recoveryDraft, setRecoveryDraft] = useState<RecoveryDraft | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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],
Expand All @@ -307,12 +353,14 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
cursorHighlight,
});
}, [
currentProjectMedia,
Expand All @@ -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,
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => {
async function loadInitialData() {
const pendingRecoveryDraft = readRecoveryDraft();
try {
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
Expand Down Expand Up @@ -397,6 +539,9 @@ export default function VideoEditor() {
} catch (err) {
setError("Error loading video: " + String(err));
} finally {
if (pendingRecoveryDraft) {
setRecoveryDraft(pendingRecoveryDraft);
}
setLoading(false);
}
}
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -1712,6 +1876,35 @@ export default function VideoEditor() {
}
}, []);

const keepRecoveryDialogOpen = useCallback(() => undefined, []);
const recoveryDraftDialog = (
<Dialog open={Boolean(recoveryDraft)} onOpenChange={keepRecoveryDialogOpen}>
<DialogContent
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Restore unsaved edits?</DialogTitle>
<DialogDescription>
OpenScreen recovered unsaved editor changes from{" "}
{recoveryDraft
? new Date(recoveryDraft.savedAt).toLocaleString()
: "a previous session"}
.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleDiscardRecoveryDraft}>
Discard
</Button>
<Button type="button" onClick={handleRestoreRecoveryDraft}>
Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
Expand All @@ -1732,6 +1925,7 @@ export default function VideoEditor() {
{ts("project.load")}
</button>
</div>
{recoveryDraftDialog}
</div>
);
}
Expand Down Expand Up @@ -2093,6 +2287,7 @@ export default function VideoEditor() {
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
}
/>
{recoveryDraftDialog}
</div>
);
}
18 changes: 9 additions & 9 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia]);
}, [teardownMedia, safeHideCountdownOverlay]);

const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
Expand All @@ -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;

Expand Down
Loading