/** * BradlyPlayer — Drop-in replacement for Remotion's . * * Frame-based composition renderer with: * - requestAnimationFrame loop for smooth playback * - Imperative API via ref (play, pause, seekTo, getCurrentFrame) * - Event system (frameupdate, play, pause, ended) * - Optional built-in controls * - Loop and autoPlay support * * Usage: * */ import React, { useState, useEffect, useRef, useCallback, useImperativeHandle, forwardRef, useMemo, } from 'react'; import { PlayerStateContext, VideoConfigContext, SequenceFrameContext } from './PlayerContext'; import type { BradlyPlayerRef, VideoConfig } from './types'; import { PlayerControls } from './PlayerControls'; interface BradlyPlayerProps { /** The React component to render as the composition */ component: React.ComponentType; /** Props to pass to the composition component */ inputProps: T; /** Total duration of the composition in frames */ durationInFrames: number; /** Width of the composition in pixels */ compositionWidth: number; /** Height of the composition in pixels */ compositionHeight: number; /** Frames per second */ fps: number; /** Show built-in playback controls */ controls?: boolean; /** Loop playback */ loop?: boolean; /** Auto-start playback */ autoPlay?: boolean; /** Allow clicking the player to toggle play/pause */ clickToPlay?: boolean; /** CSS styles for the outer container */ style?: React.CSSProperties; /** CSS class for the outer container */ className?: string; } function BradlyPlayerInner( props: BradlyPlayerProps, ref: React.ForwardedRef ) { const { component: Component, inputProps, durationInFrames, compositionWidth, compositionHeight, fps, controls = false, loop = false, autoPlay = false, clickToPlay = true, style, className, } = props; const [frame, setFrame] = useState(0); const [playing, setPlaying] = useState(autoPlay); const frameRef = useRef(0); const playingRef = useRef(autoPlay); const rafRef = useRef(0); const lastTimeRef = useRef(0); const listenersRef = useRef void>>>(new Map()); // Keep refs in sync with state useEffect(() => { frameRef.current = frame; }, [frame]); useEffect(() => { playingRef.current = playing; }, [playing]); // ── Event System ── const emit = useCallback((event: string, detail: any) => { const handlers = listenersRef.current.get(event); if (handlers) { for (const handler of handlers) { handler({ detail }); } } }, []); // ── Frame Loop ── useEffect(() => { if (!playing) return; lastTimeRef.current = performance.now(); const tick = (now: number) => { const elapsed = now - lastTimeRef.current; const frameDuration = 1000 / fps; if (elapsed >= frameDuration) { const framesToAdvance = Math.floor(elapsed / frameDuration); lastTimeRef.current = now - (elapsed % frameDuration); setFrame((prev) => { let next = prev + framesToAdvance; if (next >= durationInFrames) { if (loop) { next = next % durationInFrames; } else { next = durationInFrames - 1; // Stop playback setPlaying(false); emit('ended', { frame: next }); return next; } } emit('frameupdate', { frame: next }); return next; }); } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { cancelAnimationFrame(rafRef.current); }; }, [playing, fps, durationInFrames, loop, emit]); // ── Imperative API ── const play = useCallback(() => { setPlaying(true); emit('play', { frame: frameRef.current }); }, [emit]); const pause = useCallback(() => { setPlaying(false); emit('pause', { frame: frameRef.current }); }, [emit]); const toggle = useCallback(() => { if (playingRef.current) { pause(); } else { play(); } }, [play, pause]); const seekTo = useCallback((targetFrame: number) => { const clamped = Math.max(0, Math.min(durationInFrames - 1, Math.round(targetFrame))); setFrame(clamped); frameRef.current = clamped; emit('frameupdate', { frame: clamped }); }, [durationInFrames, emit]); const getCurrentFrame = useCallback(() => frameRef.current, []); const isPlaying = useCallback(() => playingRef.current, []); const getDuration = useCallback(() => durationInFrames, [durationInFrames]); const addEventListener = useCallback( (event: string, handler: (e: any) => void) => { if (!listenersRef.current.has(event)) { listenersRef.current.set(event, new Set()); } listenersRef.current.get(event)!.add(handler); }, [] ); const removeEventListener = useCallback( (event: string, handler: (e: any) => void) => { listenersRef.current.get(event)?.delete(handler); }, [] ); useImperativeHandle(ref, () => ({ play, pause, toggle, seekTo, getCurrentFrame, isPlaying, getDuration, addEventListener, removeEventListener, }), [play, pause, toggle, seekTo, getCurrentFrame, isPlaying, getDuration, addEventListener, removeEventListener]); // ── Context Values ── const videoConfig: VideoConfig = useMemo(() => ({ fps, width: compositionWidth, height: compositionHeight, durationInFrames, }), [fps, compositionWidth, compositionHeight, durationInFrames]); const playerState = useMemo(() => ({ frame, playing, }), [frame, playing]); // ── Click to play/pause ── const handleClick = useCallback(() => { if (clickToPlay) { toggle(); } }, [clickToPlay, toggle]); return (
{/* Composition viewport — maintains aspect ratio */}
{/* Inner composition — scaled to fit */}
{ if (el && el.parentElement) { const parent = el.parentElement; const scaleX = parent.clientWidth / compositionWidth; const scaleY = parent.clientHeight / compositionHeight; const scale = Math.min(scaleX, scaleY); el.style.transform = `scale(${scale})`; el.style.left = `${(parent.clientWidth - compositionWidth * scale) / 2}px`; el.style.top = `${(parent.clientHeight - compositionHeight * scale) / 2}px`; } }} >
{/* Controls */} {controls && ( )}
); } // forwardRef with generic support export const BradlyPlayer = forwardRef(BradlyPlayerInner) as ( props: BradlyPlayerProps & { ref?: React.ForwardedRef; key?: React.Key } ) => React.ReactElement | null;