Skip to content
Merged
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
12 changes: 12 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ interface Window {
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
}>;
listRecordingSessions: () => Promise<{
success: boolean;
sessions: Array<
import("../src/lib/recordingSession").RecordingSession & { sizeBytes: number }
>;
error?: string;
}>;
openRecordingSession: (screenVideoPath: string) => Promise<{
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
error?: string;
}>;
readBinaryFile: (filePath: string) => Promise<{
success: boolean;
data?: ArrayBuffer;
Expand Down
70 changes: 70 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2332,6 +2332,76 @@ export function registerIpcHandlers(
}
});

// Past recordings are resumable through their on-disk *.session.json manifests.
// Until now those were write-only — only the in-memory "current" session could
// be reopened, so any take that wasn't saved as a .openscreen project was
// stranded once another recording replaced it.
ipcMain.handle("list-recording-sessions", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const manifests = files.filter((file) => file.endsWith(RECORDING_SESSION_SUFFIX));
const sessions: Array<RecordingSession & { sizeBytes: number }> = [];
for (const name of manifests) {
try {
const content = await fs.readFile(path.join(RECORDINGS_DIR, name), "utf-8");
const session = normalizeRecordingSession(JSON.parse(content));
if (!session) continue;
if (!isPathWithinDir(path.resolve(session.screenVideoPath), RECORDINGS_DIR)) continue;
// Stale manifest (video deleted) — skip rather than offer a dead entry.
const stat = await fs.stat(session.screenVideoPath).catch(() => null);
if (!stat) continue;
if (session.webcamVideoPath) {
// Keep the session but drop a missing webcam sidecar.
const webcamOk = await fs
.access(session.webcamVideoPath, fsConstants.R_OK)
.then(() => true)
.catch(() => false);
if (!webcamOk) {
session.webcamVideoPath = undefined;
}
}
sessions.push({ ...session, sizeBytes: stat.size });
} catch {
// Malformed manifest — skip it.
}
}
sessions.sort((a, b) => b.createdAt - a.createdAt);
return { success: true, sessions };
} catch (error) {
console.error("Failed to list recording sessions:", error);
return { success: false, error: String(error), sessions: [] };
}
});

ipcMain.handle("open-recording-session", async (_, screenVideoPath: string) => {
try {
const normalized = normalizeVideoSourcePath(screenVideoPath);
if (!normalized || !isPathWithinDir(path.resolve(normalized), RECORDINGS_DIR)) {
return { success: false, error: "Recording is outside the recordings folder." };
}
await fs.access(normalized, fsConstants.R_OK);
const session = await loadRecordedSessionForVideoPath(normalized);
if (!session) {
return { success: false, error: "Recording session manifest not found." };
}
if (session.webcamVideoPath) {
const webcamOk = await fs
.access(session.webcamVideoPath, fsConstants.R_OK)
.then(() => true)
.catch(() => false);
if (!webcamOk) {
session.webcamVideoPath = undefined;
}
}
setCurrentRecordingSessionState(session);
currentProjectPath = null;
return { success: true, session };
} catch (error) {
console.error("Failed to open recording session:", error);
return { success: false, error: String(error) };
}
});

ipcMain.handle("get-recorded-video-path", async () => {
try {
if (currentRecordingSession?.screenVideoPath) {
Expand Down
6 changes: 6 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
getCurrentVideoPath: () => {
return ipcRenderer.invoke("get-current-video-path");
},
listRecordingSessions: () => {
return ipcRenderer.invoke("list-recording-sessions");
},
openRecordingSession: (screenVideoPath: string) => {
return ipcRenderer.invoke("open-recording-session", screenVideoPath);
},
getCurrentRecordingSession: () => {
return ipcRenderer.invoke("get-current-recording-session");
},
Expand Down
88 changes: 85 additions & 3 deletions src/components/video-editor/EditorEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,70 @@
import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { AlertCircle, Film, FolderOpen, Upload, Video, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import type { RecordingSession } from "@/lib/recordingSession";
import { getProjectFolder, parentDirectoryOf, saveUserPreferences } from "@/lib/userPreferences";
import { nativeBridgeClient } from "@/native";

interface EditorEmptyStateProps {
onVideoImported: (videoPath: string) => void;
/** Called with the loaded project data; handles both button click and drag-drop */
onProjectOpened: (project: unknown, path: string | null) => void;
/** Called after a past recording's session is reopened from its manifest. */
onRecordingSessionOpened: (session: RecordingSession) => void | Promise<void>;
}

type RecentRecording = RecordingSession & { sizeBytes: number };

function formatRecordingSize(sizeBytes: number) {
const mb = sizeBytes / (1024 * 1024);
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${Math.max(1, Math.round(mb))} MB`;
}

type DropError = "unsupported-format" | "load-failed" | null;

export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) {
export function EditorEmptyState({
onVideoImported,
onProjectOpened,
onRecordingSessionOpened,
}: EditorEmptyStateProps) {
const te = useScopedT("editor");
const tc = useScopedT("common");
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [recentRecordings, setRecentRecordings] = useState<RecentRecording[]>([]);

useEffect(() => {
let cancelled = false;
window.electronAPI
.listRecordingSessions()
.then((result) => {
if (!cancelled && result.success) {
setRecentRecordings(result.sessions);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);

const handleOpenRecording = useCallback(
async (recording: RecentRecording) => {
const result = await window.electronAPI.openRecordingSession(recording.screenVideoPath);
if (!result.success || !result.session) {
toast.error(result.error ?? te("emptyState.recentRecordings.openFailed"));
// The manifest or video may have gone away since listing; refresh.
const refreshed = await window.electronAPI.listRecordingSessions().catch(() => null);
if (refreshed?.success) {
setRecentRecordings(refreshed.sessions);
}
return;
}
await onRecordingSessionOpened(result.session);
},
[onRecordingSessionOpened, te],
);
const [dropError, setDropError] = useState<DropError>(null);
// Freeze the last non-null error type so dialog content doesn't snap to the else-branch
// during the closing animation (same pattern as UnsavedChangesDialog).
Expand Down Expand Up @@ -196,6 +244,40 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp
</button>
</div>

{recentRecordings.length > 0 && (
<div className="flex w-full max-w-xs flex-col gap-2">
<p className="text-left text-xs font-medium uppercase tracking-wide text-slate-600">
{te("emptyState.recentRecordings.title")}
</p>
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1">
{recentRecordings.map((recording) => (
<button
key={recording.screenVideoPath}
type="button"
onClick={() => handleOpenRecording(recording)}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.08] border border-white/5 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30"
>
<Film className="h-3.5 w-3.5 flex-shrink-0 text-slate-500" />
<span className="min-w-0 flex-1 truncate text-xs text-slate-300">
{new Date(recording.createdAt).toLocaleString()}
</span>
{recording.webcamVideoPath && (
<span
className="flex items-center gap-1 rounded bg-[#6366f1]/15 px-1.5 py-0.5 text-[10px] text-[#a5b4fc]"
title={te("emptyState.recentRecordings.webcamBadge")}
>
<Video className="h-3 w-3" />
</span>
)}
<span className="flex-shrink-0 text-[10px] text-slate-600">
{formatRecordingSize(recording.sizeBytes)}
</span>
</button>
))}
</div>
</div>
)}

<div className="flex flex-col items-center gap-2">
<p className="text-xs text-slate-600">{te("emptyState.supportedFormats")}</p>
<div className="flex items-center gap-1.5 text-xs text-slate-700 mt-4">
Expand Down
58 changes: 35 additions & 23 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
import type { CursorCaptureMode, ProjectMedia, RecordingSession } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
getExportFolder,
Expand Down Expand Up @@ -622,6 +622,37 @@ export default function VideoEditor() {
currentProjectSnapshot !== lastSavedSnapshot,
);

// Loads a recording session (screen video + optional webcam sidecar) into a
// fresh editor state. Used on startup for the current session and by the
// empty-state dashboard to resume any past recording from its manifest.
const applyRecordingSession = useCallback(
async (session: RecordingSession) => {
const sourcePath = fromFileUrl(session.screenVideoPath);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
if (session.webcamVideoPath) {
const webcamSourcePath = fromFileUrl(session.webcamVideoPath);
const durationMs = await getVideoDurationMs(webcamSourcePath);
pushState({
webcamSegments: [
{
id: `webcam-${nextWebcamSegmentIdRef.current++}`,
videoPath: toFileUrl(webcamSourcePath),
sourcePath: webcamSourcePath,
startMs: webcamSyncOffsetMs,
durationMs,
},
],
});
} else {
pushState({ webcamSegments: [] });
}
},
[pushState, webcamSyncOffsetMs],
);

useEffect(() => {
async function loadInitialData() {
try {
Expand All @@ -638,27 +669,7 @@ export default function VideoEditor() {

const currentSessionResult = await window.electronAPI.getCurrentRecordingSession();
if (currentSessionResult.success && currentSessionResult.session) {
const session = currentSessionResult.session;
const sourcePath = fromFileUrl(session.screenVideoPath);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
if (session.webcamVideoPath) {
const webcamSourcePath = fromFileUrl(session.webcamVideoPath);
const durationMs = await getVideoDurationMs(webcamSourcePath);
pushState({
webcamSegments: [
{
id: `webcam-${nextWebcamSegmentIdRef.current++}`,
videoPath: toFileUrl(webcamSourcePath),
sourcePath: webcamSourcePath,
startMs: webcamSyncOffsetMs,
durationMs,
},
],
});
}
await applyRecordingSession(currentSessionResult.session);
return;
}

Expand All @@ -682,7 +693,7 @@ export default function VideoEditor() {
}

loadInitialData();
}, [applyLoadedProject, webcamSyncOffsetMs, pushState]);
}, [applyLoadedProject, applyRecordingSession]);

// Avoid overwriting saved prefs with defaults before they've loaded.
const [prefsHydrated, setPrefsHydrated] = useState(false);
Expand Down Expand Up @@ -2905,6 +2916,7 @@ export default function VideoEditor() {
toast.error(t("project.invalidFormat"));
}
}}
onRecordingSessionOpened={applyRecordingSession}
/>
</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "يمكن إسقاط ملفات مشروع .openscreen فقط هنا. لاستيراد مقطع فيديو، استخدم زر \"استيراد ملف فيديو...\" بدلاً من ذلك.",
"couldNotOpenTitle": "تعذّر فتح الملف",
"couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه."
},
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
}
}
}
5 changes: 5 additions & 0 deletions src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.",
"couldNotOpenTitle": "Could Not Open File",
"couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted."
},
"recentRecordings": {
"title": "Recent recordings",
"webcamBadge": "Includes webcam",
"openFailed": "Could not open this recording. Its files may have been moved or deleted."
}
}
}
5 changes: 5 additions & 0 deletions src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "Solo se pueden soltar aquí archivos de proyecto .openscreen. Para importar un video, usa el botón \"Importar archivo de video...\" en su lugar.",
"couldNotOpenTitle": "No se pudo abrir el archivo",
"couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado."
},
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
}
}
}
5 changes: 5 additions & 0 deletions src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "Seuls les fichiers .openscreen peuvent être déposés ici. Pour importer une vidéo, utilisez plutôt le bouton \"Importer un fichier vidéo...\".",
"couldNotOpenTitle": "Impossible d'ouvrir le fichier",
"couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée."
},
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
}
}
}
5 changes: 5 additions & 0 deletions src/i18n/locales/it/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.",
"couldNotOpenTitle": "Could Not Open File",
"couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted."
},
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
}
}
}
5 changes: 5 additions & 0 deletions src/i18n/locales/ja-JP/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"unsupportedFormatMessage": "ここにドロップできるのは .openscreen プロジェクトファイルのみです。動画をインポートするには「動画ファイルをインポート...」ボタンをご使用ください。",
"couldNotOpenTitle": "ファイルを開けませんでした",
"couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。"
},
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
}
}
}
Loading
Loading