From a73efdf179bbb3812506ed96b7e6fad8462bbbf3 Mon Sep 17 00:00:00 2001 From: cbaucom Date: Thu, 11 Jun 2026 10:23:38 -0400 Subject: [PATCH] Add playback sync, presentation fullscreen, and slide jump (#17). Wire grid playhead highlighting to RenderPlan frame positions, seek on slide click, and a body-portaled Present mode that hides editor chrome. Co-authored-by: Cursor --- scripts/playwright-issue-17-smoke.mjs | 162 ++++++++++++++++++ .../issue-17-playback-controls-fullscreen.md | 24 +++ src/editor-shell/App.tsx | 39 +++-- src/editor-shell/PlayerPane.tsx | 134 ++++++++++++++- src/editor-shell/StoryboardFilmstrip.tsx | 18 +- src/sequence-planner/index.ts | 1 + src/sequence-planner/playback.test.ts | 51 ++++++ src/sequence-planner/playback.ts | 25 +++ 8 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 scripts/playwright-issue-17-smoke.mjs create mode 100644 specs/issue-17-playback-controls-fullscreen.md create mode 100644 src/sequence-planner/playback.test.ts create mode 100644 src/sequence-planner/playback.ts diff --git a/scripts/playwright-issue-17-smoke.mjs b/scripts/playwright-issue-17-smoke.mjs new file mode 100644 index 0000000..ce82d82 --- /dev/null +++ b/scripts/playwright-issue-17-smoke.mjs @@ -0,0 +1,162 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +const DEV_URL = process.env.DEV_URL ?? 'http://localhost:5175/' + +function run(args) { + const result = spawnSync('npx', ['playwright-cli', ...args], { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) console.error(result.error) + return result.status ?? 1 +} + +const demo = path.resolve('test-fixtures/demo') +const names = ['photo.jpg', 'photo2.jpg'] +const payload = Object.fromEntries( + names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]), +) +payload['slideshow.json'] = Buffer.from(JSON.stringify({ + globalSettings: { + fitMode: 'cover', + imageDurationSecs: 3, + kenBurns: false, + transitionType: 'cut', + }, + schemaVersion: 1, + slides: [ + { + durationInFrames: 90, + excluded: false, + filename: 'photo.jpg', + id: 'photo-a', + type: 'image', + }, + { + durationInFrames: 90, + excluded: false, + filename: 'photo2.jpg', + id: 'photo-b', + type: 'image', + }, + ], +}, null, 2)).toString('base64') + +const code = `async page => { + const payload = ${JSON.stringify(payload)}; + await page.goto(${JSON.stringify(DEV_URL)}); + await page.evaluate((files) => { + function decodeBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + return bytes; + } + class MockFileHandle { + constructor(name, base64) { + this.kind = 'file'; + this.name = name; + this._base64 = base64; + } + async getFile() { + const bytes = decodeBase64(this._base64); + const type = this.name.endsWith('.jpg') ? 'image/jpeg' : 'application/json'; + return new File([bytes], this.name, { type, lastModified: 1781145972852 }); + } + async createWritable() { + const handle = this; + return { + write: async (data) => { + const buffer = data instanceof Blob ? await data.arrayBuffer() : data; + handle._base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + }, + close: async () => {}, + }; + } + } + class MockDirHandle { + constructor(files) { + this.name = 'demo'; + this._files = files; + } + async getFileHandle(name, options) { + const handle = this._files.get(name); + if (handle) return handle; + if (options?.create) { + const created = new MockFileHandle(name, ''); + this._files.set(name, created); + return created; + } + throw new DOMException('NotFoundError'); + } + async *values() { + for (const handle of this._files.values()) yield handle; + } + } + const handles = new Map( + Object.entries(files).map(([name, base64]) => [name, new MockFileHandle(name, base64)]), + ); + window.showDirectoryPicker = async () => new MockDirHandle(handles); + }, payload); + + await page.getByRole('button', { name: 'Open Folder' }).click(); + await page.getByText('photo2.jpg').first().waitFor(); + + const secondSlide = page.locator('li').filter({ hasText: 'photo2.jpg' }).first(); + await secondSlide.click(); + await page.waitForTimeout(300); + + const currentHighlight = await secondSlide.evaluate((element) => element.className.includes('ring-emerald-500')); + if (!currentHighlight) { + throw new Error('Expected photo2.jpg to be highlighted as the current slide after click'); + } + + await page.getByRole('button', { name: 'Present fullscreen' }).click(); + const presentationState = await page.evaluate(() => { + const overlay = [...document.body.children].find((element) => ( + element.className.includes('fixed') && element.className.includes('inset-0') + )); + return { + fullscreenTag: document.fullscreenElement?.tagName ?? null, + hasOverlay: !!overlay, + overlayIsFullscreen: overlay === document.fullscreenElement, + }; + }); + if (!presentationState.hasOverlay) { + throw new Error('Presentation overlay missing: ' + JSON.stringify(presentationState)); + } + if (!presentationState.overlayIsFullscreen) { + throw new Error('Presentation overlay is not fullscreen: ' + JSON.stringify(presentationState)); + } + + const coversViewport = await page.evaluate(() => { + const overlay = [...document.body.children].find((element) => ( + element.className.includes('fixed') && element.className.includes('inset-0') + )); + if (!overlay) return false; + const rect = overlay.getBoundingClientRect(); + return rect.width >= window.innerWidth - 1 && rect.height >= window.innerHeight - 1; + }); + if (!coversViewport) { + throw new Error('Expected presentation overlay to cover the viewport'); + } + + await page.evaluate(() => document.fullscreenElement?.focus()); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.fullscreenElement, null, { timeout: 5000 }); + await page.getByRole('button', { name: 'Present fullscreen' }).waitFor({ timeout: 5000 }); + + await page.screenshot({ path: 'issue-17-playback-controls.png', fullPage: true }); +}` + +let status = run(['open', DEV_URL, '--browser=chrome']) +if (status !== 0) process.exit(status) + +status = run(['run-code', code]) +run(['close']) +process.exit(status) diff --git a/specs/issue-17-playback-controls-fullscreen.md b/specs/issue-17-playback-controls-fullscreen.md new file mode 100644 index 0000000..c813ccf --- /dev/null +++ b/specs/issue-17-playback-controls-fullscreen.md @@ -0,0 +1,24 @@ +# Issue 17 — Playback controls + fullscreen + +## Scope + +Player chrome: play/pause, scrubbing (Remotion `controls`), grid ↔ player position sync, current-slide indicator, and presentation fullscreen (no editor chrome, Esc exits). + +## Pure module (`sequence-planner/playback.ts`) + +- `slideIdAtFrame(renderPlan, frame)` — last entry whose `startFrame <= frame` +- `startFrameForSlideId(renderPlan, slideId)` — entry start or `null` (excluded / missing) + +## Editor shell + +- `PlayerPane`: `PlayerRef`, `frameupdate` → `onFrameChange`, presentation fullscreen on container (`fixed inset-0` + Fullscreen API) +- `App`: `currentSlideId` from frame; slide click → `seekTo(startFrame)` + open settings dialog (stories 31 & 32) +- `StoryboardFilmstrip`: `currentSlideId` highlight distinct from settings selection + +## Acceptance criteria mapping + +| Criterion | Implementation | +| --- | --- | +| Grid click → jump; playhead → highlight | `startFrameForSlideId` + `seekTo`; `slideIdAtFrame` + `currentSlideId` | +| Fullscreen: show only, Esc exits | Presentation overlay + `requestFullscreen` / `fullscreenchange` | +| Scrub matches RenderPlan | Remotion Player renders `SlideshowComposition` at seeked frame | diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 155fcd5..e3c431f 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -1,4 +1,5 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' +import type { PlayerRef } from '@remotion/player' import { Button } from '@/components/ui/button' import type { Slide, TitleSlide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' @@ -11,7 +12,7 @@ import { } from '../timeline-core' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' import { applyTheme } from '../timeline-core' -import { plan } from '../sequence-planner' +import { plan, slideIdAtFrame, startFrameForSlideId } from '../sequence-planner' import { AppHeader } from './AppHeader' import { DropImportLayer } from './DropImportLayer' import { EditorLayout } from './EditorLayout' @@ -24,6 +25,8 @@ import { TitleSlideDialog } from './TitleSlideDialog' import { useProject } from './useProject' export function App() { + const playerRef = useRef(null) + const [currentSlideId, setCurrentSlideId] = useState(null) const [selectedSlideId, setSelectedSlideId] = useState(null) const clearSelection = useCallback(() => setSelectedSlideId(null), []) @@ -66,10 +69,6 @@ export function App() { setSlides(prev => applyImageDuration(prev, themeSettings.imageDurationSecs)) }, [setGlobalSettings, setSlides, setThemeName]) - const handleSlideClick = useCallback((id: string) => { - setSelectedSlideId(prev => prev === id ? null : id) - }, []) - const handleSlideOverride = useCallback((id: string, overrides: SlideOverrides | undefined) => { setSlides(prev => prev.map(s => s.id === id ? { ...s, overrides } : s)) }, [setSlides]) @@ -108,6 +107,18 @@ export function App() { ) const totalFrames = renderPlan.totalFrames > 0 ? renderPlan.totalFrames : FPS + const handleFrameChange = useCallback((frame: number) => { + setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) + }, [renderPlan]) + + const handleSlideClick = useCallback((id: string) => { + const startFrame = startFrameForSlideId(renderPlan, id) + if (startFrame !== null) { + playerRef.current?.seekTo(startFrame) + } + setSelectedSlideId((previousId) => (previousId === id ? null : id)) + }, [renderPlan]) + const selectedSlide: Slide | null = selectedSlideId ? slides.find(s => s.id === selectedSlideId) ?? null : null return ( @@ -155,14 +166,22 @@ export function App() {
{folderOpen ? ( } + player={( + + )} filmstrip={slides.length > 0 ? ( ) : null} sidebar={ diff --git a/src/editor-shell/PlayerPane.tsx b/src/editor-shell/PlayerPane.tsx index aa723cc..040951a 100644 --- a/src/editor-shell/PlayerPane.tsx +++ b/src/editor-shell/PlayerPane.tsx @@ -1,4 +1,9 @@ -import { Player } from '@remotion/player' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal, flushSync } from 'react-dom' +import { Maximize2, Minimize2 } from 'lucide-react' +import { Player, type PlayerRef } from '@remotion/player' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' import type { RenderPlan } from '../sequence-planner' import { SlideshowComposition } from '../composition' @@ -7,23 +12,140 @@ export const COMP_WIDTH = 1920 export const COMP_HEIGHT = 1080 type Props = { + onFrameChange: (frame: number) => void + playerRef?: React.RefObject renderPlan: RenderPlan totalFrames: number } -export function PlayerPane({ renderPlan, totalFrames }: Props) { - return ( -
+export function PlayerPane({ onFrameChange, playerRef, renderPlan, totalFrames }: Props) { + const embeddedHostRef = useRef(null) + const presentationHostRef = useRef(null) + const fallbackPlayerRef = useRef(null) + const resolvedPlayerRef = playerRef ?? fallbackPlayerRef + const [isPresenting, setIsPresenting] = useState(false) + const [presentationFrame, setPresentationFrame] = useState(0) + + useEffect(() => { + let animationFrameId = 0 + let lastReportedFrame = -1 + + function pollCurrentFrame() { + const frame = resolvedPlayerRef.current?.getCurrentFrame() + if (frame !== undefined && frame !== lastReportedFrame) { + lastReportedFrame = frame + onFrameChange(frame) + } + animationFrameId = requestAnimationFrame(pollCurrentFrame) + } + + animationFrameId = requestAnimationFrame(pollCurrentFrame) + return () => cancelAnimationFrame(animationFrameId) + }, [isPresenting, onFrameChange, renderPlan, resolvedPlayerRef, totalFrames]) + + function enterPresentation() { + const frame = resolvedPlayerRef.current?.getCurrentFrame() ?? 0 + flushSync(() => { + setPresentationFrame(frame) + setIsPresenting(true) + }) + void presentationHostRef.current?.requestFullscreen() + } + + const exitPresentation = useCallback(() => { + if (document.fullscreenElement) { + void document.exitFullscreen() + } + setIsPresenting(false) + }, []) + + useEffect(() => { + function handleFullscreenChange() { + if (!document.fullscreenElement) { + setIsPresenting(false) + } + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) + }, []) + + useEffect(() => { + if (!isPresenting) { + return + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + exitPresentation() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [exitPresentation, isPresenting]) + + function renderPlayer(initialFrame?: number) { + return ( + ) + } + + const presentationLayer = isPresenting ? ( +
+ {renderPlayer(presentationFrame)} +
+ ) : null + + return ( + <> +
+ {!isPresenting ? renderPlayer() : null} + +
+ {presentationLayer ? createPortal(presentationLayer, document.body) : null} + ) } diff --git a/src/editor-shell/StoryboardFilmstrip.tsx b/src/editor-shell/StoryboardFilmstrip.tsx index c5843d7..2f96012 100644 --- a/src/editor-shell/StoryboardFilmstrip.tsx +++ b/src/editor-shell/StoryboardFilmstrip.tsx @@ -5,11 +5,12 @@ import type { Slide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' type Props = { - slides: Slide[] - selectedSlideId: string | null + currentSlideId: string | null onReorder: (fromIndex: number, toIndex: number) => void - onToggleExclude: (id: string) => void onSlideClick: (id: string) => void + onToggleExclude: (id: string) => void + selectedSlideId: string | null + slides: Slide[] } function hasOverrides(slide: Slide): boolean { @@ -20,7 +21,14 @@ function slideLabel(slide: Slide): string { return isTitleSlide(slide) ? slide.heading || 'Title' : slide.filename } -export function StoryboardFilmstrip({ slides, selectedSlideId, onReorder, onToggleExclude, onSlideClick }: Props) { +export function StoryboardFilmstrip({ + currentSlideId, + onReorder, + onSlideClick, + onToggleExclude, + selectedSlideId, + slides, +}: Props) { const dragIndexRef = useRef(null) const included = slides.filter(s => !s.excluded).length @@ -30,6 +38,7 @@ export function StoryboardFilmstrip({ slides, selectedSlideId, onReorder, onTogg {included === slides.length ? `${slides.length} slide${slides.length !== 1 ? 's' : ''}` : `${included} / ${slides.length} included`} + · green = now playing

    @@ -39,6 +48,7 @@ export function StoryboardFilmstrip({ slides, selectedSlideId, onReorder, onTogg className={cn( 'flex w-36 shrink-0 cursor-pointer flex-col gap-1 rounded-md border bg-background p-1.5 transition-colors hover:border-muted-foreground/60', slide.excluded && 'opacity-60', + currentSlideId === slide.id && 'border-transparent ring-2 ring-emerald-500', selectedSlideId === slide.id && 'border-transparent ring-2 ring-primary', )} draggable diff --git a/src/sequence-planner/index.ts b/src/sequence-planner/index.ts index f649f8d..95ab329 100644 --- a/src/sequence-planner/index.ts +++ b/src/sequence-planner/index.ts @@ -1,3 +1,4 @@ export { plan, TRANSITION_FRAMES } from './planner' +export { slideIdAtFrame, startFrameForSlideId } from './playback' export type { SoundtrackInput } from './planner' export type { MediaMetadata, RenderPlan, RenderPlanEntry, SoundtrackTrack, TransitionSpec, DuckingEnvelope, DuckingSegment, VolumeKeyframe } from './types' diff --git a/src/sequence-planner/playback.test.ts b/src/sequence-planner/playback.test.ts new file mode 100644 index 0000000..1135bee --- /dev/null +++ b/src/sequence-planner/playback.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { plan } from './planner' +import { slideIdAtFrame, startFrameForSlideId } from './playback' +import type { GlobalSettings } from '../timeline-core/settings' +import { DEFAULT_GLOBAL_SETTINGS } from '../timeline-core/settings' +import type { MediaSlide } from '../timeline-core/types' + +function makeSlide(id: string, type: 'image' | 'video', durationInFrames: number): MediaSlide { + return { + blobUrl: '', + durationInFrames, + excluded: false, + filename: `${id}.jpg`, + id, + type, + } +} + +const CROSSFADE: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, transitionType: 'crossfade' } +const IMG_A = makeSlide('slide-a', 'image', 90) +const IMG_B = makeSlide('slide-b', 'image', 90) +const IMG_C = makeSlide('slide-c', 'image', 120) + +describe('startFrameForSlideId', () => { + it('returns the planned start frame for an included slide', () => { + const renderPlan = plan([IMG_A, IMG_B, IMG_C], CROSSFADE) + expect(startFrameForSlideId(renderPlan, 'slide-b')).toBe(75) + }) + + it('returns null when the slide is not in the plan', () => { + const renderPlan = plan([IMG_A], CROSSFADE) + expect(startFrameForSlideId(renderPlan, 'missing')).toBeNull() + }) +}) + +describe('slideIdAtFrame', () => { + it('returns null for an empty plan', () => { + const renderPlan = plan([], CROSSFADE) + expect(slideIdAtFrame(renderPlan, 0)).toBeNull() + }) + + it('highlights the slide whose start frame has been reached', () => { + const renderPlan = plan([IMG_A, IMG_B, IMG_C], CROSSFADE) + + expect(slideIdAtFrame(renderPlan, 0)).toBe('slide-a') + expect(slideIdAtFrame(renderPlan, 74)).toBe('slide-a') + expect(slideIdAtFrame(renderPlan, 75)).toBe('slide-b') + expect(slideIdAtFrame(renderPlan, 149)).toBe('slide-b') + expect(slideIdAtFrame(renderPlan, 150)).toBe('slide-c') + }) +}) diff --git a/src/sequence-planner/playback.ts b/src/sequence-planner/playback.ts new file mode 100644 index 0000000..940b8d1 --- /dev/null +++ b/src/sequence-planner/playback.ts @@ -0,0 +1,25 @@ +import type { RenderPlan } from './types' + +export function startFrameForSlideId(renderPlan: RenderPlan, slideId: string): number | null { + const entry = renderPlan.entries.find((planEntry) => planEntry.slide.id === slideId) + return entry?.startFrame ?? null +} + +export function slideIdAtFrame(renderPlan: RenderPlan, frame: number): string | null { + if (renderPlan.entries.length === 0) { + return null + } + + const clampedFrame = Math.max(0, Math.min(frame, Math.max(renderPlan.totalFrames - 1, 0))) + let currentSlideId: string | null = null + + for (const entry of renderPlan.entries) { + if (entry.startFrame <= clampedFrame) { + currentSlideId = entry.slide.id + } else { + break + } + } + + return currentSlideId +}