Show Home loading states and save project edits automatically

This commit is contained in:
webadderall committed 2026-10-03 10:20:17 +10:00
1 parent 74ad7b3107
commit fc2cbeaeb6
25 files changed
+424 -281

No files matched your search

@@ -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<Buffer> {
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;
}
+25
View File
@@ -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")}`;
+21 -1
View File
@@ -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,
+25 -35
View File
@@ -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();
+70 -13
View File
@@ -1,14 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/app-icons/recordly-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recordly</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/app-icons/recordly-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recordly</title>
<script>
// Paint the editor shell before the renderer bundle is downloaded or evaluated.
// Keep recording overlays transparent during their own startup.
document.documentElement.dataset.windowType =
new URLSearchParams(location.search).get("windowType") || "";
if (document.documentElement.dataset.windowType === "editor") {
let preference = "system";
try {
preference = window.electronAPI?.getAppSetting("recordly.theme") ||
localStorage.getItem("recordly.theme") || "system";
} catch {}
document.documentElement.dataset.theme =
preference === "dark" ||
(preference !== "light" && matchMedia("(prefers-color-scheme: dark)").matches)
? "dark" : "light";
}
</script>
<style>
.boot-shell { display: none; }
html[data-window-type="editor"] { background: #f6f6f7; }
html[data-window-type="editor"][data-theme="dark"] { background: #171719; }
html[data-window-type="editor"] .boot-shell {
--boot-block: #e5e5e9;
position: fixed; inset: 0; display: flex; flex-direction: column;
padding: 0 20px 20px; gap: 16px; box-sizing: border-box;
background: #f6f6f7; overflow: hidden;
}
html[data-theme="dark"] .boot-shell { --boot-block: #303034; background: #171719; }
.boot-shell i { display: block; background: var(--boot-block); border-radius: 8px; }
.boot-header { height: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; }
.boot-header i { width: 96px; height: 28px; }
.boot-header i:nth-child(2) { width: 144px; height: 16px; }
.boot-body { display: flex; flex: 1; min-height: 0; gap: 20px; }
.boot-rail { width: 28px; display: flex; flex-direction: column; gap: 16px; }
.boot-rail i { height: 32px; flex-shrink: 0; }
.boot-settings { width: min(28vw, 320px); padding: 20px; display: flex; flex-direction: column; gap: 24px; box-sizing: border-box; }
.boot-settings i { height: 20px; }
.boot-settings i:nth-child(3) { height: 140px; }
.boot-preview { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.boot-preview i:first-child { flex: 1; }
.boot-preview i:last-child { width: 160px; height: 32px; margin: auto; border-radius: 20px; }
.boot-timeline { height: 22vh; min-height: 140px; max-height: 240px; display: flex; flex-direction: column; gap: 14px; }
.boot-timeline i { height: 24px; width: 240px; }
.boot-timeline i:nth-child(2) { height: 12px; width: 100%; }
.boot-timeline i:nth-child(3) { height: 54px; width: 80%; }
.boot-timeline i:nth-child(4) { width: 33%; }
</style>
</head>
<body>
<div id="root">
<div class="boot-shell" role="status" aria-label="Loading editor" aria-busy="true">
<div class="boot-header" aria-hidden="true"><i></i><i></i><i></i></div>
<div class="boot-body" aria-hidden="true">
<div class="boot-rail"><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="boot-settings"><i></i><i></i><i></i><i></i><i></i></div>
<div class="boot-preview"><i></i><i></i></div>
</div>
<div class="boot-timeline" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6 -1
View File
@@ -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 <Suspense fallback={null}>{content}</Suspense>;
return (
<Suspense fallback={windowType === "editor" ? <EditorLoadingSkeleton /> : null}>
{content}
</Suspense>
);
}
@@ -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) {
</Modal.Container>
</Modal.Backdrop>
</Modal>
<DashboardDialogs {...view} />
</>
);
}
@@ -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 (
<>
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{isRaw ? "Remove" : "Delete"} {selected.length}{" "}
{isRaw ? "raw file" : "project"}
{selected.length === 1 ? "" : "s"}?
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{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."}
</p>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={busy}
onClick={() =>
void run(async () => {
const deleted = await onDeleteProjects(selected);
removeProjectShareLinks(deleted);
save(
folders.map((f) => ({
...f,
paths: f.paths.filter((p) => !deleted.includes(p)),
})),
);
setSelected(selected.filter((p) => !deleted.includes(p)));
setConfirmDelete(false);
if (deleted.length === selected.length) setSelecting(false);
})
}
>
{isRaw ? "Remove from library" : "Move to Trash"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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"}
</Button>
@@ -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"}
</Button>
</div>
) : (isRaw ? rawLoading : loading) ? (
<LibrarySkeleton />
) : visible.length ? (
<ul
aria-label={isRaw ? "Raw files" : "Your projects"}
@@ -97,6 +104,7 @@ export function DashboardGrid({
{visible.map((entry) => (
<ProjectCard
key={entry.path}
onDelete={() => void deleteEntries([entry.path])}
{...{
accountLabel,
entry,
@@ -30,7 +30,7 @@ export function FolderRow({
};
const edit = () => {
setDraft(folder.name);
setEditing(true);
requestAnimationFrame(() => requestAnimationFrame(() => setEditing(true)));
};
return (
<div
@@ -53,6 +53,7 @@ export function FolderRow({
>
<Input
autoFocus
onFocus={(event) => 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}
@@ -0,0 +1,23 @@
import { Skeleton } from "@heroui/react";
export function LibrarySkeleton() {
return (
<div
aria-label="Loading projects"
aria-busy="true"
className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
>
{[0, 1, 2].map((key) => (
<div key={key} className="space-y-4">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4 rounded" />
<Skeleton className="h-3 w-1/3 rounded" />
</div>
</div>
</div>
))}
</div>
);
}
@@ -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({
>
<input
autoFocus
onFocus={(event) => 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
</Dropdown.Item>
)}
</Dropdown.Menu>
{onDelete && (
<Dropdown.Item id="delete" variant="danger" className="text-danger" isDisabled={busy} onAction={onDelete}>
{entry.rawSource ? "Remove from library" : "Delete project"}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
@@ -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
</Dropdown.Item>
{onDelete && (
<Dropdown.Item id="delete" variant="danger" className="text-danger" isDisabled={busy} onAction={onDelete}>
{entry.rawSource ? "Remove from library" : "Delete project"}
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
@@ -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<string | null>(null);
const [failedSource, setFailedSource] = useState<string | null>(null);
const [preview, setPreview] = useState<ProjectPreviewData | null>(null);
const host = useRef<HTMLDivElement>(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 && (
<Skeleton className="absolute inset-0 rounded-xl" />
)}
{path && failedSource !== sourceKey ? (
<img
src={
@@ -65,6 +70,7 @@ export function ProjectThumbnail({
loading="lazy"
draggable={false}
onError={() => setFailedSource(sourceKey)}
onLoad={() => setLoadedSource(sourceKey)}
className="h-full w-full object-cover"
/>
) : (
@@ -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<unknown>;
@@ -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<string[]>([]);
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<ProjectLibraryEntry | null>(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,
@@ -38,11 +38,12 @@ interface EditorDialogsProps {
projectBrowserOpen: boolean;
setProjectBrowserOpen: Dispatch<SetStateAction<boolean>>;
projectLibraryEntries: ProjectLibraryEntry[];
projectLibraryLoading: boolean;
projectError: string | null;
onDashboardSignIn: () => void;
onDeleteProjects: (paths: string[]) => Promise<string[]>;
onRenameProject: (path: string, name: string) => Promise<string>;
onShareProject: (path: string) => Promise<void>;
onRenameProject: (path: string, name: string) => Promise<string>;
onShareProject: (path: string) => Promise<void>;
accountLabel?: string;
handleImportMediaOrProject: () => Promise<void>;
handleOpenProjectFromLibrary: (projectPath: string) => Promise<unknown>;
@@ -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}
@@ -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 ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<input
ref={projectNameInputRef}
type="text"
@@ -172,13 +168,10 @@ export function EditorHeader(props: Props) {
variant="ghost"
type="button"
onClick={() => 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 ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
@@ -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}
@@ -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<void>((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,
@@ -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,
@@ -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(
@@ -6,6 +6,7 @@ export function useProjectState() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [projectLibraryLoading, setProjectLibraryLoading] = useState(true);
const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]);
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,
+52 -16
View File
@@ -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<void>((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",
);
+27
View File
@@ -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<void>((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);
});