mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Add muted project hover previews and hide presets button
This commit is contained in:
Vendored
+15
-3
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
|
||||
export type ProjectPreviewData = {
|
||||
project: EditorProjectData;
|
||||
videoUrl: string;
|
||||
webcamUrl: string | null;
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user