自定义移动端视频组件

1,738 阅读6分钟

前言

因在 APP Webview 内使用 video 标签会被浏览器劫持,导致在各机型上表现不一致。为了使 APP Webview 播放表现更一致,决定自定义 video 组件。

先来看看最终效果:

由于 iOS 在全屏状态下会使用原生的视频控制组件,所以在全屏状态下,Android、iOS 在全屏状态下均使用原生视频播放。

使用 React 实现。

一、步骤

  1. 数据结构
  2. DOM 元素及 CSS 样式编写
  3. 播放、暂停功能
  4. 时间显示
  5. 进度条
  6. 进入全屏、退出全屏控制,包含 Android、ios 兼容性

二、实现

数据结构

组件状态需要视频播放状态、当前时间、总时长、是否是全屏状态。

const initialState:VideoczState = {
  videoState: 'null',
  currentTime: 0,
  duration: 0,
  fullScreen: false,
};

const reducer = (state:VideoczState, action:VideoActionTypes) => {
  switch (action.type) {
    case VideoTypes.PLAY:
      return { ...state, videoState: 'play' };
    case VideoTypes.PAUSE:
      return { ...state, videoState: 'pause' };
    case VideoTypes.MODIFY:
      return { ...state, ...action.payload };
    case VideoTypes.ENTRYFULLSCREEN:
      return { ...state, fullScreen: true };
    case VideoTypes.QUITFULLSCREEN:
      return { ...state, fullScreen: false };
    default:
      throw new Error();
  }
};

DOM 元素及 CSS 样式编写

在 DOM 结构上分为四个部分:视频区、底部控制区、遮罩区(中间播放/暂停图标)、底部简易进度条。其中遮罩区为覆盖整个 video 区域,而底部简易进度条是可以并进底部控制区组件中的。

由于在移动端不需要音量调节,所以在组件中隐藏了音量调节。音量的实现和进度条是一样的,参考即可。

播放、暂停功能

播放、暂停功能就很简单了,直接调用 video 的 API 即可,代码参考:

const handleVideoPause = () => {
    videoRef.current && videoRef.current.pause();
  };

const handleVideoPlay = () => {
  videoRef.current && videoRef.current.play();
};

时间显示

由于 video 的 currentTime、duration 为毫秒数,需要对这两个毫秒数做格式化处理:

// 时间转换
export const formatDuraton = (time:number) => {
  let str = '00:00';
  if(time > 0) {
    const hour = Math.floor(time / 3600);
    const min = Math.floor(time / 60) % 60;
    const sec = Math.floor(time % 60);
    const minStr = min >= 10 ? `${min}` : `0${min}`;
    const secStr = sec >= 10 ? `${sec}` : `0${sec}`;
    if(hour > 10) {
      str = `${hour}:${minStr}:${secStr}`;
    }
    if(hour > 1) {
      str = `0${hour}:${minStr}:${secStr}`;
    } else {
      str = `${minStr}:${secStr}`;
    }
  }
  return str;
};

其中 duration 为视频的总时长,在视频未加载的情况下,无法获取到 duration,需要监听 video ondurationchange 钩子获取。注意:不能使用 onloadedmatedata 来获取 duration,此钩子在部分 Android 机中无法获得响应。

而 currentTime 为当前视频播放的时间节点,需要监听 ontimeupdate 钩子函数。

进度条显示

进度条的显示、拖拽、点击是自定义视频组件的重点。进度条的难点在于拖动的时候,需要知道拖动的终点占视频的百分之多少,从而调整视频当前时间,实现方式有两种,一种是使用 div 元素,一种是使用 input 元素,这里把两种实现方式都写一遍。

1. div 实现方式

使用 div 想知道拖动、点击时百分占比的话,需要通过 getBoundingClientRect 来获取进度条相对于屏幕的位置和拖动/点击时相对于屏幕的位置,从而计算出视频播放时间变化的百分比。

先来看看进度条结构,它包含三个部分:整体进度条、左侧已播放段、时间指针(thumb)

{/* 进度条 */}
<div
  id="progress"
  styleName="progress"
  style={progressStyle}
