From b4333b9675a20b810c933bb2e8476a294b25f248 Mon Sep 17 00:00:00 2001 From: Erick Wendel Date: Thu, 13 Aug 2026 00:03:27 -0400 Subject: [PATCH] Render video frames in presenter view --- .../editor/src/ui/presenter/PresenterView.tsx | 1 + .../unit/ui/presenter/PresenterView.test.tsx | 67 +++++++++++++++++++ .../editor/presenter-keyboard-video.spec.ts | 29 +++++++- .../presenter-video-frame-seek-browser.ts | 18 +++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/editor/presenter-video-frame-seek-browser.ts diff --git a/apps/editor/src/ui/presenter/PresenterView.tsx b/apps/editor/src/ui/presenter/PresenterView.tsx index fc64d7dc..9abe044c 100644 --- a/apps/editor/src/ui/presenter/PresenterView.tsx +++ b/apps/editor/src/ui/presenter/PresenterView.tsx @@ -1318,6 +1318,7 @@ export function PresenterView({ sessionId = getRouteSessionId() }: PresenterView project={snapshot.project} activePageId={activePage.id} selection={{ ...emptySelection, pageId: activePage.id }} + hideReadOnlyMediaPlaceholder presentationMode readOnly zoomPercent={100} diff --git a/apps/editor/tests/unit/ui/presenter/PresenterView.test.tsx b/apps/editor/tests/unit/ui/presenter/PresenterView.test.tsx index c9dd4b68..693101dd 100644 --- a/apps/editor/tests/unit/ui/presenter/PresenterView.test.tsx +++ b/apps/editor/tests/unit/ui/presenter/PresenterView.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; +import Konva from 'konva'; import { StrictMode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sampleProject } from '../../../../src/domain/projects/sampleProject'; @@ -161,6 +162,72 @@ describe('PresenterView', () => { expect(notes.style.fontSize).not.toBe(initialSize); }); + it('renders video frames instead of the read-only filename placeholder', () => { + window.localStorage.setItem('localstudio.presenterWindowIntroDismissed', '1'); + render(); + + const project = sampleProject.createSampleProject(); + project.assets['presenter-video-asset'] = { + id: 'presenter-video-asset', + mimeType: 'video/mp4', + name: 'Presenter video', + objectUrl: 'blob:presenter-video', + type: 'video', + }; + project.elements['presenter-video'] = { + assetId: 'presenter-video-asset', + autoplayInPreview: false, + controls: false, + height: 360, + id: 'presenter-video', + locked: false, + loop: false, + muted: true, + opacity: 1, + rotation: 0, + trimStartSeconds: 0, + type: 'video', + visible: true, + volume: 1, + width: 640, + x: 120, + y: 80, + }; + project.pages[0]!.elementIds.push('presenter-video'); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + data: { + payload: { + activePageId: project.pages[0]!.id, + animationPreview: undefined, + project, + }, + sessionId: 'session-1', + source: 'localstudio-presenter-main', + type: 'state', + }, + }), + ); + }); + + const currentSlide = screen.getByRole('region', { name: 'Current slide' }); + const video = currentSlide.querySelector( + 'video[aria-label="Presenter video"]', + ) as HTMLVideoElement; + expect(video).toBeInTheDocument(); + + const presenterStage = Konva.stages.find((stage) => + currentSlide.contains(stage.container()), + ); + const videoNode = presenterStage + ?.find('Image') + .find((node) => (node as Konva.Image).image() === video); + expect(videoNode).toBeDefined(); + }); + it('resizes presenter notes with the divider drag handle', () => { window.localStorage.setItem('localstudio.presenterWindowIntroDismissed', '1'); Object.defineProperty(window, 'innerWidth', { diff --git a/tests/e2e/editor/presenter-keyboard-video.spec.ts b/tests/e2e/editor/presenter-keyboard-video.spec.ts index b084b2d9..654da922 100644 --- a/tests/e2e/editor/presenter-keyboard-video.spec.ts +++ b/tests/e2e/editor/presenter-keyboard-video.spec.ts @@ -1,5 +1,9 @@ import { runPresenterKeyboardVideoJourney } from './presenter-keyboard-video-journey'; -import { test, withIsolatedDevServer } from '../support/journey-test'; +import { EditorAppPage } from '../pages/editor-app.page'; +import { expect, test, withIsolatedDevServer } from '../support/journey-test'; +import { presenterKeyboardVideoSetup } from './presenter-keyboard-video-setup'; +import { presenterNotesWindow } from './presenter-notes-window'; +import { seekPresenterVideoFrame } from './presenter-video-frame-seek-browser'; const getServer = withIsolatedDevServer(test); @@ -8,3 +12,26 @@ test('presents from the editor and controls slides and video with keyboard short }) => { await runPresenterKeyboardVideoJourney(page, getServer().baseURL); }); + +test('renders changing video frames in the presenter viewer', async ({ page }) => { + await presenterKeyboardVideoSetup.installFullscreenMock(page); + const editor = new EditorAppPage(page, getServer().baseURL); + await editor.gotoNewProject(); + await presenterKeyboardVideoSetup.addVideoAndSecondSlide(editor, page); + + const presenterPage = await presenterNotesWindow.open(page); + const currentSlide = presenterPage.getByRole('region', { name: 'Current slide' }); + const video = currentSlide.locator( + 'video.canvas-media-element[aria-label="Big_Buck_Bunny_360_10s_1MB.mp4"]', + ); + await expect(video).toBeVisible(); + + async function captureFrame(time: number) { + await video.evaluate(seekPresenterVideoFrame, time); + return video.screenshot(); + } + + const firstFrame = await captureFrame(1); + const secondFrame = await captureFrame(5); + expect(firstFrame.equals(secondFrame)).toBe(false); +}); diff --git a/tests/e2e/editor/presenter-video-frame-seek-browser.ts b/tests/e2e/editor/presenter-video-frame-seek-browser.ts new file mode 100644 index 00000000..afb5fde7 --- /dev/null +++ b/tests/e2e/editor/presenter-video-frame-seek-browser.ts @@ -0,0 +1,18 @@ +export async function seekPresenterVideoFrame( + element: Element, + targetTime: number, +): Promise { + const movie = element as HTMLVideoElement; + movie.pause(); + await new Promise((resolve) => { + if (Math.abs(movie.currentTime - targetTime) < 0.01) { + resolve(); + return; + } + movie.addEventListener('seeked', () => resolve(), { once: true }); + movie.currentTime = targetTime; + }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +}