Add muted project hover previews and hide presets button

This commit is contained in:
webadderall
2026-09-22 20:15:15 +10:00
parent 9dc9a84399
commit 3fd3151ac7
13 changed files with 345 additions and 18 deletions
+15 -3
View File
@@ -762,7 +762,16 @@ interface Window {
commit?: boolean,
) => Promise<{ success: boolean; error?: string }>;
cancelRecordingImport: () => Promise<{ success: boolean }>;
listRecordings: (includeSources?: boolean) => Promise<
getProjectPreview: (
projectPath: string,
) => Promise<
import("../src/types/recordingLibrary").LibraryResult<
import("../src/types/projectPreview").ProjectPreviewData
>
>;
listRecordings: (
includeSources?: boolean,
) => Promise<
import("../src/types/recordingLibrary").LibraryResult<
import("../src/types/recordingLibrary").RecordingLibraryEntry[]
>
@@ -842,7 +851,10 @@ interface Window {
message?: string;
canceled?: boolean;
}>;
renameLibraryProject: (path: string, name: string) => Promise<{success: boolean; path?: string; error?: string}>;
renameLibraryProject: (
path: string,
name: string,
) => Promise<{ success: boolean; path?: string; error?: string }>;
trashProjectFiles: (
paths: string[],
) => Promise<{ success: boolean; deleted: string[]; errors: string[] }>;
@@ -853,7 +865,7 @@ interface Window {
path: string;
name: string;
createdAt?: number;
updatedAt: number;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
isInProjectsDirectory: boolean;
+29
View File
@@ -42,11 +42,40 @@ describe("local media path policy", () => {
afterEach(async () => {
vi.resetModules();
vi.doUnmock("electron");
vi.doUnmock("../../mediaServer");
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("reads library previews without switching the active project and rejects unknown projects", async () => {
vi.doMock("../../mediaServer", () => ({
getMediaServerBaseUrl: () => "http://127.0.0.1:1234",
buildMediaUrl: (base: string, file: string) =>
`${base}/media?path=${encodeURIComponent(file)}`,
}));
const manager = await import("./manager");
const state = await import("../state");
const source = path.join(tempRoot, "video.mp4");
await fs.writeFile(source, "video");
const projectsDir = await manager.getProjectsDir();
const projectPath = path.join(projectsDir, "preview.recordly");
await fs.writeFile(
projectPath,
JSON.stringify({ version: 1, videoPath: source, editor: {} }),
);
state.setCurrentProjectPath("active.recordly");
state.setCurrentVideoPath("active.mp4");
const result = await manager.readProjectPreview(projectPath);
expect(result.videoUrl).toContain(encodeURIComponent(source));
expect(result.webcamUrl).toBeNull();
expect(state.currentProjectPath).toBe("active.recordly");
expect(state.currentVideoPath).toBe("active.mp4");
await expect(
manager.readProjectPreview(path.join(tempRoot, "unknown.recordly")),
).rejects.toThrow("not in the library");
});
it("rejects existing media files outside allowed directories until they are approved", async () => {
const downloadsPath = path.join(tempRoot, "Downloads");
const exportPath = path.join(downloadsPath, "export-test.mp4");
+24
View File
@@ -1,3 +1,5 @@
import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
import type { ProjectPreviewData } from "../../../src/types/projectPreview";
import { hasFreshProjectThumbnail } from "./thumbnailFreshness";
import { existsSync, constants as fsConstants, realpathSync } from "node:fs";
import fs from "node:fs/promises";
@@ -425,6 +427,28 @@ function isLoadableProjectData(projectData: unknown) {
);
}
/** Read a listed project's preview without changing the active project or recording session. */
export async function readProjectPreview(projectPath: string): Promise<ProjectPreviewData> {
if (typeof projectPath !== "string") throw new Error("Invalid project path");
const normalizedPath = normalizePath(projectPath);
const { entries } = await listProjectLibraryEntries();
if (!entries.some((entry) => entry.path === normalizedPath))
throw new Error("Project is not in the library");
const project = parseJsonWithByteOrderMark(await fs.readFile(normalizedPath, "utf-8"));
if (!isLoadableProjectData(project)) throw new Error("Invalid project file format");
const media = await resolveProjectMediaSources(project);
if (!media.success) throw new Error(media.message);
const baseUrl = getMediaServerBaseUrl();
if (!baseUrl) throw new Error("Media server is not ready");
await rememberApprovedLocalReadPath(media.videoPath);
if (media.webcamPath) await rememberApprovedLocalReadPath(media.webcamPath);
return {
project: project as ProjectPreviewData["project"],
videoUrl: buildMediaUrl(baseUrl, media.videoPath),
webcamUrl: media.webcamPath ? buildMediaUrl(baseUrl, media.webcamPath) : null,
};
}
export async function loadProjectFromPath(projectPath: string) {
const normalizedPath = normalizePath(projectPath);
let project: unknown;
+24 -9
View File
@@ -20,6 +20,7 @@ import {
isTrustedProjectPath,
listProjectLibraryEntries,
loadProjectFromPath,
readProjectPreview,
loadRecentProjectPaths,
persistRecordingsDirectorySetting,
rememberApprovedLocalReadPath,
@@ -217,15 +218,22 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
}
export function registerProjectHandlers() {
ipcMain.handle("rename-library-project", async (_, source: string, name: string) => {
try {
const entries = await listProjectLibraryEntries();
const target = await renameLibraryProject(source, name, entries.entries.map(entry => entry.path), value => [getProjectThumbnailPath(value), getProjectBackupPath(value)]);
if (currentProjectPath === source) setCurrentProjectPath(target);
await rememberRecentProject(target);
return {success: true, path: target};
} catch(error) { return {success: false, error: String(error)}; }
});
ipcMain.handle("rename-library-project", async (_, source: string, name: string) => {
try {
const entries = await listProjectLibraryEntries();
const target = await renameLibraryProject(
source,
name,
entries.entries.map((entry) => entry.path),
(value) => [getProjectThumbnailPath(value), getProjectBackupPath(value)],
);
if (currentProjectPath === source) setCurrentProjectPath(target);
await rememberRecentProject(target);
return { success: true, path: target };
} catch (error) {
return { success: false, error: String(error) };
}
});
const imports = new Map<number, AbortController>();
const pendingImports = new Map<number, Set<string>>();
const watchedImportSenders = new WeakSet<Electron.WebContents>();
@@ -270,6 +278,13 @@ export function registerProjectHandlers() {
return { success: false, error: String(error) };
}
});
ipcMain.handle("get-project-preview", async (_, projectPath: string) => {
try {
return { success: true, value: await readProjectPreview(projectPath) };
} catch (error) {
return { success: false, error: String(error) };
}
});
ipcMain.handle("list-recordings", async (_, includeSources?: boolean) => {
try {
return { success: true, value: await listRecordings(includeSources === true) };
+6 -2
View File
@@ -516,7 +516,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
showRecordingHud: () => ipcRenderer.invoke("show-recording-hud"),
createProjectFile: (data: unknown, thumbnail?: string | null) =>
ipcRenderer.invoke("create-project-file", data, thumbnail),
renameLibraryProject: (path: string, name: string) => ipcRenderer.invoke("rename-library-project", path, name),
renameLibraryProject: (path: string, name: string) =>
ipcRenderer.invoke("rename-library-project", path, name),
trashProjectFiles: (paths: string[]) => ipcRenderer.invoke("trash-project-files", paths),
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
@@ -804,7 +805,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
finishRecordingImport: (keepPath: string, commit?: boolean) =>
ipcRenderer.invoke("finish-recording-import", keepPath, commit),
cancelRecordingImport: () => ipcRenderer.invoke("cancel-recording-import"),
listRecordings: (includeSources?: boolean) => ipcRenderer.invoke("list-recordings", includeSources),
getProjectPreview: (projectPath: string) =>
ipcRenderer.invoke("get-project-preview", projectPath),
listRecordings: (includeSources?: boolean) =>
ipcRenderer.invoke("list-recordings", includeSources),
setRecordingsRemoved: (paths: string[], removed: boolean) =>
ipcRenderer.invoke("set-recordings-removed", paths, removed),
importRecording: (
@@ -217,6 +217,7 @@ function getEffectiveNativeAspectRatio(
}
interface VideoPlaybackProps {
autoPlay?: boolean;
clipRegions: ClipRegion[];
videoPath: string;
onDurationChange: (duration: number) => void;
@@ -303,6 +304,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
(
{
videoPath,
autoPlay = false,
onDurationChange,
onPreviewReadyChange,
onTimeUpdate,
@@ -1925,6 +1927,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
});
clipPlaybackRef.current = transport;
transport.seek(timelineTimeRef.current);
if (autoPlay)
void transport.play().catch((error) => onPlaybackErrorRef.current(String(error)));
const handleSeeked = () => {
isSeekingRef.current = false;
// A source seek at a contiguous cut must not reset the camera springs.
@@ -1955,7 +1959,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoSpriteRef.current = null;
};
}, [onPlayStateChange, onTimeUpdate, pixiReady, videoReady]);
}, [autoPlay, onPlayStateChange, onTimeUpdate, pixiReady, videoReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -39,6 +39,7 @@ export function ProjectCard({
save,
assignFolder,
}: Props) {
const [hovering, setHovering] = useState(false);
const [editing, setEditing] = useState(false);
const renaming = useRef(false);
const [name, setName] = useState(entry.name);
@@ -72,6 +73,12 @@ export function ProjectCard({
variant="ghost"
disabled={busy}
aria-label={entry.name}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") setHovering(true);
}}
onPointerLeave={() => setHovering(false)}
onFocus={() => setHovering(true)}
onBlur={() => setHovering(false)}
onClick={() => (selecting ? toggleSelected(entry.path) : openEntry(entry))}
aria-pressed={selecting ? selected.includes(entry.path) : undefined}
className="relative block h-auto w-full min-w-0 rounded-xl p-0"
@@ -80,6 +87,8 @@ export function ProjectCard({
key={`${entry.thumbnailPath}-${entry.updatedAt}`}
revision={entry.updatedAt}
path={entry.thumbnailPath}
projectPath={entry.path}
previewActive={hovering && !selecting && !busy}
/>
{selecting && (
<span
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ProjectPreviewData } from "@/types/projectPreview";
import VideoPlayback, { type VideoPlaybackRef } from "../VideoPlayback";
import { normalizeProjectEditor } from "../projectPersistence";
import type { CursorTelemetryPoint } from "../types";
const ignore = () => {};
/** One muted five-second pass through the saved timeline using the editor renderer. */
export function ProjectHoverPreview({
data,
onFinish,
}: {
data: ProjectPreviewData;
onFinish: () => void;
}) {
const editor = useMemo(() => normalizeProjectEditor(data.project.editor), [data]);
const playback = useRef<VideoPlaybackRef>(null);
const started = useRef(false);
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [telemetry, setTelemetry] = useState<CursorTelemetryPoint[]>([]);
const clips = useMemo(
() =>
editor.clipRegions.length
? editor.clipRegions
: duration > 0
? [
{
id: "hover-preview",
startMs: 0,
endMs: duration * 1000,
sourceStartMs: 0,
speed: 1,
},
]
: [],
[editor.clipRegions, duration],
);
useEffect(() => {
let active = true;
void window.electronAPI
.getCursorTelemetry(data.project.videoPath)
.then((result) => {
if (active && result.success) setTelemetry(result.samples);
})
.catch(ignore);
return () => {
active = false;
};
}, [data.project.videoPath]);
const updateTime = useCallback(
(seconds: number) => {
if (seconds >= 5) {
playback.current?.pause();
onFinish();
} else setTime(seconds);
},
[onFinish],
);
const updatePlaying = useCallback(
(value: boolean) => {
setPlaying(value);
if (value) started.current = true;
else if (started.current) onFinish();
},
[onFinish],
);
useEffect(() => {
// A stalled decoder or unavailable GPU must leave the static thumbnail usable.
const timeout = window.setTimeout(onFinish, 15000);
const stopWhenHidden = () => {
if (document.hidden) onFinish();
};
document.addEventListener("visibilitychange", stopWhenHidden);
return () => {
clearTimeout(timeout);
document.removeEventListener("visibilitychange", stopWhenHidden);
playback.current?.pause();
};
}, [onFinish]);
return (
<div
data-project-hover-preview
className={`pointer-events-none absolute inset-0 overflow-hidden rounded-xl ${playing ? "opacity-100" : "opacity-0"}`}
aria-hidden="true"
>
<VideoPlayback
{...editor}
autoPlay
ref={playback}
videoPath={data.videoUrl}
webcamVideoPath={data.webcamUrl}
clipRegions={clips}
showShadow={editor.shadowIntensity > 0}
currentTime={time}
isPlaying={playing}
volume={0}
cursorTelemetry={telemetry}
selectedZoomId={null}
onSelectZoom={ignore}
onZoomFocusChange={ignore}
onDurationChange={setDuration}
onTimeUpdate={updateTime}
onPlayStateChange={updatePlaying}
onError={onFinish}
/>
</div>
);
}
@@ -1,17 +1,59 @@
import type { ProjectPreviewData } from "@/types/projectPreview";
import { ProjectHoverPreview } from "./ProjectHoverPreview";
import { ImageSquare } from "@/components/ui/icons";
import { useState } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { toFileUrl } from "../projectPersistence";
export function ProjectThumbnail({
path,
projectPath,
previewActive = false,
revision = 0,
}: {
path: string | null;
projectPath?: string;
previewActive?: boolean;
revision?: number;
}) {
const [failedSource, setFailedSource] = useState<string | null>(null);
const [preview, setPreview] = useState<ProjectPreviewData | null>(null);
const host = useRef<HTMLDivElement>(null);
const finish = useCallback(() => setPreview(null), []);
useEffect(() => {
if (
!previewActive ||
!projectPath ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
let active = true;
const timer = window.setTimeout(() => {
void window.electronAPI
.getProjectPreview(projectPath)
.then((result) => {
if (active && !document.hidden && result.success) setPreview(result.value);
})
.catch(() => {});
}, 300);
const observer = new IntersectionObserver((entries) => {
if (entries.every((entry) => !entry.isIntersecting)) {
active = false;
setPreview(null);
}
});
if (host.current) observer.observe(host.current);
return () => {
active = false;
clearTimeout(timer);
observer.disconnect();
setPreview(null);
};
}, [previewActive, projectPath, revision]);
const sourceKey = `${path}:${revision}`;
return (
<div className="flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60">
<div
ref={host}
className="relative flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60"
>
{path && failedSource !== sourceKey ? (
<img
src={
@@ -28,6 +70,7 @@ export function ProjectThumbnail({
) : (
<ImageSquare weight="fill" className="size-8 text-muted-foreground/20" />
)}
{previewActive && preview && <ProjectHoverPreview data={preview} onFinish={finish} />}
</div>
);
}
@@ -15,6 +15,9 @@ import type { useProjectState } from "../state/useProjectState";
import { EditorExportMenu } from "./EditorExportMenu";
import { EditorPresetMenu } from "./EditorPresetMenu";
// Keep the preset implementation available for future use.
const SHOW_PRESETS_BUTTON = false;
type Props = {
t: ReturnType<typeof useI18n>["t"];
headerLeftControlsPaddingClass: string;
@@ -208,7 +211,7 @@ export function EditorHeader(props: Props) {
<Redo2 className="h-4 w-4" />
</Button>
</div>
<EditorPresetMenu t={t} presets={presets} />
{SHOW_PRESETS_BUTTON && <EditorPresetMenu t={t} presets={presets} />}
<EditorExportMenu
t={t}
exportSettings={exportSettings}
+6
View File
@@ -0,0 +1,6 @@
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
export type ProjectPreviewData = {
project: EditorProjectData;
videoUrl: string;
webcamUrl: string | null;
};
+1
View File
@@ -29,6 +29,7 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m
return () => window.removeEventListener("test-window-chrome", listener);
},
getAppVersion: async () => "1.4.0",
getProjectPreview: async () => ({ success: false, error: "Preview unavailable" }),
getAnnouncements: async () => ({ success: true, announcements: [] }),
loadCurrentProjectFile: async () => ({ success: false }),
createProjectFile: async () => {
+67
View File
@@ -391,3 +391,70 @@ test("Solar navigation selection, circular initials, and Raw sources are consist
);
await page.screenshot({ path: "test-results/dashboard-raw.png" });
});
test("project hover plays a muted five-second preview and stops on exit", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.listProjectFiles = async () => ({
success: true,
projects: [],
entries: [
{
path: "/projects/hover.recordly",
name: "Hover preview",
updatedAt: 1,
thumbnailPath: null,
isCurrent: false,
isInProjectsDirectory: true,
},
],
});
window.electronAPI.getProjectPreview = async () => {
document.documentElement.dataset.previewRequests = String(
Number(document.documentElement.dataset.previewRequests || 0) + 1,
);
return {
success: true,
value: {
videoUrl: `${location.origin}/tests/ui/fixtures/preview.mp4`,
webcamUrl: null,
project: {
version: 1,
videoPath: "/recordings/preview.mp4",
editor: {
clipRegions: [
{ id: "clip", startMs: 0, endMs: 6000, sourceStartMs: 0, speed: 1 },
],
},
},
},
};
};
});
await page.goto("/?windowType=editor");
await expect(page.getByRole("button", { name: "Open presets" })).toHaveCount(0);
await page.getByRole("button", { name: "Home", exact: true }).click();
const card = page.getByRole("button", { name: "Hover preview", exact: true });
await expect(card).toBeVisible();
await expect(page.locator("html")).not.toHaveAttribute("data-preview-requests");
await card.hover();
const preview = page.locator("[data-project-hover-preview]");
await expect(preview).toBeVisible();
await expect
.poll(() =>
preview
.locator("video")
.first()
.evaluate(
(video: HTMLVideoElement) =>
!video.paused && video.currentTime > 0 && video.muted,
),
)
.toBe(true);
await expect(preview).toHaveCount(0, { timeout: 8000 });
await page.mouse.move(0, 0);
await card.hover();
await expect(preview).toBeVisible();
await page.mouse.move(0, 0);
await expect(preview).toHaveCount(0);
});