311 lines
8.4 KiB
TypeScript
311 lines
8.4 KiB
TypeScript
/**
|
|
* BradlyPlayer — Drop-in replacement for Remotion's <Player>.
|
|
*
|
|
* 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:
|
|
* <BradlyPlayer
|
|
* ref={playerRef}
|
|
* component={MyComposition}
|
|
* inputProps={{ ... }}
|
|
* durationInFrames={150}
|
|
* compositionWidth={1080}
|
|
* compositionHeight={1920}
|
|
* fps={30}
|
|
* controls
|
|
* />
|
|
*/
|
|
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<T> {
|
|
/** The React component to render as the composition */
|
|
component: React.ComponentType<T>;
|
|
/** 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<T>(
|
|
props: BradlyPlayerProps<T>,
|
|
ref: React.ForwardedRef<BradlyPlayerRef>
|
|
) {
|
|
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<number>(0);
|
|
const lastTimeRef = useRef<number>(0);
|
|
const listenersRef = useRef<Map<string, Set<(e: any) => 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 (
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
backgroundColor: 'transparent',
|
|
...style,
|
|
}}
|
|
className={className}
|
|
>
|
|
{/* Composition viewport — maintains aspect ratio */}
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: controls ? 'calc(100% - 32px)' : '100%',
|
|
overflow: 'hidden',
|
|
}}
|
|
onClick={handleClick}
|
|
>
|
|
{/* Inner composition — scaled to fit */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: compositionWidth,
|
|
height: compositionHeight,
|
|
transformOrigin: 'top left',
|
|
// Scale composition to fit the container
|
|
// We use CSS containment for performance
|
|
}}
|
|
ref={(el) => {
|
|
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`;
|
|
}
|
|
}}
|
|
>
|
|
<VideoConfigContext.Provider value={videoConfig}>
|
|
<PlayerStateContext.Provider value={playerState}>
|
|
<SequenceFrameContext.Provider value={null}>
|
|
<Component {...inputProps} />
|
|
</SequenceFrameContext.Provider>
|
|
</PlayerStateContext.Provider>
|
|
</VideoConfigContext.Provider>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
{controls && (
|
|
<PlayerControls
|
|
frame={frame}
|
|
durationInFrames={durationInFrames}
|
|
fps={fps}
|
|
playing={playing}
|
|
onPlay={play}
|
|
onPause={pause}
|
|
onSeek={seekTo}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// forwardRef with generic support
|
|
export const BradlyPlayer = forwardRef(BradlyPlayerInner) as <T>(
|
|
props: BradlyPlayerProps<T> & { ref?: React.ForwardedRef<BradlyPlayerRef>; key?: React.Key }
|
|
) => React.ReactElement | null;
|