React实现视频播放器组件

2022-6-10 20 min read

TOC

前言

现在人们已经离不开从视频中获取乐趣,对于这些网站视频播放器是一个很重要的功能,因此我准备写一个视频播放器组件并将其发布成npm包方便使用。

背景

最近才学完React不久,准备先用它和开发体验很好的Vite进行编写,等到该组件完善后再将仓库转为Monorepo,并添加上Vue3的版本。

技术选型

React18 + Vanilla-Extract + Vite

组件结构

Image.png

首先在最外层我们使用 figure 标签来包裹,里面放上video元素,为了实现关灯功能,我们在video元素下面放置一个div标签,设置宽高分别为 100vw 和100vh,使其撑满整个页面,并设置固定定位使其脱离文档流。

Image.png

由于视频播放会处于加载状态,我们需要加上 loading 动画,提高用户体验,因此下面放置加载缓冲组件,最后是核心组件Controller,外面包裹上Context,用于向下传递组件的通用数据。

Controller

Image.png

controller组件是整个组件的核心组件,为了能够检测到鼠标的移入需撑满整个组件,覆盖在 video 元素上面。

里面包含一片区域用于检测鼠标是否进行点击来播放/暂停视频。下面放入进度条和视频控件:

Image.png

controls

Image.png

视频控件包含对视频的一些功能控制:

  • 切换画质
  • 倍速
  • 音量调节
  • 设置
    • 循环播放
    • 关灯
  • 截图
  • 画中画
  • 网页全屏
  • 全屏

Core

为了实现组件的自定义配置,我们需要将父组件传入的数据通过props出入视频组件内部,例如组件的宽高,视频的 src 等。

首先通过useRef钩子来获取一些 DOM 元素:

// 对应 figure 标签
const videoContainerRef = useRef<HTMLElement>(null!);

const lightOffMaskRef = useRef<HTMLDivElement>(null);

const videoRef = useRef<HTMLVideoElement>(null!);

// 检测视频源是否可用
const timerToCheckVideoIsUseful = useRef<NodeJS.Timeout | null>(null);

useEffect中进行初始化,设置视频组件的宽高,为了支持hls(HTTP Live Streaming)引入hls.js这个库,封装一个函数在初始化时调用:

const setHls = (videoElem: HTMLVideoElement) => {
    if (videoType && videoType === 'hls') {
      if (Hls.isSupported()) {
        const hls = new Hls();
        hls.loadSource(videoSrc);
        hls.attachMedia(videoElem);
      }
    }
  };

视频源由于一些原因可能加载失败,我们需要在useEffect初始化时使用定时器进行检测:

timerToCheckVideoIsUseful.current = setTimeout(() => {
      // 0: NETWORK_EMPTY    3: NETWORK_NO_SOURCE
      if (videoElem.networkState === 0 || videoElem.networkState === 3) {
        toast({
          message: 'Error: Video source is not found',
          duration: 4000,
          position: toastPosition,
        });
      }
      else {
        clearTimeout(timerToCheckVideoIsUseful.current!);
      }
    }, 3000);

接下来给 video 元素添加两个事件:waitingplaying,当视频等待加载时设置Buffer组件显示,播放时进行隐藏,useEffect返回的回调函数中清除定时器和这两个事件,至此useEffect中的工作完成。

useVideo hook

该 hook 主要用于定义 video 元素的一些参数并为其添加一些事件监听,这些参数以对象的形式存储在useRef返回的对象中,这是useRef的另一个用法,主要是解决数据改变触发React频繁更新的问题,使用useRef后数据会响应式改变,但页面并不会更新,能够提高性能,如果想更新,可以通过useState返回的setState进行调用来强制更新,useReducer也同样可以实现:

const useMandatoryUpdate = () => {
  const [, forceUpdate] = useReducer(v => v + 1, 0);
  return forceUpdate;
};

最后返回videoAttributesvideoMethods等:

interface VideoAttributes<T = number, K = boolean> {
  /**
   * @description whether to play
   */
  isPlay: K;
  /**
   * @description current time, unit: s
   */
  currentTime: T;
  /**
   * @description total time, unit: s
   */
  duration: T;
  /**
   * @description buffered time, unit: s
   */
  bufferedTime: T;
  /**
   * @description wether to enable picture-in-picture mode
   */
  isPicInPic: K;
  /**
   * @description volume
   */
  volume: T;
  /**
   * @description video playback speed
   */
  multiple: T;
  /**
   * @description whether to end
   */
  isEnded: K;
  /**
   * @description error
   */
  error: null | T;
}

