diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index da1037d03..068fb0551 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4b3be1b03..35cd8bcd5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 = []; + 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) { diff --git a/electron/preload.ts b/electron/preload.ts index ec985430c..7e7ecb43e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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"); }, diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 2a5c49547..a85f6e285 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -1,7 +1,9 @@ -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"; @@ -9,14 +11,60 @@ 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; +} + +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([]); + + 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(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). @@ -196,6 +244,40 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp + {recentRecordings.length > 0 && ( +
+

+ {te("emptyState.recentRecordings.title")} +

+
+ {recentRecordings.map((recording) => ( + + ))} +
+
+ )} +

{te("emptyState.supportedFormats")}

diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 2f36a7547..2bb8557e0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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, @@ -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 { @@ -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; } @@ -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); @@ -2905,6 +2916,7 @@ export default function VideoEditor() { toast.error(t("project.invalidFormat")); } }} + onRecordingSessionOpened={applyRecordingSession} />
)} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 39750e5eb..859405d8f 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -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" } } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index d6a56f033..086588fb2 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -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." } } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 277ce40ff..336abce22 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -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" } } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 40dc24fd7..227c32329 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -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" } } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 7851fbae0..c15afd4ba 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -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" } } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 8e0da42e1..81b03caec 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -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" } } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index a63a22a57..c15b1872e 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -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" } } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 7b2588b4d..d9121be5d 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -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" } } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 78fa129a1..9351b1ab7 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -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" } } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 89203e719..f91f65d20 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -76,6 +76,11 @@ "unsupportedFormatMessage": "Buraya yalnızca .openscreen proje dosyaları bırakılabilir. Video içe aktarmak için \"Video Dosyası İçe Aktar...\" düğmesini kullanın.", "couldNotOpenTitle": "Dosya Açılamadı", "couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir." + }, + "recentRecordings": { + "openFailed": "Could not open this recording. Its files may have been moved or deleted.", + "title": "Recent recordings", + "webcamBadge": "Includes webcam" } } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 90004091e..8bad439b5 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -76,6 +76,11 @@ "unsupportedFormatMessage": "Chỉ có thể thả các tệp dự án .openscreen vào đây. Để nhập video, hãy sử dụng nút \"Nhập tệp video...\" thay thế.", "couldNotOpenTitle": "Không thể mở tệp", "couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa." + }, + "recentRecordings": { + "openFailed": "Could not open this recording. Its files may have been moved or deleted.", + "title": "Recent recordings", + "webcamBadge": "Includes webcam" } } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 58f6ae27b..d6cf2764a 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -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" } } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 8a6485409..e7ddfd779 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -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" } } }