>
// 左侧已播放
<div style={progressSuppleStyle}>
	<span styleName="inside" style={insideStyle} />
</div>
// 时间指针
<span
  styleName="point"
  style={pointStyle}
  onMouseDown={handleMouseDownProgress}
  onTouchStart={handleMouseDownProgress} />
</div>

需要通过监听鼠标移动事件,来判断用户拖动,注意在 PC 端和移动端监听的事件名不一样:

// 监听手指放在时间指针上
useEffect(() => {
    const hanldeMouseUp = () => {
      if(!progressLockRef.current) {
        progressLockRef.current = true;
        dispatch({ type: VideoTypes.PAUSE });
      }
    };
    document.body.addEventListener('mouseup', hanldeMouseUp);
    document.body.addEventListener('touchend', hanldeMouseUp);

    return () => {
      document.body.removeEventListener('touchend', hanldeMouseUp);
      document.body.removeEventListener('mouseup', hanldeMouseUp);
    };
  }, []);

/**
   * 进度条拖动
   */
  useEffect(() => {
    const mouseMoveListener = (event:any) => {
      // 设置一个开关,只有用户放在时间指针上时才解锁
      // 防止用户在其它地方移动时,触发进度条拖动
      if(progressLockRef.current) return;
      
      const client = getClient(event);
      if(!client) return;
      const { clientX } = client;

      // 获取进度条位置
      const elePosition = getEleRelativeScreenPosition('progress');
      if(!elePosition) return;
      
      const { eleWidth: progressWidth, eleBorderLeft: progressBorderLeft, eleBorderRight: progressBorderRight } = elePosition;

      if(clientX <= progressBorderLeft) {
        // 拖动范围超出进度条左侧
        dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: 0 } });
      }else if(clientX >= progressBorderRight) {
        // 拖动范围超出进度条右侧
        dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: duration } });
      }else {
        // 拖动范围在进度条内
        const percent = (clientX - progressBorderLeft) / progressWidth;
        const time = Math.floor(duration * percent);
        dispatch({ type: VideoTypes.MODIFY, payload: { currentTime: time } });
      }
    };

    // PC 端
    window.addEventListener('mousemove', mouseMoveListener, false);
    // 移动端
    window.addEventListener('touchmove', mouseMoveListener, false);

    return () => {
      window.removeEventListener('mousemove', mouseMoveListener);
      window.removeEventListener('touchmove', mouseMoveListener, false);
    };
  }, [duration]);
// utils.js
// 获取元素位置
export const getEleRelativeScreenPosition = (ele:HTMLElement|string) => {
  let _ele;
  if(typeof ele === 'object') {
    _ele = ele;
  } else if(typeof ele === 'string') {
    _ele = document.getElementById(ele);
  }
  if(!isElement(_ele) || !_ele) return null;

  const client = _ele.getBoundingClientRect();
  const eleWidth = _ele ? _ele.offsetWidth : 0;
  const eleHeight = _ele ? _ele.offsetHeight : 0;
  // 元素左侧边界相对于屏幕左侧的位置
  const eleBorderLeft = _ele ? client.left : 0;
  // 元素右侧边界相对于屏幕左侧的位置
  const eleBorderRight = _ele ? client.right : 0;
  // 元素顶部边界相对于屏幕顶部的位置
  const eleBorderTop = _ele ? client.top : 0;
  // 元素底部边界相对于屏幕顶部的位置
  const eleBorderBottom = _ele ? client.bottom : 0;

  return { eleWidth, eleHeight, eleBorderBottom, eleBorderLeft, eleBorderRight, eleBorderTop };
};

2. input 实现方式

input 有 type=range 属性,原生支持拖动事件,实现起来比 div 简单很多。onChange 事件直接返回当前时间指针所占百分比

<input
  css={inputCss}
  type="range"
  min="0"
  max="100"
  value={percent >> 0}
  step="1"
  onMouseDown={(e) => {
    operation.handleVideoPause();
  }}
  onMouseUp={(e) => {
    operation.handleVideoPlay();
  }}
  onTouchStart={(e) => {
    operation.handleVideoPause();
  }}
  onTouchEnd={(e) => {
    operation.handleVideoPlay();
  }}

  onChange={(e) => {
    e.preventDefault();
    e.stopPropagation();
    const time = Math.floor(duration * (Number.parseInt(e.target.value) / 100));
    // TODO: 修改 video currentTime
  }}
