From af5cd85fb7fafb0575c074c72eedf0358cd6be6d Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:31:18 +1000 Subject: [PATCH] Add HeroUI loading skeletons and refine timeline presentation --- .../layout/EditorLoadingSkeleton.tsx | 59 ++++++++++ .../layout/EditorPreviewPanel.tsx | 9 ++ .../video-editor/layout/EditorShell.tsx | 8 +- .../video-editor/layout/EditorSidebar.tsx | 6 +- src/components/video-editor/timeline/Item.tsx | 105 +++++++++--------- .../timeline/ItemGlass.module.css | 45 +++----- src/components/video-editor/timeline/Row.tsx | 8 +- .../components/filmstrip/ClipFilmstrip.tsx | 7 ++ .../components/viewport/TimelineCanvas.tsx | 4 +- .../timeline/timelineLayout.test.ts | 16 ++- .../video-editor/timeline/timelineLayout.ts | 9 +- tests/ui/clip-origin.spec.ts | 6 +- tests/ui/editor.spec.ts | 2 +- tests/ui/timeline-presentation.spec.ts | 100 +++++++++++++++++ 14 files changed, 287 insertions(+), 97 deletions(-) create mode 100644 src/components/video-editor/layout/EditorLoadingSkeleton.tsx create mode 100644 tests/ui/timeline-presentation.spec.ts diff --git a/src/components/video-editor/layout/EditorLoadingSkeleton.tsx b/src/components/video-editor/layout/EditorLoadingSkeleton.tsx new file mode 100644 index 00000000..955fad5e --- /dev/null +++ b/src/components/video-editor/layout/EditorLoadingSkeleton.tsx @@ -0,0 +1,59 @@ +import { Card, Skeleton } from "@heroui/react"; + +/** Keep the editor's proportions visible while project media is being opened. */ +export function EditorLoadingSkeleton() { + return ( +
+ Loading editor… + + + +
+ ); +} diff --git a/src/components/video-editor/layout/EditorPreviewPanel.tsx b/src/components/video-editor/layout/EditorPreviewPanel.tsx index 4276a6dc..3dd99847 100644 --- a/src/components/video-editor/layout/EditorPreviewPanel.tsx +++ b/src/components/video-editor/layout/EditorPreviewPanel.tsx @@ -1,3 +1,4 @@ +import { Skeleton } from "@/components/ui/skeleton"; import { Slider } from "@/components/ui/slider"; import { CaretDown, @@ -65,6 +66,7 @@ type Props = { handleSaveAutoCaptionEdit: (target: CaptionEditTarget, text: string) => void; handleSelectAnnotation: (id: string | null) => void; setDuration: Dispatch>; + isPreviewReady: boolean; setIsPreviewReady: Dispatch>; setCurrentTime: Dispatch>; setIsPlaying: Dispatch>; @@ -107,6 +109,7 @@ export function EditorPreviewPanel(props: Props) { handleSaveAutoCaptionEdit, handleSelectAnnotation, setDuration, + isPreviewReady, setIsPreviewReady, setCurrentTime, setIsPlaying, @@ -179,6 +182,12 @@ export function EditorPreviewPanel(props: Props) { boxSizing: "border-box", }} > + {videoPath && !isPreviewReady && ( + + )} -
Loading video...
+ <> + {editorDialogs} - + ); if (project.error) return ( @@ -217,6 +218,7 @@ export function EditorShell(props: Props) { handleSaveAutoCaptionEdit={autoCaption.handleSaveAutoCaptionEdit} handleSelectAnnotation={handleSelectAnnotation} setDuration={ui.setDuration} + isPreviewReady={ui.isPreviewReady} setIsPreviewReady={ui.setIsPreviewReady} setCurrentTime={ui.setCurrentTime} setIsPlaying={ui.setIsPlaying} diff --git a/src/components/video-editor/layout/EditorSidebar.tsx b/src/components/video-editor/layout/EditorSidebar.tsx index ef737986..ab1d1174 100644 --- a/src/components/video-editor/layout/EditorSidebar.tsx +++ b/src/components/video-editor/layout/EditorSidebar.tsx @@ -60,15 +60,15 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane [t], ); return ( -
+
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 1ad9fdd0..fdc39725 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -86,25 +86,20 @@ z-index: 10; } -.glassPurple { +.glassBlue { position: relative; border-radius: 8px; - background: #4a2a69; - border: 1px solid #5c3385; - transition: - background 0.15s ease, - border-color 0.15s ease; + background: var(--accent); + border: 1px solid transparent; + transition: background 0.15s ease; } -.glassPurple:hover { - background: #593180; - border-color: #733da7; +.glassBlue:hover { + background: color-mix(in srgb, var(--accent) 88%, white); } -.glassPurple.selected { - background: #643690; - border-color: #a855f7; - box-shadow: inset 0 0 0 1.5px #a855f7; +.glassBlue.selected { + border-color: color-mix(in srgb, var(--accent) 60%, white); z-index: 10; } @@ -252,20 +247,6 @@ box-shadow: inset 0 0 0 1.5px #f59e0b; } -:global(:root:not(.dark)) .glassPurple { - background: linear-gradient(180deg, #f3e8ff 0%, #d8b4fe 100%); - border-color: #c084fc; -} -:global(:root:not(.dark)) .glassPurple:hover { - background: linear-gradient(180deg, #e9d5ff 0%, #c084fc 100%); - border-color: #a855f7; -} -:global(:root:not(.dark)) .glassPurple.selected { - background: linear-gradient(180deg, #d8b4fe 0%, #a855f7 100%); - border-color: #a855f7; - box-shadow: inset 0 0 0 1.5px #a855f7; -} - :global(:root:not(.dark)) .glassCyan { background: linear-gradient(180deg, #dbeafe 0%, #93c5fd 100%); border-color: #60a5fa; @@ -323,7 +304,7 @@ .glassRed:hover .zoomEndCap, .glassYellow:hover .zoomEndCap, .glassAmber:hover .zoomEndCap, -.glassPurple:hover .zoomEndCap, +.glassBlue:hover .zoomEndCap, .glassCyan:hover .zoomEndCap, .glassDarkGreen:hover .zoomEndCap, .glassCaption:hover .zoomEndCap { @@ -345,3 +326,11 @@ .zoomEndCap::after { content: none; } + +/* Clip handles remain legible over both light and dark source frames. */ +.zoomEndCap.clipHandle, +:global(:root:not(.dark)) .zoomEndCap.clipHandle { + opacity: 1; + background: white; + box-shadow: 0 0 2px rgb(0 0 0 / 45%); +} diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 6d1aff3c..d3519c3d 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -1,8 +1,10 @@ import type { RowDefinition } from "dnd-timeline"; import { useRow } from "dnd-timeline"; +import { TIMELINE_CLIP_ROW_HEIGHT_PX } from "./timelineLayout"; interface RowProps extends RowDefinition { compact?: boolean; + filmstrip?: boolean; children: React.ReactNode; label?: string; hint?: string; @@ -18,6 +20,7 @@ interface RowProps extends RowDefinition { export default function Row({ id, compact = false, + filmstrip = false, children, label, hint, @@ -37,8 +40,9 @@ export default function Row({ style={{ ...rowWrapperStyle, marginBottom: 2, - flexGrow: compact ? 0 : 1, - flexBasis: compact ? 32 : undefined, + flexGrow: compact || filmstrip ? 0 : 1, + flexShrink: 0, + flexBasis: filmstrip ? TIMELINE_CLIP_ROW_HEIGHT_PX : compact ? 32 : undefined, }} > {label && ( diff --git a/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx b/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx index cf5d8499..c0875006 100644 --- a/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx +++ b/src/components/video-editor/timeline/components/filmstrip/ClipFilmstrip.tsx @@ -1,3 +1,4 @@ +import { Skeleton } from "@/components/ui/skeleton"; import { useTimelineContext, type Span } from "dnd-timeline"; import { useEffect, useRef, useState } from "react"; import { filmstripSampleTimes } from "../../core/filmstrip"; @@ -16,6 +17,7 @@ export function ClipFilmstrip({ const ref = useRef(null); const [count, setCount] = useState(0); const [frames, setFrames] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { const node = ref.current; if (!node) return; @@ -41,6 +43,7 @@ export function ClipFilmstrip({ { start, end }, count, ); + setLoading(times.length > 0); if (times.length) { void extractFilmstrip(path, times, controller.signal) .then((images) => { @@ -48,6 +51,9 @@ export function ClipFilmstrip({ }) .catch(() => { /* Keep the clip's color when a source cannot be decoded. */ + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); }); } return () => controller.abort(); @@ -59,6 +65,7 @@ export function ClipFilmstrip({ className="pointer-events-none absolute inset-0 flex overflow-hidden" aria-hidden="true" > + {loading && } {frames.map((frame, index) => ( - + {clipItems.map((item) => (
diff --git a/src/components/video-editor/timeline/timelineLayout.test.ts b/src/components/video-editor/timeline/timelineLayout.test.ts index 44623938..9ab716fe 100644 --- a/src/components/video-editor/timeline/timelineLayout.test.ts +++ b/src/components/video-editor/timeline/timelineLayout.test.ts @@ -3,6 +3,7 @@ import { getTimelineContentMinHeightPx, getTimelineRowsMinHeightPx, getTimelineViewportStretchFactor, + TIMELINE_CLIP_ROW_HEIGHT_PX, TIMELINE_AXIS_HEIGHT_PX, TIMELINE_ROW_MIN_HEIGHT_PX, TIMELINE_VISIBLE_ROW_COUNT, @@ -10,9 +11,14 @@ import { describe("timelineLayout", () => { it("reserves vertical space for every rendered timeline row", () => { - expect(getTimelineRowsMinHeightPx(5)).toBe(5 * TIMELINE_ROW_MIN_HEIGHT_PX); + expect(getTimelineRowsMinHeightPx(5)).toBe( + TIMELINE_CLIP_ROW_HEIGHT_PX + 4 * TIMELINE_ROW_MIN_HEIGHT_PX + 10, + ); expect(getTimelineContentMinHeightPx(5)).toBe( - TIMELINE_AXIS_HEIGHT_PX + 5 * TIMELINE_ROW_MIN_HEIGHT_PX, + TIMELINE_AXIS_HEIGHT_PX + + TIMELINE_CLIP_ROW_HEIGHT_PX + + 4 * TIMELINE_ROW_MIN_HEIGHT_PX + + 10, ); }); @@ -25,9 +31,11 @@ describe("timelineLayout", () => { }); it("floors fractional row counts", () => { - expect(getTimelineRowsMinHeightPx(2.9)).toBe(2 * TIMELINE_ROW_MIN_HEIGHT_PX); + expect(getTimelineRowsMinHeightPx(2.9)).toBe( + TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4, + ); expect(getTimelineContentMinHeightPx(2.9)).toBe( - TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX, + TIMELINE_AXIS_HEIGHT_PX + TIMELINE_CLIP_ROW_HEIGHT_PX + TIMELINE_ROW_MIN_HEIGHT_PX + 4, ); }); diff --git a/src/components/video-editor/timeline/timelineLayout.ts b/src/components/video-editor/timeline/timelineLayout.ts index 4f5b6bfc..d9c94bca 100644 --- a/src/components/video-editor/timeline/timelineLayout.ts +++ b/src/components/video-editor/timeline/timelineLayout.ts @@ -1,5 +1,6 @@ export const TIMELINE_AXIS_HEIGHT_PX = 32; -export const TIMELINE_ROW_MIN_HEIGHT_PX = 28; +export const TIMELINE_ROW_MIN_HEIGHT_PX = 32; +export const TIMELINE_CLIP_ROW_HEIGHT_PX = TIMELINE_ROW_MIN_HEIGHT_PX * 2; export const TIMELINE_VISIBLE_ROW_COUNT = 3; function normalizeRowCount(rowCount: number) { @@ -11,7 +12,11 @@ function normalizeRowCount(rowCount: number) { } export function getTimelineRowsMinHeightPx(rowCount: number) { - return normalizeRowCount(rowCount) * TIMELINE_ROW_MIN_HEIGHT_PX; + const count = normalizeRowCount(rowCount); + // The first lane is a double-height filmstrip; reserve its extra space. + return count + ? TIMELINE_CLIP_ROW_HEIGHT_PX + (count - 1) * TIMELINE_ROW_MIN_HEIGHT_PX + count * 2 + : 0; } export function getTimelineContentMinHeightPx(rowCount: number) { diff --git a/tests/ui/clip-origin.spec.ts b/tests/ui/clip-origin.spec.ts index 6722ca5b..75287939 100644 --- a/tests/ui/clip-origin.spec.ts +++ b/tests/ui/clip-origin.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { installDesktopBridge } from "./bridge"; test("selecting an untouched clip does not introduce a leading gap", async ({ page }) => { - test.setTimeout(60000); + test.setTimeout(120000); await installDesktopBridge(page); await page.addInitScript(() => { window.electronAPI.onMenuSaveProject = (callback) => { @@ -46,7 +46,9 @@ test("selecting an untouched clip does not introduce a leading gap", async ({ pa window.dispatchEvent(new Event("test-save-project")); }); await expect - .poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips"))) + .poll(() => page.evaluate(() => sessionStorage.getItem("test-saved-clips")), { + timeout: 20000, + }) .not.toBeNull(); const clips = await page.evaluate(() => JSON.parse(sessionStorage.getItem("test-saved-clips")!), diff --git a/tests/ui/editor.spec.ts b/tests/ui/editor.spec.ts index efe15b90..13896ad3 100644 --- a/tests/ui/editor.spec.ts +++ b/tests/ui/editor.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { installDesktopBridge } from "./bridge"; test("editor loads video, switches tools and edits export options", async ({ page }) => { - test.setTimeout(60000); + test.setTimeout(120000); const errors: string[] = []; page.on("pageerror", (e) => { errors.push(e.message); diff --git a/tests/ui/timeline-presentation.spec.ts b/tests/ui/timeline-presentation.spec.ts new file mode 100644 index 00000000..189b9640 --- /dev/null +++ b/tests/ui/timeline-presentation.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge } from "./bridge"; + +test("editor uses skeletons until media opens", async ({ page }) => { + test.setTimeout(60000); + let release!: () => void; + let releaseMedia!: () => void; + const mediaPending = new Promise((resolve) => { + releaseMedia = resolve; + }); + await page.route("**/tests/ui/fixtures/preview.mp4", async (route) => { + await mediaPending; + await route.continue(); + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + await page.exposeFunction("waitForTestMedia", () => pending); + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.getCurrentVideoPath = async () => { + await ( + window as unknown as { waitForTestMedia: () => Promise } + ).waitForTestMedia(); + return { success: true, path: `${location.origin}/tests/ui/fixtures/preview.mp4` }; + }; + }); + await page.goto("/?windowType=editor"); + const loading = page.getByRole("status", { name: "Loading editor" }); + await expect(loading).toBeVisible({ timeout: 20000 }); + await expect(loading.locator(".skeleton").first()).toBeVisible(); + await page.screenshot({ path: "test-results/editor-skeleton.png", animations: "disabled" }); + release(); + await expect(loading).toHaveCount(0); + await expect(page.getByLabel("Loading preview", { exact: true })).toBeVisible(); + releaseMedia(); + await expect(page.getByLabel("Loading preview", { exact: true })).toHaveCount(0, { + timeout: 20000, + }); + await expect(page.locator('[data-variant="clip"]')).toBeVisible(); +}); + +test("filmstrips have white handles, conditional speed badges and centered navigation", async ({ + page, +}) => { + test.setTimeout(90000); + await installDesktopBridge(page, "filmstrip.mp4"); + await page.goto("/?windowType=editor"); + const clip = page.locator('[data-timeline-item][data-variant="clip"]'); + await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 }); + await expect(clip).toHaveText(""); + await page.mouse.move(1000, 300); + for (const side of ["left", "right"]) { + await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS("opacity", "1"); + await expect(clip.getByTitle(`Resize ${side}`)).toHaveCSS( + "background-color", + "rgb(255, 255, 255)", + ); + } + await page.getByRole("button", { name: "Add Zoom (Z)", exact: true }).click(); + const zoom = page.locator('[data-variant="zoom"] .timeline-block'); + await expect(zoom).toBeVisible(); + const clipHeight = (await clip.locator(".timeline-block").boundingBox())!.height; + const zoomHeight = (await zoom.boundingBox())!.height; + expect(Math.abs(clipHeight - zoomHeight * 2)).toBeLessThan(1); + const colors = await zoom.evaluate((node) => { + const probe = document.createElement("span"); + probe.style.background = "var(--accent)"; + document.body.append(probe); + const accent = getComputedStyle(probe).backgroundColor; + probe.remove(); + return [getComputedStyle(node).backgroundColor, accent]; + }); + expect(colors[0]).toBe(colors[1]); + for (const width of [1440, 1250, 800]) { + await page.setViewportSize({ width, height: 800 }); + await expect(clip.locator("img").first()).toBeVisible({ timeout: 20000 }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle("dark", dark), + width === 1250, + ); + const panel = (await page.locator("aside").boundingBox())!; + const nav = page.getByRole("navigation", { name: "Editor tools" }); + for (const control of await nav.locator('button, [role="radio"]').all()) { + const box = (await control.boundingBox())!; + expect(Math.abs(box.x + box.width / 2 - panel.x / 2)).toBeLessThan(1); + } + await page.screenshot({ + path: `test-results/clip-rail-${width}.png`, + animations: "disabled", + }); + } + await clip.click(); + const speed = page.getByRole("slider", { name: "Speed", exact: true }); + await speed.focus(); + await page.keyboard.press("ArrowRight"); + await expect(clip).toHaveText("1.25x"); + await page.keyboard.press("ArrowLeft"); + await expect(clip).toHaveText(""); +});