Remotion LabRemotion Lab
Player為 Player 建立自訂控制項

為 Player 建立自訂控制項

學習兩種為 Remotion Player 建立自訂控制項的方法:自訂行內控制項和 Player 外部的完整自訂控制項。

為 Player 建立自訂控制項

你可能想為 <Player> 元件實作自訂控制項。

有兩種方式可以達成:

  • 啟用 controls prop,並細粒度地覆蓋 Player 內部的部分或全部控制項。
  • 停用 controls prop,並在頁面任意位置實作自己的控制項。

自訂行內控制項

適用情境:

  • 喜歡預設控制項,但想自訂其中一些
  • 希望控制項覆蓋在 Player 上方

確保在 <Player> 中設定 controls prop。使用以下 API 來自訂各個控制項:

控制項插槽(Controls Slot)

Player 在主要播放控制項和全螢幕按鈕之間提供一個插槽,你可以使用 renderCustomControls prop 在其中渲染自訂控制項。

當你想要新增自訂按鈕或指示器,並讓其與預設控制列融合時,請使用此方法。

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>;
}> = ({ 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
      onClick={() => console.log('在幀', frame, '下載')}
      style={{
        background: 'transparent',
        border: 'none',
        color: 'white',
        cursor: 'pointer',
      }}
    >
      下載
    </button>
  );
};
 
const MyVideo: React.FC = () => null;
 
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}
    />
  );
};

注意:使用 PlayerRef 從自訂控制項中存取 Player 狀態和方法。下方「Player 外部控制項」章節中展示的模式在控制項插槽中同樣適用。

Player 外部的控制項

適用情境:

  • 想在頁面任意位置實作自訂控制項
  • 需要完全控制控制項的外觀和行為

使用以下起始範本來實作你自己的控制項。你需要以下先決條件:

  • 確保 <Player>設定 controls prop
  • 取得 <Player>PlayerRef 型別的 ref
  • 某些元件需要 durationInFramesfps props,將這些值放在共用變數中供 <Player> 和這些元件使用
  • <Player> 元件可選擇性接受 inFrameoutFrame props,它們與傳給 <Player> 的值相同(也是可選的)

播放 / 暫停按鈕

PlayPauseButton.tsx
import type { PlayerRef } from '@remotion/player';
import { useCallback, useEffect, useState } from 'react';
 
export const PlayPauseButton: React.FC<{
  playerRef: React.RefObject<PlayerRef>;
}> = ({ 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}>
      {playing ? '暫停' : '播放'}
    </button>
  );
};

注意:此程式碼片段中未實作緩衝指示器

時間顯示

TimeDisplay.tsx
import type { PlayerRef } from '@remotion/player';
import React, { useEffect, useState } 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>;
}> = ({ durationInFrames, fps, playerRef }) => {
  const [time, setTime] = useState(0);
 
  useEffect(() => {
    const { current } = playerRef;
    if (!current) return;
 
    const onFrameUpdate = () => {
      setTime(current.getCurrentFrame());
    };
 
    current.addEventListener('frameupdate', onFrameUpdate);
    return () => {
      current.removeEventListener('frameupdate', onFrameUpdate);
    };
  }, [playerRef]);
 
  return (
    <div>
      {formatTime(time, fps)} / {formatTime(durationInFrames - 1, fps)}
    </div>
  );
};

進度條 / 搜尋列

SeekBar.tsx
import type { PlayerRef } from '@remotion/player';
import React, { useCallback, useEffect, useRef, useState } from 'react';
 
