Skip to main content

播放器的自定义控件

你可能需要为 <Player> 组件实现自定义控件。

🌐 You may want to implement custom controls for the <Player> component.

有两种方法:

🌐 There are two approaches:

  • 启用 controls 属性,并精细地重写播放器内的一部分或全部控件。
  • 禁用 controls 属性,并在页面的任何位置实现你自己的控制。

自定义内联控件

🌐 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.tsx
import {Player, PlayerRef, RenderCustomControls} from '@remotion/player'; import React, {useCallback, useEffect, useRef, useState} from 'react'; const DownloadButton: React.FC<{ playerRef: React.RefObject<PlayerRef | null>; }> = ({playerRef}) => { const [frame, setFrame] = useState(0); useEffect(() => { const {current} = playerRef; if (!current) return; const onFrameUpdate = () => { 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 const App: React.FC = () => { const playerRef = useRef<PlayerRef>(null); const renderCustomControls: RenderCustomControls = useCallback(() => { return <DownloadButton playerRef={playerRef} />; }, []); return ( <Player ref={playerRef} component={MyVideo} durationInFrames={120} compositionWidth={1920} compositionHeight={1080} fps={30} controls renderCustomControls={renderCustomControls} /> ); };
note

使用 PlayerRef 从自定义控件中访问播放器状态和方法。下面的 播放器外部控件 部分中显示的模式在控件插槽内部的用法是相同的。

播放器之外的控制

🌐 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/> 的类型为 PlayerRefref
  • 其中一些组件将需要 durationInFramesfps 属性。将这些值放入共享变量,以便在 <Player/> 和这些组件中使用。
  • <SeekBar/> 组件可以选择性地接受 inFrameoutFrame 属性。它们是传递给 <Player/>(也是可选的)的相同值。

播放 / 暂停按钮

🌐 Play / Pause button

PlayPauseButton.tsx
import type {PlayerRef} from '@remotion/player'; import {useCallback, useEffect, useState} from 'react'; export const PlayPauseButton: React.FC<{ playerRef: React.RefObject<PlayerRef | null>; }> = ({playerRef}) => { const [playing, setPlaying] = useState(false); useEffect(() => { const {current} = playerRef; setPlaying(current?.isPlaying() ?? false); if (!current) return; const onPlay = () => { setPlaying(true); }; const onPause = () => { setPlaying(false); }; current.addEventListener('play', onPlay); current.addEventListener('pause', onPause); return () => { current.removeEventListener('play', onPlay); current.removeEventListener('pause', onPause); }; }, [playerRef]); const onToggle = useCallback(() => { playerRef.current?.toggle(); }, [playerRef]); return ( <button onClick={onToggle} type="button"> {playing ? 'Pause' : 'Play'} </button> ); };
note

此代码片段中未实现缓冲指示器

时间显示

🌐 Time display

TimeDisplay.tsx
import type {PlayerRef} from '@remotion/player'; import React, {useEffect} from 'react'; export const formatTime = (frame: number, fps: number): string => { const hours = Math.floor(frame / fps / 3600); const remainingMinutes = frame - hours * fps * 3600; const minutes = Math.floor(remainingMinutes / 60 / fps); const remainingSec = frame - hours * fps * 3600 - minutes * fps * 60; const seconds = Math.floor(remainingSec / fps); const frameAfterSec = Math.round(frame % fps); const hoursStr = String(hours); const minutesStr = String(minutes).padStart(2, '0'); const secondsStr = String(seconds).padStart(2, '0'); const frameStr = String(frameAfterSec).padStart(2, '0'); if (hours > 0) { return `${hoursStr}:${minutesStr}:${secondsStr}.${frameStr}`; } return `${minutesStr}:${secondsStr}.${frameStr}`; }; export const TimeDisplay: 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; } const onTimeUpdate = () => { 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> ); };
note

视频编辑器的常规时间格式hh:mm:ss.ff,其中 hh 是小时,mm 是分钟,ss 是秒,ff 是秒后帧数。

全屏按钮

🌐 Fullscreen button

在实现全屏按钮时注意两个细微差别:

🌐 Pay attention to two nuances when implementing the Fullscreen button:

  • 并非所有浏览器都支持全屏功能,应进行功能检测。
  • 如果使用服务器端渲染,应该在组件在客户端挂载后进行功能检测,以避免 React 的 hydration 不匹配。
FullscreenButton.tsx
import type {PlayerRef} from '@remotion/player'; import React, {useCallback, useEffect, useState} from 'react'; export const FullscreenButton: 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; } const onFullscreenChange = () => { setIsFullscreen(document.fullscreenElement !== null); }; current.addEventListener('fullscreenchange', onFullscreenChange); return () => { current.removeEventListener('fullscreenchange', onFullscreenChange); }; }, [playerRef]); useEffect(() => { // Must be handled client-side to avoid SSR hydration mismatch setSupportsFullscreen( (typeof document !== 'undefined' && (document.fullscreenEnabled || // @ts-expect-error Types not defined document.webkitFullscreenEnabled)) ?? false, ); }, []); const onClick = 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> ); };
note

Exit Fullscreen 标签是假设性的,因为如果它在播放器外呈现,在全屏模式下将不可见。

进度条

🌐 Seek bar

SeekBar.tsx
import type {PlayerRef} from '@remotion/player'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {interpolate} from 'remotion'; type Size = { 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 const useElementSize = ( ref: React.RefObject<HTMLElement | null>, ): Size | null => { const [size, setSize] = useState<Size | null>(() => { if (!ref.current) { return null; } const rect = 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, }; }); const observer = useMemo(() => { if (typeof ResizeObserver === 'undefined') { return null; } return new ResizeObserver((entries) => { const {target} = entries[0]; const newSize = 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, }); }); }, []); const updateSize = useCallback(() => { if (!ref.current) { return; } const rect = ref.current.getClientRects(); if (!rect[0]) { setSize(null); return; } setSize((prevState) => { const isSame = prevState && prevState.width === rect[0].width && prevState.height === rect[0].height && prevState.left === rect[0].x && prevState.top === rect[0].y; if (isSame) { return prevState; } 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]); return useMemo(() => { if (!size) { return null; } return {...size, refresh: updateSize}; }, [size, updateSize]); }; const getFrameFromX = ( clientX: number, durationInFrames: number, width: number, ) => { const pos = clientX; const frame = Math.round( interpolate(pos, [0, width], [0, Math.max(durationInFrames - 1, 0)], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }), ); return frame; }; const BAR_HEIGHT = 5; const KNOB_SIZE = 12; const VERTICAL_PADDING = 4; const containerStyle: React.CSSProperties = { userSelect: 'none', WebkitUserSelect: 'none', paddingTop: VERTICAL_PADDING, paddingBottom: VERTICAL_PADDING, boxSizing: 'border-box', cursor: 'pointer', position: 'relative', touchAction: 'none', flex: 1, }; const barBackground: React.CSSProperties = { height: BAR_HEIGHT, backgroundColor: 'rgba(0, 0, 0, 0.25)', width: '100%', borderRadius: BAR_HEIGHT / 2, }; const findBodyInWhichDivIsLocated = (div: HTMLElement) => { let current = div; while (current.parentElement) { current = current.parentElement; } return current; }; export const useHoverState = (ref: React.RefObject<HTMLDivElement | null>) => { const [hovered, setHovered] = useState(false); useEffect(() => { const {current} = ref; if (!current) { return; } const onHover = () => { setHovered(true); }; const onLeave = () => { setHovered(false); }; const onMove = () => { 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]); return hovered; }; export const SeekBar: React.FC<{ durationInFrames: number; inFrame?: number | null; outFrame?: number | null; playerRef: React.RefObject<PlayerRef | null>; }> = ({durationInFrames, inFrame, outFrame, playerRef}) => { const containerRef = useRef<HTMLDivElement>(null); const barHovered = useHoverState(containerRef); const size = useElementSize(containerRef); const [playing, setPlaying] = useState(false); const [frame, setFrame] = useState(0); useEffect(() => { const {current} = playerRef; if (!current) { return; } const onFrameUpdate = () => { setFrame(current.getCurrentFrame()); }; current.addEventListener('frameupdate', onFrameUpdate); return () => { current.removeEventListener('frameupdate', onFrameUpdate); }; }, [playerRef]); useEffect(() => { const {current} = playerRef; if (!current) { return; } const onPlay = () => { setPlaying(true); }; const onPause = () => { 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, }); const width = size?.width ?? 0; const onPointerDown = useCallback( (e: React.PointerEvent<HTMLDivElement>) => { if (e.button !== 0) { return; } if (!playerRef.current) { return; } const posLeft = 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], ); const onPointerMove = useCallback( (e: PointerEvent) => { if (!size) { throw new Error('Player has no size'); } if (!dragging.dragging) { return; } if (!playerRef.current) { return; } const posLeft = containerRef.current?.getBoundingClientRect() .left as number; const _frame = getFrameFromX( e.clientX - posLeft, durationInFrames, size.width, ); playerRef.current.seekTo(_frame); }, [dragging.dragging, durationInFrames, playerRef, size], ); const onPointerUp = 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; } const body = findBodyInWhichDivIsLocated( containerRef.current as HTMLElement, ); body.addEventListener('pointermove', onPointerMove); body.addEventListener('pointerup', onPointerUp); return () => { body.removeEventListener('pointermove', onPointerMove); body.removeEventListener('pointerup', onPointerUp); }; }, [dragging.dragging, onPointerMove, onPointerUp]); const knobStyle: 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]); const fillStyle: 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]); const active: 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.tsx
import React from 'react'; export const LoopButton: React.FC<{ loop: boolean; setLoop: React.Dispatch<React.SetStateAction<boolean>>; }> = ({loop, setLoop}) => { const onClick = React.useCallback(() => { setLoop((prev) => !prev); }, [setLoop]); return ( <button type="button" onClick={onClick}> {loop ? 'Loop enabled' : 'Loop disabled'} </button> ); };
Usage
import React, {useState} from 'react'; import {LoopButton} from './LoopButton'; import {Player} from '@remotion/player'; export const MyComponent: 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.tsx
import type {PlayerRef} from '@remotion/player'; import React, {useEffect, useState} from 'react'; export const VolumeSlider: 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; } const onVolumeChange = () => { setVolume(current.getVolume()); }; const onMuteChange = () => { setMuted(current.isMuted()); }; current.addEventListener('volumechange', onVolumeChange); current.addEventListener('mutechange', onMuteChange); return () => { current.removeEventListener('volumechange', onVolumeChange); current.removeEventListener('mutechange', onMuteChange); }; }, [playerRef]); const onChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback( (evt) => { if (!playerRef.current) { return; } const newVolume = 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.tsx
import type {PlayerRef} from '@remotion/player'; import React, {useEffect, useState} from 'react'; export const MuteButton: React.FC<{ playerRef: React.RefObject<PlayerRef | null>; }> = ({playerRef}) => { const [muted, setMuted] = useState(playerRef.current?.isMuted() ?? false); const onClick = 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; } const onMuteChange = () => { setMuted(current.isMuted()); }; current.addEventListener('mutechange', onMuteChange); return () => { current.removeEventListener('mutechange', onMuteChange); }; }, [playerRef]); return ( <button type="button" onClick={onClick}> {muted ? 'Unmute' : 'Mute'} </button> ); };