From fc2cbeaeb68b04584b693c6d5335764a6ab14fff Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:20:17 +1000 Subject: [PATCH] Show Home loading states and save project edits automatically --- electron/ipc/project/firstFrameThumbnail.ts | 45 +++++++ electron/ipc/project/manager.test.ts | 25 ++++ electron/ipc/project/manager.ts | 22 +++- electron/main.ts | 60 ++++------ index.html | 83 +++++++++++-- src/App.tsx | 7 +- .../video-editor/dashboard/Dashboard.tsx | 2 - .../dashboard/DashboardDialogs.tsx | 87 -------------- .../dashboard/DashboardFilters.tsx | 6 +- .../video-editor/dashboard/DashboardGrid.tsx | 8 ++ .../video-editor/dashboard/FolderRow.tsx | 3 +- .../dashboard/LibrarySkeleton.tsx | 23 ++++ .../video-editor/dashboard/ProjectCard.tsx | 18 ++- .../dashboard/ProjectThumbnail.tsx | 6 + .../video-editor/dashboard/types.ts | 1 + .../dashboard/useDashboardModel.ts | 29 ++++- .../video-editor/layout/EditorDialogs.tsx | 15 ++- .../video-editor/layout/EditorHeader.tsx | 9 +- .../video-editor/layout/EditorShell.tsx | 1 + .../project/useProjectLibraryController.ts | 113 ++++++------------ .../project/useProjectOpenActions.ts | 26 ++-- .../project/useProjectSaveActions.ts | 18 +-- .../video-editor/state/useProjectState.ts | 3 + tests/ui/project-dashboard.spec.ts | 68 ++++++++--- tests/ui/startup-loading.spec.ts | 27 +++++ 25 files changed, 424 insertions(+), 281 deletions(-) create mode 100644 electron/ipc/project/firstFrameThumbnail.ts delete mode 100644 src/components/video-editor/dashboard/DashboardDialogs.tsx create mode 100644 src/components/video-editor/dashboard/LibrarySkeleton.tsx create mode 100644 tests/ui/startup-loading.spec.ts diff --git a/electron/ipc/project/firstFrameThumbnail.ts b/electron/ipc/project/firstFrameThumbnail.ts new file mode 100644 index 00000000..403f5e95 --- /dev/null +++ b/electron/ipc/project/firstFrameThumbnail.ts @@ -0,0 +1,45 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getFfmpegBinaryPath } from "../ffmpeg/binary"; +import { + PROJECT_THUMBNAIL_WIDTH, + PROJECT_THUMBNAIL_HEIGHT, +} from "../../../src/lib/projectThumbnail"; +const run = promisify(execFile); +let queue = Promise.resolve(); + +export function createProjectFirstFrameThumbnail(sourcePath: string): Promise { + const task = queue.then(async () => { + const result = await run( + getFfmpegBinaryPath(), + [ + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-threads", + "1", + "-i", + sourcePath, + "-frames:v", + "1", + "-an", + "-vf", + `scale=${PROJECT_THUMBNAIL_WIDTH}:${PROJECT_THUMBNAIL_HEIGHT}:force_original_aspect_ratio=increase,crop=${PROJECT_THUMBNAIL_WIDTH}:${PROJECT_THUMBNAIL_HEIGHT}`, + "-f", + "image2pipe", + "-c:v", + "png", + "pipe:1", + ], + { encoding: "buffer", maxBuffer: 4 * 1024 * 1024, timeout: 15000, windowsHide: true }, + ); + if (!result.stdout.length) throw new Error("Project source has no preview frame"); + return result.stdout; + }); + queue = task.then( + () => undefined, + () => undefined, + ); + return task; +} diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index c89dec32..120f357b 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -43,6 +43,7 @@ describe("local media path policy", () => { vi.resetModules(); vi.doUnmock("electron"); vi.doUnmock("../../mediaServer"); + vi.doUnmock("./firstFrameThumbnail"); if (tempRoot) { await fs.rm(tempRoot, { recursive: true, force: true }); } @@ -172,6 +173,30 @@ describe("local media path policy", () => { await expect(resolveApprovedLocalMediaPath(symlinkInsideUserData)).resolves.toBeNull(); }); + it("generates a missing project poster once and keeps it cached", async () => { + const png = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png); + png.writeUInt32BE(640, 16); + png.writeUInt32BE(480, 20); + const generate = vi.fn().mockResolvedValue(png); + vi.doMock("./firstFrameThumbnail", () => ({ createProjectFirstFrameThumbnail: generate })); + const manager = await import("./manager"); + const source = path.join(tempRoot, "source.mp4"); + const projectPath = path.join(tempRoot, "test.recordly"); + await fs.writeFile(source, "video"); + await fs.writeFile( + projectPath, + JSON.stringify({ version: 2, videoPath: source, editor: {} }), + ); + const before = await fs.readFile(projectPath, "utf8"); + const entry = await manager.buildProjectLibraryEntry(projectPath, tempRoot); + expect(entry?.thumbnailPath).toBe(manager.getProjectThumbnailPath(projectPath)); + expect(generate).toHaveBeenCalledWith(source); + await manager.buildProjectLibraryEntry(projectPath, tempRoot); + expect(generate).toHaveBeenCalledTimes(1); + expect(await fs.readFile(projectPath, "utf8")).toBe(before); + }); + it("preserves an existing project thumbnail when no replacement is provided", async () => { const projectPath = path.join(tempRoot, "Projects", "demo.recordly"); const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`; diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index ee32e20a..be9493d7 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -1,3 +1,4 @@ +import { createProjectFirstFrameThumbnail } from "./firstFrameThumbnail"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import type { ProjectPreviewData } from "../../../src/types/projectPreview"; import { hasFreshProjectThumbnail } from "./thumbnailFreshness"; @@ -338,7 +339,26 @@ export async function buildProjectLibraryEntry( } const thumbnailPath = getProjectThumbnailPath(normalizedPath); - const thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs); + let thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs); + if (!thumbnailExists) { + try { + const project = parseJsonWithByteOrderMark( + await fs.readFile(normalizedPath, "utf-8"), + ); + if (isLoadableProjectData(project)) { + const media = await resolveProjectMediaSources(project); + if (media.success) { + const thumbnail = await createProjectFirstFrameThumbnail(media.videoPath); + if (!(await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs))) { + await fs.writeFile(thumbnailPath, thumbnail); + } + thumbnailExists = true; + } + } + } catch { + // Missing/offline source media must not hide a project from the library. + } + } return { path: normalizedPath, diff --git a/electron/main.ts b/electron/main.ts index 417d4a05..b012ec6a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,7 +6,6 @@ import { app, BrowserWindow, desktopCapturer, - dialog, webContents as electronWebContents, ipcMain, Menu, @@ -428,10 +427,13 @@ function sendEditorMenuAction( targetWindow = mainWindow; if (!targetWindow || targetWindow.isDestroyed()) return; - targetWindow.webContents.once("did-finish-load", () => { + const send = () => { if (!targetWindow || targetWindow.isDestroyed()) return; targetWindow.webContents.send(channel); - }); + }; + if (targetWindow.webContents.isLoadingMainFrame()) + targetWindow.webContents.once("did-finish-load", send); + else send(); return; } @@ -811,6 +813,7 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; }); + let savingBeforeClose = false; editorWindow.on("close", (event) => { if (isForceClosing || !editorHasUnsavedChanges) { if (process.platform === "win32" && !isForceClosing && !isAppQuitting) { @@ -822,39 +825,25 @@ function createEditorWindowWrapper() { event.preventDefault(); - const choice = dialog.showMessageBoxSync(editorWindow, { - type: "warning", - buttons: ["Save & Close", "Discard & Close", "Cancel"], - defaultId: 0, - cancelId: 2, - title: "Unsaved Changes", - message: "You have unsaved changes.", - detail: "Do you want to save your project before closing?", - }); - - if (choice === 0) { - editorWindow.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_event, saved: boolean) => { - if (!saved) { - isAppQuitting = false; - return; - } - - if (process.platform === "win32" && !isAppQuitting) { - closeEditorWindowToHud(editorWindow); - } else { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); - } - }); - } else if (choice === 1) { - if (process.platform === "win32" && !isAppQuitting) { - closeEditorWindowToHud(editorWindow); - } else { - closeEditorWindowBypassingUnsavedPrompt(editorWindow); + if (editorWindow.webContents.isDestroyed() || savingBeforeClose) return; + savingBeforeClose = true; + const saved = (sender: Electron.IpcMainEvent, success: boolean) => { + if (sender.sender !== editorWindow.webContents) return; + ipcMain.removeListener("save-before-close-done", saved); + editorWindow.removeListener("closed", cancelPendingSave); + savingBeforeClose = false; + if (!success) { + isAppQuitting = false; + return; } - } else { - isAppQuitting = false; - } + if (process.platform === "win32" && !isAppQuitting) + closeEditorWindowToHud(editorWindow); + else closeEditorWindowBypassingUnsavedPrompt(editorWindow); + }; + const cancelPendingSave = () => ipcMain.removeListener("save-before-close-done", saved); + ipcMain.on("save-before-close-done", saved); + editorWindow.once("closed", cancelPendingSave); + editorWindow.webContents.send("request-save-before-close"); }); return editorWindow; @@ -889,6 +878,7 @@ app.on("window-all-closed", () => { }); app.on("activate", () => { + if (getHudOverlayWindow()?.isVisible()) return; // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. focusOrCreateMainWindow(); diff --git a/index.html b/index.html index c36792f9..b5a85511 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,71 @@ - - - - - - - Recordly - - -
- - - + + + + + + + Recordly + + + + +
+
+ + + +
+
+ + + diff --git a/src/App.tsx b/src/App.tsx index 86d7a6bf..43a83e77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { Card } from "@heroui/react"; import { lazy, Suspense, useEffect, useState } from "react"; import { useI18n } from "./contexts/I18nContext"; +import { EditorLoadingSkeleton } from "./components/video-editor/layout/EditorLoadingSkeleton"; const HudWindow = lazy(() => import("./components/launch/HudWindow")); const SourceSelector = lazy(() => @@ -99,5 +100,9 @@ export default function App() { ); } - return {content}; + return ( + : null}> + {content} + + ); } diff --git a/src/components/video-editor/dashboard/Dashboard.tsx b/src/components/video-editor/dashboard/Dashboard.tsx index fbd063d4..cd74ad29 100644 --- a/src/components/video-editor/dashboard/Dashboard.tsx +++ b/src/components/video-editor/dashboard/Dashboard.tsx @@ -1,6 +1,5 @@ import { DashboardAnnouncements } from "./DashboardAnnouncements"; import { Card, Modal } from "@heroui/react"; -import { DashboardDialogs } from "./DashboardDialogs"; import { DashboardFilters } from "./DashboardFilters"; import { DashboardGrid } from "./DashboardGrid"; import { DashboardSidebar } from "./DashboardSidebar"; @@ -34,7 +33,6 @@ export function Dashboard(props: DashboardProps) { - ); } diff --git a/src/components/video-editor/dashboard/DashboardDialogs.tsx b/src/components/video-editor/dashboard/DashboardDialogs.tsx deleted file mode 100644 index b0cc4e3b..00000000 --- a/src/components/video-editor/dashboard/DashboardDialogs.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { removeProjectShareLinks } from "../cloud/projectShareLinks"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -import type { DashboardProps } from "./types"; - -import type { DashboardModel } from "./useDashboardModel"; - -export function DashboardDialogs({ - isRaw, - folders, - busy, - confirmDelete, - setConfirmDelete, - selected, - run, - onDeleteProjects, - save, - setSelected, - setSelecting, -}: Pick< - DashboardProps & DashboardModel, - | "isRaw" - | "folders" - | "busy" - | "confirmDelete" - | "setConfirmDelete" - | "selected" - | "run" - | "onDeleteProjects" - | "save" - | "setSelected" - | "setSelecting" ->) { - return ( - <> - - - - - {isRaw ? "Remove" : "Delete"} {selected.length}{" "} - {isRaw ? "raw file" : "project"} - {selected.length === 1 ? "" : "s"}? - - -

- {isRaw - ? "Remove these files from the library. Original files stay on disk so existing projects keep working." - : "Project files move to Trash. Source recordings are kept."} -

- - - - -
-
- - ); -} diff --git a/src/components/video-editor/dashboard/DashboardFilters.tsx b/src/components/video-editor/dashboard/DashboardFilters.tsx index 6d66b65a..00efd102 100644 --- a/src/components/video-editor/dashboard/DashboardFilters.tsx +++ b/src/components/video-editor/dashboard/DashboardFilters.tsx @@ -18,7 +18,7 @@ export function DashboardFilters({ setSort, visible, busy, - setConfirmDelete, + deleteEntries, }: Pick< DashboardModel & DashboardProps, | "isRaw" @@ -32,7 +32,7 @@ export function DashboardFilters({ | "setSort" | "visible" | "busy" - | "setConfirmDelete" + | "deleteEntries" >) { return ( <> @@ -119,7 +119,7 @@ export function DashboardFilters({ variant="destructive" size="sm" disabled={!selected.length || busy} - onClick={() => setConfirmDelete(true)} + onClick={() => void deleteEntries(selected)} > {isRaw ? "Remove" : "Delete"} diff --git a/src/components/video-editor/dashboard/DashboardGrid.tsx b/src/components/video-editor/dashboard/DashboardGrid.tsx index d14d271c..7e86c4e3 100644 --- a/src/components/video-editor/dashboard/DashboardGrid.tsx +++ b/src/components/video-editor/dashboard/DashboardGrid.tsx @@ -1,3 +1,4 @@ +import { LibrarySkeleton } from "./LibrarySkeleton"; import { RecordNewButton } from "./RecordNewButton"; import { RawPreview } from "./RawRecordings"; import { Cloud, ImageSquare } from "@/components/ui/icons"; @@ -13,6 +14,8 @@ import type { DashboardModel } from "./useDashboardModel"; export function DashboardGrid({ onImportFile, + loading, + deleteEntries, isRaw, rawPreview, setRawPreview, @@ -40,6 +43,8 @@ export function DashboardGrid({ run, }: Pick< DashboardProps & DashboardModel, + | "loading" + | "deleteEntries" | "onImportFile" | "isRaw" | "rawPreview" @@ -89,6 +94,8 @@ export function DashboardGrid({ {accountLabel ? "Account" : "Sign in"} + ) : (isRaw ? rawLoading : loading) ? ( + ) : visible.length ? (
    ( void deleteEntries([entry.path])} {...{ accountLabel, entry, diff --git a/src/components/video-editor/dashboard/FolderRow.tsx b/src/components/video-editor/dashboard/FolderRow.tsx index 7d2c74e8..7c78e0a0 100644 --- a/src/components/video-editor/dashboard/FolderRow.tsx +++ b/src/components/video-editor/dashboard/FolderRow.tsx @@ -30,7 +30,7 @@ export function FolderRow({ }; const edit = () => { setDraft(folder.name); - setEditing(true); + requestAnimationFrame(() => requestAnimationFrame(() => setEditing(true))); }; return (
    event.currentTarget.select()} aria-label="Folder name" className="h-8 w-full min-w-0 border-0 bg-transparent px-0 text-[13px] shadow-none" value={draft} diff --git a/src/components/video-editor/dashboard/LibrarySkeleton.tsx b/src/components/video-editor/dashboard/LibrarySkeleton.tsx new file mode 100644 index 00000000..6fd2713c --- /dev/null +++ b/src/components/video-editor/dashboard/LibrarySkeleton.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@heroui/react"; +export function LibrarySkeleton() { + return ( +
    + {[0, 1, 2].map((key) => ( +
    + +
    + +
    + + +
    +
    +
    + ))} +
    + ); +} diff --git a/src/components/video-editor/dashboard/ProjectCard.tsx b/src/components/video-editor/dashboard/ProjectCard.tsx index 22688065..a0c8a70d 100644 --- a/src/components/video-editor/dashboard/ProjectCard.tsx +++ b/src/components/video-editor/dashboard/ProjectCard.tsx @@ -25,9 +25,10 @@ type Props = Pick< | "folders" | "save" | "assignFolder" -> & { entry: ProjectLibraryEntry }; +> & { entry: ProjectLibraryEntry; onDelete?: () => void }; export function ProjectCard({ accountLabel, + onDelete, entry, busy, selecting, @@ -119,6 +120,7 @@ export function ProjectCard({ > event.currentTarget.select()} aria-label={entry.rawSource ? "Raw file name" : "Project name"} className="inline-project-name h-5 w-full pr-8 text-[12px] font-medium" value={name} @@ -196,7 +198,12 @@ export function ProjectCard({ Create a folder in the sidebar )} - + {onDelete && ( + + {entry.rawSource ? "Remove from library" : "Delete project"} + + )} +
    @@ -219,7 +226,7 @@ export function ProjectCard({ id="rename" onAction={() => { setName(entry.name); - setEditing(true); + requestAnimationFrame(() => requestAnimationFrame(() => setEditing(true))); }} > Rename @@ -251,6 +258,11 @@ export function ProjectCard({ > Show in folder + {onDelete && ( + + {entry.rawSource ? "Remove from library" : "Delete project"} + + )} diff --git a/src/components/video-editor/dashboard/ProjectThumbnail.tsx b/src/components/video-editor/dashboard/ProjectThumbnail.tsx index 653946a2..83083017 100644 --- a/src/components/video-editor/dashboard/ProjectThumbnail.tsx +++ b/src/components/video-editor/dashboard/ProjectThumbnail.tsx @@ -1,3 +1,4 @@ +import { Skeleton } from "@heroui/react"; import type { ProjectPreviewData } from "@/types/projectPreview"; import { ProjectHoverPreview } from "./ProjectHoverPreview"; import { ImageSquare } from "@/components/ui/icons"; @@ -14,6 +15,7 @@ export function ProjectThumbnail({ previewActive?: boolean; revision?: number; }) { + const [loadedSource, setLoadedSource] = useState(null); const [failedSource, setFailedSource] = useState(null); const [preview, setPreview] = useState(null); const host = useRef(null); @@ -54,6 +56,9 @@ export function ProjectThumbnail({ ref={host} className="relative flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60" > + {path && loadedSource !== sourceKey && failedSource !== sourceKey && ( + + )} {path && failedSource !== sourceKey ? ( setFailedSource(sourceKey)} + onLoad={() => setLoadedSource(sourceKey)} className="h-full w-full object-cover" /> ) : ( diff --git a/src/components/video-editor/dashboard/types.ts b/src/components/video-editor/dashboard/types.ts index 0f78f692..0348c59a 100644 --- a/src/components/video-editor/dashboard/types.ts +++ b/src/components/video-editor/dashboard/types.ts @@ -1,6 +1,7 @@ import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; export type DashboardProps = { open: boolean; + loading?: boolean; onOpenChange: (open: boolean) => void; entries: ProjectLibraryEntry[]; onOpenProject: (path: string) => Promise; diff --git a/src/components/video-editor/dashboard/useDashboardModel.ts b/src/components/video-editor/dashboard/useDashboardModel.ts index 840a8c8d..0f87549f 100644 --- a/src/components/video-editor/dashboard/useDashboardModel.ts +++ b/src/components/video-editor/dashboard/useDashboardModel.ts @@ -1,5 +1,6 @@ +import { removeProjectShareLinks } from "../cloud/projectShareLinks"; import { useRawLibrary } from "./useRawLibrary"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "@/components/ui/toast"; import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; import type { DashboardProps } from "./types"; @@ -16,7 +17,6 @@ export function useDashboardModel({ const { metadata, update } = useDashboardMetadata(); const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState([]); - const [confirmDelete, setConfirmDelete] = useState(false); const toggleSelected = (path: string) => setSelected((prev) => prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], @@ -41,6 +41,14 @@ export function useDashboardModel({ const [sort, setSort] = useState("recent"); const [section, changeSection] = useState("projects"); const [library, setLibrary] = useState("projects"); + useEffect(() => { + if (!open) return; + changeSection("projects"); + setLibrary("projects"); + setQuery(""); + setSelecting(false); + setSelected([]); + }, [open]); const raw = useRawLibrary(open && library === "raw"); const [rawPreview, setRawPreview] = useState(null); const isRaw = library === "raw"; @@ -90,6 +98,20 @@ export function useDashboardModel({ setBusy(false); } }; + const deleteEntries = (paths: string[]) => + run(async () => { + const deleted = await (isRaw ? raw.remove : onDeleteProjects)(paths); + removeProjectShareLinks(deleted); + save( + folders.map((folder) => ({ + ...folder, + paths: folder.paths.filter((path) => !deleted.includes(path)), + })), + ); + setSelected((current) => current.filter((path) => !deleted.includes(path))); + if (selected.every((path) => deleted.includes(path))) setSelecting(false); + }); + const openEntry = (entry: ProjectLibraryEntry) => entry.rawSource ? setRawPreview(entry) @@ -116,8 +138,7 @@ export function useDashboardModel({ setSelecting, selected, setSelected, - confirmDelete, - setConfirmDelete, + deleteEntries, toggleSelected, assignFolder, query, diff --git a/src/components/video-editor/layout/EditorDialogs.tsx b/src/components/video-editor/layout/EditorDialogs.tsx index 8f8a8d26..b29c0bf2 100644 --- a/src/components/video-editor/layout/EditorDialogs.tsx +++ b/src/components/video-editor/layout/EditorDialogs.tsx @@ -38,11 +38,12 @@ interface EditorDialogsProps { projectBrowserOpen: boolean; setProjectBrowserOpen: Dispatch>; projectLibraryEntries: ProjectLibraryEntry[]; + projectLibraryLoading: boolean; projectError: string | null; onDashboardSignIn: () => void; onDeleteProjects: (paths: string[]) => Promise; - onRenameProject: (path: string, name: string) => Promise; - onShareProject: (path: string) => Promise; + onRenameProject: (path: string, name: string) => Promise; + onShareProject: (path: string) => Promise; accountLabel?: string; handleImportMediaOrProject: () => Promise; handleOpenProjectFromLibrary: (projectPath: string) => Promise; @@ -67,11 +68,12 @@ export function EditorDialogs({ projectBrowserOpen, setProjectBrowserOpen, projectLibraryEntries, + projectLibraryLoading, projectError, onDashboardSignIn, onDeleteProjects, - onRenameProject, - onShareProject, + onRenameProject, + onShareProject, accountLabel, handleImportMediaOrProject, handleOpenProjectFromLibrary, @@ -185,11 +187,12 @@ export function EditorDialogs({ open={projectBrowserOpen} onOpenChange={setProjectBrowserOpen} entries={projectLibraryEntries} + loading={projectLibraryLoading} error={projectError} onSignIn={onDashboardSignIn} onDeleteProjects={onDeleteProjects} - onRenameProject={onRenameProject} - onShareProject={onShareProject} + onRenameProject={onRenameProject} + onShareProject={onShareProject} accountLabel={accountLabel} onImportFile={handleImportMediaOrProject} onOpenProject={handleOpenProjectFromLibrary} diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx index 123526c7..4059976f 100644 --- a/src/components/video-editor/layout/EditorHeader.tsx +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -68,7 +68,6 @@ export function EditorHeader(props: Props) { projectBrowserTriggerRef, projectNameInputRef, projectDisplayName, - hasUnsavedChanges, canUndo, canRedo, handleOpenProjectBrowser, @@ -139,9 +138,6 @@ export function EditorHeader(props: Props) { onSubmit={(event) => void handleProjectNameSubmit(event)} className="flex w-full min-w-0 items-center gap-1.5 px-1" > - {hasUnsavedChanges ? ( - - ) : null} setIsEditingProjectName(true)} - className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 px-1" + className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2" title={t("editor.project.renameTitle", "Rename project")} aria-label={t("editor.project.renameTitle", "Rename project")} > - {hasUnsavedChanges ? ( - - ) : null} {projectDisplayName} diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index d7a6037c..25a8c7cb 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -165,6 +165,7 @@ export function EditorShell(props: Props) { projectBrowserOpen={project.projectBrowserOpen} setProjectBrowserOpen={project.setProjectBrowserOpen} projectLibraryEntries={project.projectLibraryEntries} + projectLibraryLoading={project.projectLibraryLoading} projectError={project.error} onDashboardSignIn={() => requestSignIn("account")} onDeleteProjects={openActions.handleDeleteProjects} diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index f789678e..f7037c78 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -1,5 +1,5 @@ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */ -import { type RefObject, useCallback, useEffect, useRef } from "react"; +import { type RefObject, useCallback } from "react"; import { PROJECT_THUMBNAIL_WIDTH, PROJECT_THUMBNAIL_HEIGHT } from "@/lib/projectThumbnail"; import { FrameRenderer } from "@/lib/exporter/frameRenderer"; import { toFileUrl } from "../projectPersistence"; @@ -24,14 +24,9 @@ export function useProjectLibraryController({ appearance, timeline, videoPlaybackRef, - currentTime, effectiveShowCursor, }: Input) { - const currentTimeRef = useRef(currentTime); - useEffect(() => { - currentTimeRef.current = currentTime; - }, [currentTime]); - const { setProjectLibraryEntries } = project; + const { setProjectLibraryEntries, setProjectLibraryLoading } = project; const { backgroundBlur, borderRadius, @@ -83,6 +78,7 @@ export function useProjectLibraryController({ zoomRegions, } = timeline; const refreshProjectLibrary = useCallback(async () => { + setProjectLibraryLoading(true); try { const result = await window.electronAPI.listProjectFiles(); if (!result.success) { @@ -92,56 +88,57 @@ export function useProjectLibraryController({ setProjectLibraryEntries(result.entries); } catch (projectLibraryError) { console.warn("Unable to refresh project library:", projectLibraryError); + } finally { + setProjectLibraryLoading(false); } }, []); const captureProjectThumbnail = useCallback(async () => { const previewHandle = videoPlaybackRef.current; const previewVideo = previewHandle?.video ?? null; - const previewCanvas = previewHandle?.app?.canvas ?? null; - if (previewHandle && previewVideo && previewVideo.paused) { - try { - await previewHandle.refreshFrame(); - await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); - } catch (thumbnailRefreshError) { - console.warn( - "Unable to refresh preview frame before thumbnail capture:", - thumbnailRefreshError, - ); - } - } - - const canvas = document.createElement("canvas"); const targetWidth = PROJECT_THUMBNAIL_WIDTH; const targetHeight = PROJECT_THUMBNAIL_HEIGHT; - canvas.width = targetWidth; - canvas.height = targetHeight; - - const context = canvas.getContext("2d"); - if (!context) { - return null; - } - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "high"; - const editorBgHsl = getComputedStyle(document.documentElement) - .getPropertyValue("--editor-bg") - .trim(); - context.fillStyle = editorBgHsl ? `hsl(${editorBgHsl})` : "#111113"; - context.fillRect(0, 0, targetWidth, targetHeight); const previewWidth = previewHandle?.containerRef.current?.clientWidth || 1920; const previewHeight = previewHandle?.containerRef.current?.clientHeight || 1080; - const frameTimestampUs = Math.max(0, Math.round(currentTimeRef.current * 1_000_000)); + const frameTimestampUs = 0; if (previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { let videoFrame: VideoFrame | null = null; let frameRenderer: FrameRenderer | null = null; + const thumbnailVideo = document.createElement("video"); try { - const sourceTimestampUs = previewVideo.currentTime * 1_000_000; + const firstClip = findPreviewClipAtTimelineTime(0, clipRegions); + const sourceSeconds = firstClip ? getClipSourceStartMs(firstClip) / 1000 : 0; + thumbnailVideo.muted = true; + thumbnailVideo.preload = "auto"; + await new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => finish(new Error("Thumbnail decode timed out")), + 10000, + ); + const finish = (error?: Error) => { + clearTimeout(timeout); + thumbnailVideo.onloadeddata = null; + thumbnailVideo.onseeked = null; + thumbnailVideo.onerror = null; + error ? reject(error) : resolve(); + }; + thumbnailVideo.onerror = () => + finish(new Error("Thumbnail source unavailable")); + thumbnailVideo.onloadeddata = () => { + if (sourceSeconds > 0) { + thumbnailVideo.onseeked = () => finish(); + thumbnailVideo.currentTime = sourceSeconds; + } else finish(); + }; + thumbnailVideo.src = previewVideo.currentSrc || previewVideo.src; + }); + const sourceTimestampUs = sourceSeconds * 1_000_000; if (findPreviewClipAtTimelineTime(frameTimestampUs / 1000, clipRegions)) { - videoFrame = new VideoFrame(previewVideo, { timestamp: sourceTimestampUs }); + videoFrame = new VideoFrame(thumbnailVideo, { timestamp: sourceTimestampUs }); } frameRenderer = new FrameRenderer({ timelineEffects: true, @@ -238,46 +235,12 @@ export function useProjectLibraryController({ } finally { videoFrame?.close(); frameRenderer?.destroy(); + thumbnailVideo.removeAttribute("src"); + thumbnailVideo.load(); } } - const drawableSource = - previewCanvas && previewCanvas.width > 0 && previewCanvas.height > 0 - ? previewCanvas - : previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0 - ? previewVideo - : null; - - if (!drawableSource) { - return null; - } - - const sourceWidth = - drawableSource instanceof HTMLVideoElement - ? drawableSource.videoWidth - : drawableSource.width; - const sourceHeight = - drawableSource instanceof HTMLVideoElement - ? drawableSource.videoHeight - : drawableSource.height; - - if (sourceWidth <= 0 || sourceHeight <= 0) { - return null; - } - - const scale = Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight); - const drawWidth = Math.round(sourceWidth * scale); - const drawHeight = Math.round(sourceHeight * scale); - const offsetX = Math.round((targetWidth - drawWidth) / 2); - const offsetY = Math.round((targetHeight - drawHeight) / 2); - - try { - context.drawImage(drawableSource, offsetX, offsetY, drawWidth, drawHeight); - return canvas.toDataURL("image/png"); - } catch (thumbnailError) { - console.warn("Unable to capture project thumbnail:", thumbnailError); - return null; - } + return null; }, [ annotationRegions, autoCaptionSettings, diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 33ea8f3f..b08c4a2a 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -45,7 +45,6 @@ export function useProjectOpenActions({ setCurrentTime, setDuration, applyLoadedProject, - openUnsavedChangesDialog, saveProject, refreshProjectLibrary, resetSourceScopedEditorState, @@ -54,14 +53,11 @@ export function useProjectOpenActions({ handleSaveProjectAs, }: UseProjectOpenActionsInput) { const confirmReplaceSourceWithUnsavedChanges = useCallback( - async (actionLabel: string) => { + async (_actionLabel: string) => { if (!hasUnsavedChanges) return true; - const decision = await openUnsavedChangesDialog(actionLabel); - if (decision === "discard") return true; - if (decision === "save") return saveProject(false); - return false; + return saveProject(false, { remountPreviewAfterSave: false }); }, - [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], + [hasUnsavedChanges, saveProject], ); const handleOpenProjectFromLibrary = useCallback( @@ -180,14 +176,22 @@ export function useProjectOpenActions({ } videoPlaybackRef.current?.pause(); setIsPlaying(false); - if (project.videoPath && !project.error) { - await saveProject(false, { remountPreviewAfterSave: false }); - } + project.setProjectLibraryLoading(true); project.setProjectBrowserOpen(true); - void refreshProjectLibrary(); + try { + if (project.videoPath && !project.error) { + await saveProject(false, { + remountPreviewAfterSave: false, + refreshLibraryAfterSave: false, + }); + } + } finally { + await refreshProjectLibrary(); + } }, [ project.projectBrowserOpen, project.setProjectBrowserOpen, + project.setProjectLibraryLoading, refreshProjectLibrary, videoPlaybackRef, setIsPlaying, diff --git a/src/components/video-editor/project/useProjectSaveActions.ts b/src/components/video-editor/project/useProjectSaveActions.ts index 85a12cbc..64608c47 100644 --- a/src/components/video-editor/project/useProjectSaveActions.ts +++ b/src/components/video-editor/project/useProjectSaveActions.ts @@ -6,7 +6,7 @@ import { createProjectData, type EditorProjectData } from "../projectPersistence import type { useProjectState } from "../state/useProjectState"; import { cloneStructured, getErrorMessage } from "../videoEditorUtils"; -const PROJECT_AUTOSAVE_DELAY_MS = 750; +const PROJECT_AUTOSAVE_DELAY_MS = 500; type SaveProjectOptions = { silent?: boolean; @@ -184,12 +184,7 @@ export function useProjectSaveActions({ [saveProject], ); useEffect(() => { - if ( - project.projectBrowserOpen || - project.loading || - !currentSourcePath || - !hasUnsavedChanges - ) { + if (project.loading || !currentSourcePath || !hasUnsavedChanges) { clearPendingAutosave(); return; } @@ -203,14 +198,7 @@ export function useProjectSaveActions({ }); }, PROJECT_AUTOSAVE_DELAY_MS); return clearPendingAutosave; - }, [ - clearPendingAutosave, - hasUnsavedChanges, - saveProject, - project.projectBrowserOpen, - project.loading, - currentSourcePath, - ]); + }, [clearPendingAutosave, hasUnsavedChanges, saveProject, project.loading, currentSourcePath]); useEffect(() => clearPendingAutosave, [clearPendingAutosave]); const saveProjectWithName = useCallback( diff --git a/src/components/video-editor/state/useProjectState.ts b/src/components/video-editor/state/useProjectState.ts index 1b5a3d7a..3542f569 100644 --- a/src/components/video-editor/state/useProjectState.ts +++ b/src/components/video-editor/state/useProjectState.ts @@ -6,6 +6,7 @@ export function useProjectState() { const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); + const [projectLibraryLoading, setProjectLibraryLoading] = useState(true); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); const [isEditingProjectName, setIsEditingProjectName] = useState(false); @@ -28,6 +29,8 @@ export function useProjectState() { setVideoSourcePath, currentProjectPath, setCurrentProjectPath, + projectLibraryLoading, + setProjectLibraryLoading, projectLibraryEntries, setProjectLibraryEntries, projectBrowserOpen, diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index cff055b3..3b44dc26 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -4,12 +4,43 @@ import { hasFreshProjectThumbnail } from "../../electron/ipc/project/thumbnailFr import { expect, test } from "@playwright/test"; import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge"; +test("Home opens with a wireframe while saving and returns to the main library", async ({ page }) => { + await installDesktopBridge(page); + await page.goto("/?windowType=editor"); + await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1"); + await page.evaluate(() => { + const save = window.electronAPI.saveProjectFile; + window.electronAPI.saveProjectFile = async (...args) => { + await new Promise((resolve) => { + window.addEventListener("test-release-save", () => resolve(), { once: true }); + document.documentElement.dataset.savePending = "true"; + }); + return save(...args); + }; + }); + await page.getByRole("button", { name: "Home", exact: true }).click(); + const home = page.getByRole("dialog", { name: "Projects dashboard" }); + await expect(home).toBeVisible(); + await expect(home.getByLabel("Loading projects")).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-save-pending", "true"); + await page.screenshot({ path: "test-results/home-loading.png", animations: "disabled" }); + await page.evaluate(() => window.dispatchEvent(new Event("test-release-save"))); + await expect(home.getByLabel("Loading projects")).toHaveCount(0); + await home.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(home).not.toBeVisible(); + await page.getByRole("button", { name: "Home", exact: true }).click(); + await expect(home.getByRole("textbox", { name: "Search projects" })).toBeVisible(); + await expect(home.getByLabel("Loading projects")).toBeVisible(); +}); + test("home dashboard searches, sorts, opens projects and returns to the editor", async ({ page, }) => { await installDesktopBridge(page); await page.addInitScript(() => { - const entries = [ + let entries = [ { path: "/projects/launch.recordly", name: "Launch video", @@ -40,6 +71,10 @@ test("home dashboard searches, sorts, opens projects and returns to the editor", projects: [], entries, }); + window.electronAPI.trashProjectFiles = async (paths) => { + entries = entries.filter((entry) => !paths.includes(entry.path)); + return { success: true, deleted: paths, errors: [] }; + }; window.electronAPI.openProjectFileAtPath = async (path) => { document.documentElement.dataset.openedProject = path; return { success: false, canceled: true }; @@ -104,18 +139,17 @@ test("home dashboard searches, sorts, opens projects and returns to the editor", await home.getByRole("button", { name: "Settings", exact: true }).click(); await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible(); await home.getByRole("button", { name: "Home", exact: true }).click(); - await home.getByRole("complementary", { name: "Library navigation" }).getByRole("button", { name: "Record new", exact: true }).click(); + await home + .getByRole("complementary", { name: "Library navigation" }) + .getByRole("button", { name: "Record new", exact: true }) + .click(); await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true"); await home.getByRole("button", { name: "Select projects to delete" }).click(); await cards.first().click(); await expect(cards.first()).toHaveAttribute("aria-pressed", "true"); await home.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).toBeVisible(); - await page - .getByRole("dialog", { name: "Delete 1 project?" }) - .getByRole("button", { name: "Cancel", exact: true }) - .click(); - await home.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).not.toBeVisible(); + await expect(cards).toHaveCount(2); await page.screenshot({ path: "test-results/project-dashboard.png", animations: "disabled" }); await page.evaluate(() => document.documentElement.classList.add("dark")); await page.screenshot({ @@ -241,10 +275,7 @@ test("deletion refreshes the grid and keeps unselected projects", async ({ page .filter({ hasNot: page.locator("img") }) .first() .click(); - await page - .getByRole("dialog", { name: "Delete 1 project?" }) - .getByRole("button", { name: "Move to Trash" }) - .click(); + await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).not.toBeVisible(); await expect( home.getByRole("list").getByRole("button", { name: "Delete", exact: true }), ).toHaveCount(0); @@ -294,6 +325,14 @@ test("cards rename inline, preserve folder chips and use existing share links", ); await home.getByRole("button", { name: "Options for Demo" }).click(); await page.getByRole("menuitem", { name: "Rename", exact: true }).click(); + const renameInput = home.getByRole("textbox", { name: "Project name" }); + await expect(renameInput).toBeFocused(); + expect( + await renameInput.evaluate((input: HTMLInputElement) => [ + input.selectionStart, + input.selectionEnd, + ]), + ).toEqual([0, 4]); await home.getByRole("textbox", { name: "Project name" }).fill("Renamed"); await page.keyboard.press("Enter"); await expect(home.getByRole("button", { name: "Renamed", exact: true })).toBeVisible(); @@ -511,7 +550,6 @@ test("Solar navigation selection, circular initials, and Raw sources are consist await home.getByRole("button", { name: "Select raw files to remove" }).click(); await home.getByRole("button", { name: "Original take", exact: true }).click(); await home.getByRole("button", { name: "Remove", exact: true }).click(); - await page.getByRole("button", { name: "Remove from library", exact: true }).click(); await expect(home.getByRole("button", { name: "Original take", exact: true })).toHaveCount(0); await home.getByRole("button", { name: "Raw", exact: true }).click(); }); @@ -708,9 +746,7 @@ test("sidebar cards, separate Import, and shortcut settings use the dashboard fl const settings = sidebar.getByRole("button", { name: "Settings", exact: true }); const importButton = home.getByRole("button", { name: "Import", exact: true }); const all = home.getByRole("button", { name: "All", exact: true }); - expect( - (await importButton.boundingBox())!.y - (await all.boundingBox())!.y, - ).toBeLessThan(0); + expect((await importButton.boundingBox())!.y - (await all.boundingBox())!.y).toBeLessThan(0); expect(await all.evaluate((element) => element.parentElement!.textContent)).not.toContain( "Import", ); diff --git a/tests/ui/startup-loading.spec.ts b/tests/ui/startup-loading.spec.ts new file mode 100644 index 00000000..5c89457a --- /dev/null +++ b/tests/ui/startup-loading.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge } from "./bridge"; + +test("editor paints a wireframe before JavaScript loads", async ({ page }) => { + await page.route("**/src/main.tsx*", (route) => route.abort()); + await page.goto("/?windowType=editor"); + await expect(page.getByRole("status", { name: "Loading editor" })).toBeVisible(); + await expect(page.locator(".boot-preview i").first()).toBeVisible(); + await page.goto("/?windowType=hud-overlay"); + await expect(page.locator(".boot-shell")).not.toBeVisible(); +}); + +test("wireframe stays visible while the editor module loads, then gives way to the app", async ({ page }) => { + await installDesktopBridge(page); + let release!: () => void; + const paused = new Promise((resolve) => { release = resolve; }); + await page.route("**/components/video-editor/EditorWindow.tsx*", async (route) => { + await paused; + await route.continue(); + }); + await page.goto("/?windowType=editor", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".boot-shell")).toHaveCount(0); + await expect(page.getByRole("status", { name: "Loading editor" })).toBeVisible(); + release(); + await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible(); + await expect(page.getByRole("status", { name: "Loading editor" })).toHaveCount(0); +});