播放器的自定义控件
你可能需要为 <Player> 组件实现自定义控件。
🌐 You may want to implement custom controls for the <Player> component.
有两种方法:
🌐 There are two approaches:
自定义内联控件
🌐 Custom inline controls
如果你符合以下情况,请使用这种方法:
🌐 Use this approach if you:
- 喜欢默认控制但想自定义其中一些
- 希望控件覆盖在播放器上。
确保在 <Player/> 中设置 controls 属性。
使用以下 API 来自定义各个控件:
🌐 Ensure the controls prop is set in the <Player/>.
Use the following APIs to customize the individual controls:
控制槽v4.0.418
🌐 Controls slotv4.0.418
播放器在主播放控制和全屏按钮之间提供了一个插槽,你可以使用 renderCustomControls 属性在其中渲染自定义控制项。
🌐 The Player provides a slot between the main playback controls and the fullscreen button where you can render custom controls using the renderCustomControls prop.
当你想添加与默认控制栏融为一体的自定义按钮或指示器时使用此选项。
🌐 Use this when you want to add custom buttons or indicators that blend in with the default controls bar.
ControlsSlot.tsximport {Player ,PlayerRef ,RenderCustomControls } from '@remotion/player'; importReact , {useCallback ,useEffect ,useRef ,useState } from 'react'; constDownloadButton :React .FC <{playerRef :React .RefObject <PlayerRef | null>; }> = ({playerRef }) => { const [frame ,setFrame ] =useState (0);useEffect (() => { const {current } =playerRef ; if (!current ) return; constonFrameUpdate = () => {setFrame (current .getCurrentFrame ()); };current .addEventListener ('frameupdate',onFrameUpdate ); return () => {current .removeEventListener ('frameupdate',onFrameUpdate ); }; }, [playerRef ]); return ( <button type ="button"onClick ={() =>console .log ('Download at frame',frame )}style ={{background : 'transparent',border : 'none',color : 'white',cursor : 'pointer', }} > Download </button > ); }; export constApp :React .FC = () => { constplayerRef =useRef <PlayerRef >(null); constrenderCustomControls :RenderCustomControls =useCallback (() => { return <DownloadButton playerRef ={playerRef } />; }, []); return ( <Player ref ={playerRef }component ={MyVideo }durationInFrames ={120}compositionWidth ={1920}compositionHeight ={1080}fps ={30}controls renderCustomControls ={renderCustomControls } /> ); };
播放器之外的控制
🌐 Controls outside the Player
如果你符合以下情况,请使用这种方法:
🌐 Use this approach if you:
- 想在页面的任何地方实现自定义控件
- 想要完全控制控件的外观和行为
使用以下起点来实现你自己的控件。你将需要以下先决条件:
🌐 Use the following starting points to implement your own controls. You will need the following prerequisites:
- 确保在
<Player/>中没有设置controls属性。 - 获取
<Player/>的类型为PlayerRef的ref。 - 其中一些组件将需要
durationInFrames和fps属性。将这些值放入共享变量,以便在<Player/>和这些组件中使用。 <SeekBar/>组件可以选择性地接受inFrame和outFrame属性。它们是传递给<Player/>(也是可选的)的相同值。
播放 / 暂停按钮
🌐 Play / Pause button
PlayPauseButton.tsximport type {PlayerRef } from '@remotion/player'; import {useCallback ,useEffect ,useState } from 'react'; export constPlayPauseButton :React .FC <{playerRef :React .RefObject <PlayerRef | null>; }> = ({playerRef }) => { const [playing ,setPlaying ] =useState (false);useEffect (() => { const {current } =playerRef ;setPlaying (current ?.isPlaying () ?? false); if (!current ) return; constonPlay = () => {setPlaying (true); }; constonPause = () => {setPlaying (false); };current .addEventListener ('play',onPlay );current .addEventListener ('pause',onPause ); return () => {current .removeEventListener ('play',onPlay );current .removeEventListener ('pause',onPause ); }; }, [playerRef ]); constonToggle =useCallback (() => {playerRef .current ?.toggle (); }, [playerRef ]); return ( <button onClick ={onToggle }type ="button"> {playing ? 'Pause' : 'Play'} </button > ); };
此代码片段中未实现缓冲指示器。
时间显示
🌐 Time display
TimeDisplay.tsximport type {PlayerRef } from '@remotion/player'; importReact , {useEffect } from 'react'; export constformatTime = (frame : number,fps : number): string => { consthours =Math .floor (frame /fps / 3600); constremainingMinutes =frame -hours *fps * 3600; constminutes =Math .floor (remainingMinutes / 60 /fps ); constremainingSec =frame -hours *fps * 3600 -minutes *fps * 60; constseconds =Math .floor (remainingSec /fps ); constframeAfterSec =Math .round (frame %fps ); consthoursStr =String (hours ); constminutesStr =String (minutes ).padStart (2, '0'); constsecondsStr =String (seconds ).padStart (2, '0'); constframeStr =String (frameAfterSec ).padStart (2, '0'); if (hours > 0) { return `${hoursStr }:${minutesStr }:${secondsStr }.${frameStr }`; } return `${minutesStr }:${secondsStr }.${frameStr }`; }; export constTimeDisplay :React .FC <{durationInFrames : number;fps : number;playerRef :React .RefObject <PlayerRef | null>; }> = ({durationInFrames ,fps ,playerRef }) => { const [time ,setTime ] =React .useState (0);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonTimeUpdate = () => {setTime (current .getCurrentFrame ()); };current .addEventListener ('frameupdate',onTimeUpdate ); return () => {current .removeEventListener ('frameupdate',onTimeUpdate ); }; }, [playerRef ]); return ( <div style ={{fontFamily : 'monospace', }} > <span > {formatTime (time ,fps )}/{formatTime (durationInFrames ,fps )} </span > </div > ); };
视频编辑器的常规时间格式 是 hh:mm:ss.ff,其中 hh 是小时,mm 是分钟,ss 是秒,ff 是秒后帧数。
全屏按钮
🌐 Fullscreen button
在实现全屏按钮时注意两个细微差别:
🌐 Pay attention to two nuances when implementing the Fullscreen button:
- 并非所有浏览器都支持全屏功能,应进行功能检测。
- 如果使用服务器端渲染,应该在组件在客户端挂载后进行功能检测,以避免 React 的 hydration 不匹配。
FullscreenButton.tsximport type {PlayerRef } from '@remotion/player'; importReact , {useCallback ,useEffect ,useState } from 'react'; export constFullscreenButton :React .FC <{playerRef :React .RefObject <PlayerRef | null>; }> = ({playerRef }) => { const [supportsFullscreen ,setSupportsFullscreen ] =useState (false); const [isFullscreen ,setIsFullscreen ] =useState (false);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonFullscreenChange = () => {setIsFullscreen (document .fullscreenElement !== null); };current .addEventListener ('fullscreenchange',onFullscreenChange ); return () => {current .removeEventListener ('fullscreenchange',onFullscreenChange ); }; }, [playerRef ]);useEffect (() => { // Must be handled client-side to avoid SSR hydration mismatchsetSupportsFullscreen ( (typeofdocument !== 'undefined' && (document .fullscreenEnabled || // @ts-expect-error Types not defineddocument .webkitFullscreenEnabled )) ?? false, ); }, []); constonClick =useCallback (() => { const {current } =playerRef ; if (!current ) { return; } if (isFullscreen ) {current .exitFullscreen (); } else {current .requestFullscreen (); } }, [isFullscreen ,playerRef ]); if (!supportsFullscreen ) { return null; } return ( <button type ="button"onClick ={onClick }> {isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'} </button > ); };
Exit Fullscreen 标签是假设性的,因为如果它在播放器外呈现,在全屏模式下将不可见。
进度条
🌐 Seek bar
SeekBar.tsximport type {PlayerRef } from '@remotion/player'; importReact , {useCallback ,useEffect ,useMemo ,useRef ,useState } from 'react'; import {interpolate } from 'remotion'; typeSize = {width : number;height : number;left : number;top : number; }; // If a pane has been moved, it will cause a layout shift without // the window having been resized. Those UI elements can call this API to // force an update export constuseElementSize = (ref :React .RefObject <HTMLElement | null>, ):Size | null => { const [size ,setSize ] =useState <Size | null>(() => { if (!ref .current ) { return null; } constrect =ref .current .getClientRects (); if (!rect [0]) { return null; } return {width :rect [0].width as number,height :rect [0].height as number,left :rect [0].x as number,top :rect [0].y as number, }; }); constobserver =useMemo (() => { if (typeofResizeObserver === 'undefined') { return null; } return newResizeObserver ((entries ) => { const {target } =entries [0]; constnewSize =target .getClientRects (); if (!newSize ?.[0]) {setSize (null); return; } const {width } =newSize [0]; const {height } =newSize [0];setSize ({width ,height ,left :newSize [0].x ,top :newSize [0].y , }); }); }, []); constupdateSize =useCallback (() => { if (!ref .current ) { return; } constrect =ref .current .getClientRects (); if (!rect [0]) {setSize (null); return; }setSize ((prevState ) => { constisSame =prevState &&prevState .width ===rect [0].width &&prevState .height ===rect [0].height &&prevState .left ===rect [0].x &&prevState .top ===rect [0].y ; if (isSame ) { returnprevState ; } return {width :rect [0].width as number,height :rect [0].height as number,left :rect [0].x as number,top :rect [0].y as number,windowSize : {height :window .innerHeight ,width :window .innerWidth , }, }; }); }, [ref ]);useEffect (() => { if (!observer ) { return; } const {current } =ref ; if (current ) {observer .observe (current ); } return (): void => { if (current ) {observer .unobserve (current ); } }; }, [observer ,ref ,updateSize ]);useEffect (() => {window .addEventListener ('resize',updateSize ); return () => {window .removeEventListener ('resize',updateSize ); }; }, [updateSize ]); returnuseMemo (() => { if (!size ) { return null; } return {...size ,refresh :updateSize }; }, [size ,updateSize ]); }; constgetFrameFromX = (clientX : number,durationInFrames : number,width : number, ) => { constpos =clientX ; constframe =Math .round (interpolate (pos , [0,width ], [0,Math .max (durationInFrames - 1, 0)], {extrapolateLeft : 'clamp',extrapolateRight : 'clamp', }), ); returnframe ; }; constBAR_HEIGHT = 5; constKNOB_SIZE = 12; constVERTICAL_PADDING = 4; constcontainerStyle :React .CSSProperties = {userSelect : 'none',WebkitUserSelect : 'none',paddingTop :VERTICAL_PADDING ,paddingBottom :VERTICAL_PADDING ,boxSizing : 'border-box',cursor : 'pointer',position : 'relative',touchAction : 'none',flex : 1, }; constbarBackground :React .CSSProperties = {height :BAR_HEIGHT ,backgroundColor : 'rgba(0, 0, 0, 0.25)',width : '100%',borderRadius :BAR_HEIGHT / 2, }; constfindBodyInWhichDivIsLocated = (div :HTMLElement ) => { letcurrent =div ; while (current .parentElement ) {current =current .parentElement ; } returncurrent ; }; export constuseHoverState = (ref :React .RefObject <HTMLDivElement | null>) => { const [hovered ,setHovered ] =useState (false);useEffect (() => { const {current } =ref ; if (!current ) { return; } constonHover = () => {setHovered (true); }; constonLeave = () => {setHovered (false); }; constonMove = () => {setHovered (true); };current .addEventListener ('mouseenter',onHover );current .addEventListener ('mouseleave',onLeave );current .addEventListener ('mousemove',onMove ); return () => {current .removeEventListener ('mouseenter',onHover );current .removeEventListener ('mouseleave',onLeave );current .removeEventListener ('mousemove',onMove ); }; }, [ref ]); returnhovered ; }; export constSeekBar :React .FC <{durationInFrames : number;inFrame ?: number | null;outFrame ?: number | null;playerRef :React .RefObject <PlayerRef | null>; }> = ({durationInFrames ,inFrame ,outFrame ,playerRef }) => { constcontainerRef =useRef <HTMLDivElement >(null); constbarHovered =useHoverState (containerRef ); constsize =useElementSize (containerRef ); const [playing ,setPlaying ] =useState (false); const [frame ,setFrame ] =useState (0);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonFrameUpdate = () => {setFrame (current .getCurrentFrame ()); };current .addEventListener ('frameupdate',onFrameUpdate ); return () => {current .removeEventListener ('frameupdate',onFrameUpdate ); }; }, [playerRef ]);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonPlay = () => {setPlaying (true); }; constonPause = () => {setPlaying (false); };current .addEventListener ('play',onPlay );current .addEventListener ('pause',onPause ); return () => {current .removeEventListener ('play',onPlay );current .removeEventListener ('pause',onPause ); }; }, [playerRef ]); const [dragging ,setDragging ] =useState < | {dragging : false; } | {dragging : true;wasPlaying : boolean; } >({dragging : false, }); constwidth =size ?.width ?? 0; constonPointerDown =useCallback ( (e :React .PointerEvent <HTMLDivElement >) => { if (e .button !== 0) { return; } if (!playerRef .current ) { return; } constposLeft =containerRef .current ?.getBoundingClientRect () .left as number; const_frame =getFrameFromX (e .clientX -posLeft ,durationInFrames ,width , );playerRef .current .pause ();playerRef .current .seekTo (_frame );setDragging ({dragging : true,wasPlaying :playing , }); }, [durationInFrames ,width ,playerRef ,playing ], ); constonPointerMove =useCallback ( (e :PointerEvent ) => { if (!size ) { throw newError ('Player has no size'); } if (!dragging .dragging ) { return; } if (!playerRef .current ) { return; } constposLeft =containerRef .current ?.getBoundingClientRect () .left as number; const_frame =getFrameFromX (e .clientX -posLeft ,durationInFrames ,size .width , );playerRef .current .seekTo (_frame ); }, [dragging .dragging ,durationInFrames ,playerRef ,size ], ); constonPointerUp =useCallback (() => {setDragging ({dragging : false, }); if (!dragging .dragging ) { return; } if (!playerRef .current ) { return; } if (dragging .wasPlaying ) {playerRef .current .play (); } else {playerRef .current .pause (); } }, [dragging ,playerRef ]);useEffect (() => { if (!dragging .dragging ) { return; } constbody =findBodyInWhichDivIsLocated (containerRef .current asHTMLElement , );body .addEventListener ('pointermove',onPointerMove );body .addEventListener ('pointerup',onPointerUp ); return () => {body .removeEventListener ('pointermove',onPointerMove );body .removeEventListener ('pointerup',onPointerUp ); }; }, [dragging .dragging ,onPointerMove ,onPointerUp ]); constknobStyle :React .CSSProperties =useMemo (() => { return {height :KNOB_SIZE ,width :KNOB_SIZE ,borderRadius :KNOB_SIZE / 2,position : 'absolute',top :VERTICAL_PADDING -KNOB_SIZE / 2 + 5 / 2,backgroundColor : '#000',left :Math .max ( 0, (frame /Math .max (1,durationInFrames - 1)) *width -KNOB_SIZE / 2, ),boxShadow : '0 0 2px black',opacity :Number (barHovered ),transition : 'opacity 0.1s ease', }; }, [barHovered ,durationInFrames ,frame ,width ]); constfillStyle :React .CSSProperties =useMemo (() => { return {height :BAR_HEIGHT ,backgroundColor : '#000',width : ((frame - (inFrame ?? 0)) / (durationInFrames - 1)) * 100 + '%',marginLeft : ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',borderRadius :BAR_HEIGHT / 2, }; }, [durationInFrames ,frame ,inFrame ]); constactive :React .CSSProperties =useMemo (() => { return {height :BAR_HEIGHT ,backgroundColor : '#000',opacity : 0.6,width : (((outFrame ??durationInFrames - 1) - (inFrame ?? 0)) / (durationInFrames - 1)) * 100 + '%',marginLeft : ((inFrame ?? 0) / (durationInFrames - 1)) * 100 + '%',borderRadius :BAR_HEIGHT / 2,position : 'absolute', }; }, [durationInFrames ,inFrame ,outFrame ]); return ( <div ref ={containerRef }onPointerDown ={onPointerDown }style ={containerStyle } > <div style ={barBackground }> <div style ={active } /> <div style ={fillStyle } /> </div > <div style ={knobStyle } /> </div > ); };
循环按钮
🌐 Loop button
loop 是 <Player/> 组件的一个属性,因此你可以直接使用 useState 钩子来控制它。
LoopButton.tsximportReact from 'react'; export constLoopButton :React .FC <{loop : boolean;setLoop :React .Dispatch <React .SetStateAction <boolean>>; }> = ({loop ,setLoop }) => { constonClick =React .useCallback (() => {setLoop ((prev ) => !prev ); }, [setLoop ]); return ( <button type ="button"onClick ={onClick }> {loop ? 'Loop enabled' : 'Loop disabled'} </button > ); };
UsageimportReact , {useState } from 'react'; import {LoopButton } from './LoopButton'; import {Player } from '@remotion/player'; export constMyComponent :React .FC = () => { const [loop ,setLoop ] =useState (false); return ( <> <Player component ={MyComp }loop ={loop }durationInFrames ={100}fps ={30}compositionWidth ={1920}compositionHeight ={1080}inputProps ={{}} /> <LoopButton loop ={loop }setLoop ={setLoop } /> </> ); };
音量滑块
🌐 Volume slider
请注意,如果视频被“静音”,音量状态可能大于0。以下组件处理视频被“静音”的特殊情况:
🌐 Note that if the video is "muted", the volume state may be greater than 0.
The following component handles the special case of the video being "muted":
- 如果视频被静音,将滑块的值设置为0。
- 如果滑块正在被滑动,如有必要,请取消静音视频。
这使我们能够保持在静音视频之前设置的音量的内部状态,并在取消静音后将滑块重置为该值。
🌐 This allows us to keep an internal state of the volume that was set before muting the video and reset the slider to that value after unmuting.
VolumeSlider.tsximport type {PlayerRef } from '@remotion/player'; importReact , {useEffect ,useState } from 'react'; export constVolumeSlider :React .FC <{playerRef :React .RefObject <PlayerRef | null>; }> = ({playerRef }) => { const [volume ,setVolume ] =useState (playerRef .current ?.getVolume () ?? 1); const [muted ,setMuted ] =useState (playerRef .current ?.isMuted () ?? false);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonVolumeChange = () => {setVolume (current .getVolume ()); }; constonMuteChange = () => {setMuted (current .isMuted ()); };current .addEventListener ('volumechange',onVolumeChange );current .addEventListener ('mutechange',onMuteChange ); return () => {current .removeEventListener ('volumechange',onVolumeChange );current .removeEventListener ('mutechange',onMuteChange ); }; }, [playerRef ]); constonChange :React .ChangeEventHandler <HTMLInputElement > =React .useCallback ( (evt ) => { if (!playerRef .current ) { return; } constnewVolume =Number (evt .target .value ); if (newVolume > 0 &&playerRef .current .isMuted ()) {playerRef .current .unmute (); }playerRef .current .setVolume (newVolume ); }, [playerRef ], ); return ( <input value ={muted ? 0 :volume }type ="range"min ={0}max ={1}step ={0.01}onChange ={onChange } /> ); };
静音按钮
🌐 Mute button
如果音量为0,Remotion也会认为视频是“静音”的。你不需要在这里处理特殊情况。
🌐 Remotion also considers a video "muted" if the volume is 0.
You don't need to handle a special case here.
MuteButton.tsximport type {PlayerRef } from '@remotion/player'; importReact , {useEffect ,useState } from 'react'; export constMuteButton :React .FC <{playerRef :React .RefObject <PlayerRef | null>; }> = ({playerRef }) => { const [muted ,setMuted ] =useState (playerRef .current ?.isMuted () ?? false); constonClick =React .useCallback (() => { if (!playerRef .current ) { return; } if (playerRef .current .isMuted ()) {playerRef .current .unmute (); } else {playerRef .current .mute (); } }, [playerRef ]);useEffect (() => { const {current } =playerRef ; if (!current ) { return; } constonMuteChange = () => {setMuted (current .isMuted ()); };current .addEventListener ('mutechange',onMuteChange ); return () => {current .removeEventListener ('mutechange',onMuteChange ); }; }, [playerRef ]); return ( <button type ="button"onClick ={onClick }> {muted ? 'Unmute' : 'Mute'} </button > ); };