interface VideoMethod<T = NoParamsVoidFn> {
  /**
   * @description reload
   */
  load: T;
  /**
   * @description start playing
   */
  play: T;
  /**
   * @description pause
   */
  pause: T;
  /**
   * @description set volume
   */
  setVolume: ParamsVoidFn<number>;
  /**
   * @description set the playing position of the specified video
   */
  seek: ParamsVoidFn<number>;
  /**
   * @description set source of the video
   */
  setVideoSrc: ParamsVoidFn<string>;
}

useVideoFlow hook

hook包含了ControlsProgress组件的一些状态信息,由于类别较多,使用useReducer来进行派发:

interface VideoStateType<K = boolean, T = number> {
  /**
   * @description whether to show the control component
   */
  isControl: K;
  /**
   * @description the changing data of the onProgressMouseDown event
   */
  progressSliderChangeVal: T;
  /**
   * @description the changing data of the onProgressMouseUp event
   */
  progressMouseUpChangeVal: T;
  /**
   * @description video quality
   */
  quality: QualityKey | undefined;
}

function useVideoFlow() {
  const reducer = (state: VideoStateType, action: MergeAction) => {
    switch (action.type) {
      case 'isControl':
        return { ...state, isControl: action.data };
      case 'progressSliderChangeVal':
        return { ...state, progressSliderChangeVal: action.data };
      case 'progressMouseUpChangeVal':
        return { ...state, progressMouseUpChangeVal: action.data };
      case 'quality':
        return { ...state, quality: action.data };
      default:
        return state;
    }
  };
  const [videoFlow, dispatch] = useReducer(reducer, initialState);
  return { videoFlow, dispatch };

useCallback hook

为了执行用户传进来的回调函数,封装一个hook来统一执行,例如播放暂停时执行的回调,视频结束时的回调等等:

interface VideoCallback<T = CallbackType, U = CallbackType<QualityKey>> {
  /**
   * @description drag the progress bar callback
   */
  onProgressMouseDown: T;
  /**
   * @description video start playing callback
   */
  onPlay: T;
  /**
   * @description video pause callback
   */
  onPause: T;
  /**
   * @description time change callback
   */
  onTimeChange: T;
  /**
   * @description video end callback
   */
  onEndEd: T;
  /**
   * @description slider release callback
   */
  onProgressMouseUp: T;
  /**
   * @description play error callback
   */
  onError: NoParamsVoidFn;
  /**
   * @description volume change callback
   */
  onVolumeChange: T;
  /**
   * @description quality change callback
   */
  onQualityChange: U;
}

数据传递

为了子组件能够获取到通用的数据,我们给Controller组件包裹上一层Context,并将需要共享的数据通过useMemo缓存起来赋值给其value属性:

const contextProps = useMemo(() => {
    return Object.assign(
      {},
      {
        videoRef: videoRef.current,
        videoContainerRef: videoContainerRef.current,
        lightOffMaskRef: lightOffMaskRef.current,
        dispatch,
        videoFlow,
        propsAttributes: options,
      },
    );
  }, [videoRef.current, videoFlow, options]);

除了向内传递数据,在使用视频组件的时候也需要向外传递组件的一些参数,我们使用useImperativeHandle这个 hook 来向外传递组件内部数据。

useImperativeHandle(ref, () => {
    return {
      video: videoRef.current,
      ...videoMethod,
      ...videoAttributes,
    };
  });

Controller

Controller组件时铺满整个组件的,当鼠标移入该组件时需要显示控件,所以给它加上两个事件监听:onMouseEnteronMouseLeave,通过ContextProps传递来的dispatch方法来派发isControl的值。

为了检测鼠标是否在点击区域和Controls内移动,我们分别定义userActivityisControlsContainerMove变量来表示,另外需要两个定时器,在useEffect中,先使用循环定时器,在其中通过userActivity来判断鼠标是否移动,移动的话进行则清除另一个在鼠标未移动时隐藏的定时器:

useEffect(() => {
    timer.current = setInterval(() => {
      if (userActivity.current) {
        /**
         * @description reset
         */
        userActivity.current = false;
        dispatch!({ type: 'isControl', data: true });
        controllerRef.current.style.cursor = 'pointer';
        inactivityTimeout.current && clearTimeout(inactivityTimeout.current);
        inactivityTimeout.current = setTimeout(
          () => {
            /**
             * @note the mouse doesn't hide when the mouse is moving in the controller
             */
            if (!userActivity.current && !isControlsContainerMove.current) {
              dispatch!({ type: 'isControl', data: false });
              controllerRef.current.style.cursor = 'none';
            }
          },
          propsAttributes!.hideMouseTime ? propsAttributes!.hideMouseTime : 2000,
        );
      }
    }, 200);

    return () => {
      timer.current && clearInterval(timer.current);
    };
  }, []);

然后给点击区域和Controls区域分别加上对应的鼠标移动和离开事件以改变上述两个变量的值。

为了控制Screenshot组件的显示,需要在Controller组件中将setIsScreenshot传递给Controls,将isScreenshot通过props传递给Screenshot组件。

Progress

进度条通过opacity来控制显示与隐藏,如果videoFlowisControl属性为true则设为1,false为0。

缓冲条和播放条

遇到视频进度条时,除了当前进度外还有一条白色的进度条,这就是缓冲条,表示当前缓冲的进度,其默认宽度设为0%,然后根据useVideo返回的已缓冲时间bufferedTime和总时长duration相除并乘以 100 得到百分比:

const calculateBufferedPercent = useMemo(() => {
    return ((bufferedTime / duration) * 100).toString();
  }, [bufferedTime, duration]);

播放条表示当前播放的进度,将bufferedTime换成currentTime即可:

const calculateProcessPercent = useMemo(() => {
    return ((currentTime / duration) * 100).toString();
  }, [duration, currentTime]);

useProgress hook

hook记录了当前进度条的一些信息,比如用户是否在移动进度条,进度条的百分比,位置信息等,与useVideoFlow一样通过useReducer来进行控制:

export const useProgress = () => {
  const initialState = {
    positionX: 0,
    isMovingProgress: false,
    progressPercent: 0,
    isDrag: false,
  };

  const reducer = (state: ProgressVariableType, action: MergeAction) => {
    switch (action.type) {
      case 'positionX':
        return { ...state, positionX: action.data };
      case 'isMovingProgress':
        return { ...state, isMovingProgress: action.data };
      case 'progressPercent':
        return { ...state, progressPercent: action.data };
      case 'isDrag':
        return { ...state, isDrag: action.data };
      default:
        return state;
    }
  };
  const [progressState, dispatch] = useReducer(reducer, initialState);

  return { progressState, dispatch };
};

拖动条

为了检测鼠标是否放在进度条区域,我们设置一个高度较大的区域覆盖在进度条上,当鼠标放在该区域上则通过mousemove来进行监听,派发isMovingProgress事件的值为true,并且在useEffect中给判断isMovingProgress的值来控制进度条的高度和小圆点的显示,当用户离开该区域则设置isMovingProgress为 false 恢复之前的状态。

接下来在mousemove事件中通过计算鼠标的距离左侧视口的距离拖动条距离左侧视口的距离之差来求得当前鼠标在进度条上的位置,然后通过与拖动条的宽度比来求出当前视频播放比:

const popCurrentVideoImgBox = (e: MouseEvent) => {
    const seekPositionX
      = e.clientX - progressSeekMaskRef.current.getBoundingClientRect().left + 1;
    dispatch({
      type: 'progressPercent',
      data: seekPositionX / progressSeekMaskRef.current.offsetWidth,
    });
    dispatch({ type: 'isMovingProgress', data: true });
    dispatch({ type: 'positionX', data: seekPositionX });
  };

如果直接点击则直接修改 video 的currentTime为上面移动过程中获取的百分比然后乘以总时长即可改变当前进度。

当开始拖动该区域时鼠标处于press状态,使用onmousedown进行监听,计算出当前进度位置后,调用updateCurrentTime这个函数来进行更新进度:

const updateCurrentTime = (
    seekPositionX: number,
    progressSeekMaskRefOffsetWidth: number,
  ) => {
    if (seekPositionX >= 0 && seekPositionX <= progressSeekMaskRefOffsetWidth) {
      const progressPercent = seekPositionX / progressSeekMaskRefOffsetWidth;
      dispatch({
        type: 'progressPercent',
        data: progressPercent,
      });
      // 更新视频当前播放的时长,使用进度百分比 * 总时长
      Props.videoRef!.currentTime = percentToSeconds(progressPercent, duration);
      dispatch({ type: 'positionX', data: seekPositionX });
      dispatch({ type: 'isMovingProgress', data: true });
      dispatch({ type: 'isDrag', data: true });
    }
    if (seekPositionX < 0)
      Props.videoRef!.currentTime = 0;

    if (seekPositionX > progressSeekMaskRefOffsetWidth)
      Props.videoRef!.currentTime = duration;
  };

由于拖拽到释放鼠标过程中只会执行一次mousedown事件,所以我们添加一个循环定时器,按照 1ms 一次的频率执行更新,当触发鼠标拖拽事件的回调函数时,开启该定时器进行持续更新,当用户释放鼠标执行事件移除操作:

const changeCurrentTime = () => {
    const progressSeekMaskRefOffsetWidth = progressSeekMaskRef.current.offsetWidth;
    interval.current && clearInterval(interval.current);
    interval.current = setInterval(() => {
      const seekPositionX
        // 使用 useWindowClient 获取鼠标的位置
        = clientXDistance.current
        - progressSeekMaskRef.current.getBoundingClientRect().left
        + 1;
      updateCurrentTime(seekPositionX, progressSeekMaskRefOffsetWidth);
      Props.dispatch!({ type: 'progressSliderChangeVal', data: Date.now() });
    }, 1);
  };

  const whenMouseUpDo = () => {
    interval.current && clearInterval(interval.current);
    if (currentTime < duration && progressState.isDrag) {
      Props.videoRef!.play();
      dispatch({ type: 'isDrag', data: false });
    }
    dispatch({ type: 'isMovingProgress', data: false });
  };

 useEffect(() => {
    window.addEventListener('mouseup', whenMouseUpDo);
    return () => {
      window.removeEventListener('mouseup', whenMouseUpDo);
    };
  }, [currentTime, duration, progressState.isDrag]);

注意:在更新时我们重新计算了鼠标在进度上的位置,为什么不使用鼠标在区域上移动时获取的位置?主要原因是拖拽回调只会执行一次,此时的内部获取的位置是拖拽时的位置,拖拽过程中位置的更新在循环定时器中无法获取到,另外用户在拖拽期间鼠标有可能脱离的检测移动的区域,所以要封装一个 hook 来重新检测鼠标的位置。

useWindowClient hook

由于在拖拽期间我们要更新显示播放条的进度,所以需要即时获取鼠标距离左视口的位置,而拖拽的鼠标事件只能获取到刚进行拖拽时的鼠标位置,所以需要封装一个 hook 来辅助:

const useWindowClient = (): useWindowSizeType => {
  const [windowDistance, setWindowDistance] = useState<useWindowSizeType>({
    clientX: 0,
    clientY: 0,
  });

  useEffect(() => {
    function handle(e: MouseEvent) {
      setWindowDistance(() => {
        return {
          clientX: e.clientX,
          clientY: e.clientY,
        };
      });
    }
    window.addEventListener('mousemove', handle);
    return () => window.removeEventListener('mousemove', handle);
  }, []);

  return { ...windowDistance };
};

Controls

在进度条组件下面是Controls组件,是视频组件的一些功能控件。

我们在Controls部分设置了width:calc(100% - 26px)导致两边存有一些缝隙,当从播放暂停区域移至该区域并停留时,由于该区域既不属于点击播放暂停区域也不属于包裹进度条和控件的区域,所以会触发隐藏Controls和鼠标的bug。

因此我们给Controls组件添加伪元素来覆盖整个区域:

'::after': {
    content: '',
    width: 'calc(100% + 26px)',
    height: '100%',
    ...positionStyle('absolute', 'auto', 'auto', 0, '50%'),
    zIndex: -1,
    transform: 'translateX(-50%)',
    background: 'linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent)',
  },

useControls

为了记录控件的具体信息,封装一个 hook 来进行管理:

const useControls = () => {
  const initialState = {
    volume: defaultVolume,
    isMuted: false,
    isSlideVolume: false,
    isScreenFull: false,
    multiple: 1.0,
    isWebPageFullScreen: false,
    isMute: false,
  };

  const reducer = (state: controlsVariableType, action: mergeAction) => {
    switch (action.type) {
      case 'volume':
        return { ...state, volume: action.data };
      case 'isMuted':
        return { ...state, isMuted: action.data };
      case 'isSlideVolume':
        return { ...state, isSlideVolume: action.data };
      case 'isScreenFull':
        return { ...state, isScreenFull: action.data };
      case 'multiple':
        return { ...state, multiple: action.data };
      case 'isWebPageFullScreen':
        return { ...state, isWebPageFullScreen: action.data };
      case 'isMute':
        return { ...state, isMute: action.data };
      default:
        return state;
    }
  };
  const [controlsState, dispatch] = useReducer(reducer, initialState);

  return { controlsState, dispatch };
};

Monitor

位于左下角的该组件用于展示已播放时长和总时长,以及一个播放暂停按钮。

Controls组件中使用useVideo获取到视频元素的参数信息,将currentTimeduration转换成分钟和秒的形式后和isPlay传递给该组件即可,组件内更新播放暂停 Icon 时通过判断isPlay来进行切换。

Quality

该组件只有传入画质列表且列表长度不为 0 才会进行展示,为了控制画质列表的展示,我们通过使用useState返回的setIsShow来进行设置,当点击该组件时设置取反,鼠标离开后设置false。

通过map来遍历渲染画质列表,给每一项添加点击事件。

为了能够在Controls中获取到用户选择的画质选项,需要给Quality组件传入一个回调函数,在用户点击后触发,调用videoMethod中的设置视频源并重新定位到当前进度:

const qualityToggle: QualityToggleType = (url, key) => {
    contentDispatch!({ type: 'quality', data: key });
    videoMethod.setVideoSrc(url);
    videoMethod.seek(currentTime);
    videoMethod.play();
  };

Multiple

倍速组件使用map渲染默认列表并添加点击事件,在其中执行父组件传递过来的回调函数将当前点击的选项数据传递并调用,然后设置 video 元素的playbackRate属性。

Set

Controls中定义一个switchChange函数,当Set组件中两个开关改变时触发该函数:

const switchChange = (e: string, flag: string) => {
    const { videoRef, lightOffMaskRef } = propsData.current!;
    if (flag === 'lights') {
      if (lightOffMaskRef)
        lightOffMaskRef.style.display = e === 'yes' ? 'block' : 'none';
    }
    else {
      const loop = videoRef!.loop;
      videoRef!.loop = !loop;
    }
  };

然后在Set组件引用Switch组件时传递进去:

<Switch
	sole="lights"
	label={i18n(language || defaultLanguage, 'closeLights')}
	onChange={(e: string) => switchChange(e, 'lights')}
	theme={theme}
/>

Switch组件中继续调用Set传递进来的回调:

// switch/index.tsx
const switchChange: React.ChangeEventHandler<HTMLInputElement> = e => {
    e.stopPropagation();
    const status = e.target.value === 'yes' ? 'no' : 'yes';
    setOn(status);
    onChange && onChange(status);
  };

Screenshot

为了能够捕获到视频画面,封装一个capture方法:

export const capture = (video: HTMLVideoElement, scaleFactor = 0.25) => {
  const w = video.videoWidth * scaleFactor;
  const h = video.videoHeight * scaleFactor;
  const canvas = document.createElement('canvas') as HTMLCanvasElement;
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d');
  ctx!.drawImage(video, 0, 0, w, h);
  return canvas;
};

当点击截图的 icon 时,调用Controller传递进来的setIsScreenshot显示截图界面,由于setState方法异步执行,Dom并没有更新,所以我们拿不到相应的 Dom 元素,所以应该在 setTimeout 中调用caputre方法:

// controls.tsx
const screenshot = () => {
    setIsScreenshot(true);
    setTimeout(() => {
      const output = document.querySelector('#screenshotCanvas')!;
      const canvas = capture(videoProps.videoRef!, 0.45);
      if (output) {
        setScreenshotLoading(false);
        output.innerHTML = '';
        output.appendChild(canvas);
      }
      else {
        setScreenshotLoading(true);
      }
    });
  };