/>

进入/退出全屏

1. 进入全屏

进入全屏直接使用 requestFullScreen API 即可,注意兼容性:

const handleFullScreen = () => {
    const container = document.getElementById('video-container');
    if(!container) return;

    if(videoRef.current) {
      if (videoRef.current.requestFullscreen) {
        videoRef.current.requestFullscreen();
      } else if (videoRef.current.mozRequestFullScreen) {
        videoRef.current.mozRequestFullScreen();
      } else if (videoRef.current.webkitRequestFullscreen) {
        videoRef.current.webkitRequestFullscreen();
      } else if (videoRef.current.msRequestFullscreen) {
        videoRef.current.msRequestFullscreen();
      } else if (videoRef.current.webkitEnterFullScreen) {
        videoRef.current.webkitEnterFullScreen();
      }
  }

在 iOS 中,只要视频进入全屏状态,无论 video 有没有设置 controls ,系统的全屏控制器都会接管 video,无法应用自定义的控制器。因此视频进入全屏状态后,无论在 iOS 还是 Android 均使用原生的全屏控制器。

另外,在 Android 部分机型中,例如 OPPO 系列的手机,即使是处于 inline 的播放状态,也依然会使用系统自带的播放器,目前这个没有办法可以解决。

2. 退出全屏

组件还需要监听用户退出全屏,监听 fullscreenchange 再使用 document.fullscreenElement 判断是否有元素处于全屏状态。

在 iOS 中无法监听到 fullscreenchange,搭配兼容性写法也依然无法监听。最后使用一个定时器去轮训检查。且在 iOS 中 document.fullscrrenElement 也始终返回 undefined,查阅了苹果的开发者文档,需要使用 webkitDisplayingFullscreen 才能检查到元素是否处于全屏状态。

/**
   * 监听全屏
   */
  useEffect(() => {
    const handleFullScreenChange = () => {
      if(document.fullscreenElement === null) {
        dispatch({ type: VideoTypes.QUITFULLSCREEN });
      }
    };
    const handleIosInterval = () => {
      if(videoRef.current) {
        !videoRef.current.webkitDisplayingFullscreen && dispatch({ type: VideoTypes.QUITFULLSCREEN });
      }
    };

    // ios 无法监听到 fullscreenchange 事件,设置一个定时器检查
    const iosIntervalTimer = setInterval(handleIosInterval, 500);

    document.addEventListener('fullscreenchange', handleFullScreenChange, false);
    document.addEventListener('mozfullscreenchange', handleFullScreenChange, false);
    document.addEventListener('webkitfullscreenchange', handleFullScreenChange, false);
    document.addEventListener('msfullscreenchange', handleFullScreenChange, false);
    return () => {
      document.removeEventListener('fullscreenchange', handleFullScreenChange);
      document.removeEventListener('mozFullScreen', handleFullScreenChange);
      document.removeEventListener('webkitfullscreenchange', handleFullScreenChange);
      clearInterval(iosIntervalTimer);
    };
  }, []);

兼容性问题处理

在设计自定义视频播放组件时,原本设计的是通过控制 State,监听 State 的变化,然后修改 video 的状态。

在 Chrome 中表现良好,但是在 ios Safari 中无效,提示错误为:

原因是修改 State 是一个异步非实时的操作,而 Safari 不允许非用户触发的播放、暂停等行为。

最后修改为直接操作 Video 的行为,然后监听 Video 行为变化从而改变 State 状态。

总结

观察了其它移动端自定义控制器,发现多是使用 div 的实现方式。目前 input 的实现方式符合实际需求,或许是有什么细节的地方需要使用 div 去实现。

做移动端视频组件有很多兼容性问题需要注意,在 iOS 和 Android 端均有很多不一致的表现,并且很多 Android 手机厂商底层就不接受自定义视频控制器。