From 3fd3151ac726a3dde14cf0bffcefafad5ad08be7 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:15:15 +1000 Subject: [PATCH] Add muted project hover previews and hide presets button --- electron/electron-env.d.ts | 18 ++- electron/ipc/project/manager.test.ts | 29 +++++ electron/ipc/project/manager.ts | 24 ++++ electron/ipc/register/project.ts | 33 ++++-- electron/preload.ts | 8 +- src/components/video-editor/VideoPlayback.tsx | 6 +- .../video-editor/dashboard/ProjectCard.tsx | 9 ++ .../dashboard/ProjectHoverPreview.tsx | 110 ++++++++++++++++++ .../dashboard/ProjectThumbnail.tsx | 47 +++++++- .../video-editor/layout/EditorHeader.tsx | 5 +- src/types/projectPreview.ts | 6 + tests/ui/bridge.ts | 1 + tests/ui/project-dashboard.spec.ts | 67 +++++++++++ 13 files changed, 345 insertions(+), 18 deletions(-) create mode 100644 src/components/video-editor/dashboard/ProjectHoverPreview.tsx create mode 100644 src/types/projectPreview.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 7eae1e17..bb155348 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 50873092..c89dec32 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -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"); diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index f6921d77..ee32e20a 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -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 { + 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; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 0782c8dc..2f974c6b 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -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(); const pendingImports = new Map>(); const watchedImportSenders = new WeakSet(); @@ -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) }; diff --git a/electron/preload.ts b/electron/preload.ts index 8665528b..068d52a5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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: ( diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 02723d83..90a8533a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( ( { videoPath, + autoPlay = false, onDurationChange, onPreviewReadyChange, onTimeUpdate, @@ -1925,6 +1927,8 @@ const VideoPlayback = forwardRef( }); 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( videoSpriteRef.current = null; }; - }, [onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); + }, [autoPlay, onPlayStateChange, onTimeUpdate, pixiReady, videoReady]); useEffect(() => { if (!pixiReady || !videoReady) return; diff --git a/src/components/video-editor/dashboard/ProjectCard.tsx b/src/components/video-editor/dashboard/ProjectCard.tsx index ac9d1f19..5ded020d 100644 --- a/src/components/video-editor/dashboard/ProjectCard.tsx +++ b/src/components/video-editor/dashboard/ProjectCard.tsx @@ -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 && ( {}; +/** 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(null); + const started = useRef(false); + const [time, setTime] = useState(0); + const [playing, setPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [telemetry, setTelemetry] = useState([]); + 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 ( + + ); +} diff --git a/src/components/video-editor/dashboard/ProjectThumbnail.tsx b/src/components/video-editor/dashboard/ProjectThumbnail.tsx index f41b51fe..89164030 100644 --- a/src/components/video-editor/dashboard/ProjectThumbnail.tsx +++ b/src/components/video-editor/dashboard/ProjectThumbnail.tsx @@ -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(null); + const [preview, setPreview] = useState(null); + const host = useRef(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 ( -
+
{path && failedSource !== sourceKey ? ( )} + {previewActive && preview && }
); } diff --git a/src/components/video-editor/layout/EditorHeader.tsx b/src/components/video-editor/layout/EditorHeader.tsx index 9f22cb28..1099ad50 100644 --- a/src/components/video-editor/layout/EditorHeader.tsx +++ b/src/components/video-editor/layout/EditorHeader.tsx @@ -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["t"]; headerLeftControlsPaddingClass: string; @@ -208,7 +211,7 @@ export function EditorHeader(props: Props) {
- + {SHOW_PRESETS_BUTTON && } "1.4.0", + getProjectPreview: async () => ({ success: false, error: "Preview unavailable" }), getAnnouncements: async () => ({ success: true, announcements: [] }), loadCurrentProjectFile: async () => ({ success: false }), createProjectFile: async () => { diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts index 466d661d..44139a6e 100644 --- a/tests/ui/project-dashboard.spec.ts +++ b/tests/ui/project-dashboard.spec.ts @@ -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); +});