export const SeekBar: React.FC<{
  durationInFrames: number;
  playerRef: React.RefObject<PlayerRef>;
  inFrame?: number | null;
  outFrame?: number | null;
}> = ({ durationInFrames, playerRef, inFrame, outFrame }) => {
  const [frame, setFrame] = useState(0);
  const [dragging, setDragging] = useState(false);
  const barRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const { current } = playerRef;
    if (!current) return;
 
    const onFrameUpdate = () => {
      if (!dragging) {
        setFrame(current.getCurrentFrame());
      }
    };
 
    current.addEventListener('frameupdate', onFrameUpdate);
    return () => {
      current.removeEventListener('frameupdate', onFrameUpdate);
    };
  }, [dragging, playerRef]);
 
  const onPointerDown = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      const { current } = barRef;
      if (!current) return;
 
      const rect = current.getBoundingClientRect();
      const pos = (e.clientX - rect.left) / rect.width;
      const newFrame = Math.round(pos * (durationInFrames - 1));
 
      setDragging(true);
      setFrame(newFrame);
      playerRef.current?.seekTo(newFrame);
 
      const onPointerMove = (moveEvent: PointerEvent) => {
        const movePos = (moveEvent.clientX - rect.left) / rect.width;
        const moveFrame = Math.round(
          Math.max(0, Math.min(1, movePos)) * (durationInFrames - 1)
        );
        setFrame(moveFrame);
        playerRef.current?.seekTo(moveFrame);
      };
 
      const onPointerUp = () => {
        setDragging(false);
        window.removeEventListener('pointermove', onPointerMove);
      };
 
      window.addEventListener('pointermove', onPointerMove);
      window.addEventListener('pointerup', onPointerUp, { once: true });
    },
    [durationInFrames, playerRef],
  );
 
  const progress = frame / (durationInFrames - 1);
 
  return (
    <div
      ref={barRef}
      onPointerDown={onPointerDown}
      style={{
        height: 4,
        backgroundColor: '#333',
        cursor: 'pointer',
        position: 'relative',
      }}
    >
      <div
        style={{
          height: '100%',
          width: `${progress * 100}%`,
          backgroundColor: '#0B84F3',
        }}
      />
    </div>
  );
};

音量控制

VolumeControl.tsx
import type { PlayerRef } from '@remotion/player';
import React, { useCallback, useEffect, useState } from 'react';
 
export const VolumeControl: React.FC<{
  playerRef: React.RefObject<PlayerRef>;
}> = ({ playerRef }) => {
  const [volume, setVolume] = useState(1);
  const [muted, setMuted] = useState(false);
 
  useEffect(() => {
    const { current } = playerRef;
    if (!current) return;
 
    setVolume(current.getVolume());
    setMuted(current.isMuted());
 
    const onVolumeChange = () => {
      setVolume(current.getVolume());
      setMuted(current.isMuted());
    };
 
    current.addEventListener('volumechange', onVolumeChange);
    return () => {
      current.removeEventListener('volumechange', onVolumeChange);
    };
  }, [playerRef]);
 
  const onMuteToggle = useCallback(() => {
    if (muted) {
      playerRef.current?.unmute();
    } else {
      playerRef.current?.mute();
    }
  }, [muted, playerRef]);
 
  const onVolumeChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newVolume = parseFloat(e.target.value);
      playerRef.current?.setVolume(newVolume);
    },
    [playerRef],
  );
 
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <button onClick={onMuteToggle}>{muted ? '取消靜音' : '靜音'}</button>
      <input
        type="range"
        min={0}
        max={1}
        step={0.01}
        value={muted ? 0 : volume}
        onChange={onVolumeChange}
      />
    </div>
  );
};

組合所有控制項

VideoPlayer.tsx
import { Player, PlayerRef } from '@remotion/player';
import React, { useRef } from 'react';
import { MyVideo } from './remotion/MyVideo';
import { PlayPauseButton } from './PlayPauseButton';
import { SeekBar } from './SeekBar';
import { TimeDisplay } from './TimeDisplay';
import { VolumeControl } from './VolumeControl';
 
const DURATION_IN_FRAMES = 300;
const FPS = 30;
 
export const VideoPlayer: React.FC = () => {
  const playerRef = useRef<PlayerRef>(null);
 
  return (
    <div>
      <Player
        ref={playerRef}
        component={MyVideo}
        durationInFrames={DURATION_IN_FRAMES}
        compositionWidth={1920}
        compositionHeight={1080}
        fps={FPS}
        // 不要設定 controls prop
      />
      <div style={{ padding: '8px 0' }}>
        <SeekBar
          durationInFrames={DURATION_IN_FRAMES}
          playerRef={playerRef}
        />
        <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
          <PlayPauseButton playerRef={playerRef} />
          <TimeDisplay
            durationInFrames={DURATION_IN_FRAMES}
            fps={FPS}
            playerRef={playerRef}
          />
          <VolumeControl playerRef={playerRef} />
        </div>
      </div>
    </div>
  );
};

PlayerRef 可用的方法

以下是 PlayerRef 上可用的主要方法:

方法說明
play(e?)播放(可選擇傳遞使用者事件)
pause()暫停
toggle(e?)切換播放 / 暫停
seekTo(frame)跳至指定幀
getCurrentFrame()取得目前幀
isPlaying()是否正在播放
isMuted()是否靜音
mute()靜音
unmute()取消靜音
setVolume(num)設定音量(0 到 1)
getVolume()取得音量
requestFullscreen()請求全螢幕
exitFullscreen()退出全螢幕
isFullscreen()是否全螢幕
pauseAndReturnToPlayStart()暫停並返回播放起始點

另請參閱