mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 23:43:35 +00:00
Show Home loading states and save project edits automatically
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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")}`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user