基于 React video 视频打点

2,465 阅读4分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战

HTML <video> 元素用于在HTML或者XHTML文档中嵌入媒体播放器,用于支持文档内的视频播放。

  • currentTime:读取 CurentTime 返回一个双精度浮点值,指示以秒为单位的媒体的当前播放位置。如果video尚未开始播放,则会在开始播放后返回偏移量。通过 CurentTime 将当前播放位置设置为给定时间,会在加载媒体时将媒体查找到该位置(从指定的位置开始播放)。

  • duration(只读):一个双精度浮点值,它指示媒体的持续时间(总长度),以秒为单位,在媒体的时间线上。

  • volume:音量

  • playbackrate:播放速度

  • play():播放视频

  • pause():暂停视频

更多属性:developer.mozilla.org/zh-CN/docs/…

实现思路:

  1. 播放 / 暂停
// 获取 dom 节点
const player = document.querySelector('.player')
const video = player.querySelector('.viewer')
const toggle = player.querySelector('.toggle')

当 video 标签和 控制按钮被点击时,切换播放状态

video.addEventListener('click', togglePlay)
toggle.addEventListener('click', togglePlay)

判断 video 的状态,如果当前是暂停,则调用 video 的 play。

// 切换播放状态
function togglePlay() {
  const method = video.paused ? 'play' : 'pause'
  video[method]()
}
  1. 切换播放状态图标,对 video 进行播放状态的监听
video.addEventListener('play', updatedToggle)
video.addEventListener('pause', updatedToggle)
// 切换播放状态图标
function updatedToggle() {
  const icon = video.paused ? '►' : '❚ ❚'
  toggle.textContent = icon
}
  1. 实现前进后退功能

获取页面上的两个前进后退按钮,为其添加 click 事件。

const skipButtons = player.querySelectorAll('[data-skip]')

skipButtons.forEach(skipButton => skipButton.addEventListener('click', skip))

获取 skip 按钮上的自定义属性,改变 video 的 currentTime

function skip() {
  video.currentTime += parseFloat(this.dataset.skip)
}
  1. 改变播放速度和音量

获取页面上控制音量和播放速度的两个按钮,为其添加 change 事件和 mousemove 事件,实现点击或拖动改变的效果。

const ranges = player.querySelectorAll('.player__slider')

ranges.forEach(range => range.addEventListener('change', handleRangeUpdate))
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate))

// 改变播放速度和音量
function handleRangeUpdate() {
  video[this.name] = this.value
}
  1. 实现播放进度条的颜色移动
const progressBar = player.querySelector('.progress__filled')

监听 video 标签的 timeupdate

video.addEventListener('timeupdate', handleProgress)

用当前播放的时间 video.currentTime 除以视频的总时长 video.duration

// 改变进度条
function handleProgress() {
  const percent = (video.currentTime / video.duration) * 100;
  progressBar.style.flexBasis = `${percent}%`;
}
  1. 拖动进度条,视频切换到对应的播放时间
const progress = player.querySelector('.progress')

需要一个变量,控制切换是否生效

let mousedown = false

监听 progress 的 clickmousemovemousedownmouseup 四个事件

let mousedown = false
progress.addEventListener('click', scrub)
progress.addEventListener('mousemove', (e) => mousedown && scrub(e))
progress.addEventListener('mousedown', () => mousedown = true)
progress.addEventListener('mouseup', () => mousedown = false)

image.png

// 点击切换播放进度
function scrub(e) {
  const scrubTime = (e.offsetX / progress.offsetWidth) * video.duration
  video.currentTime = scrubTime
}
基本思路:

隐藏原生 video 控件,实现原生控件的功能。

<Controls
  progressEl={progressEl}
  volumeEl={volumeEl}
  controls={controls}
  isPlaying={isPlaying}
  volume={volume}
  currentTime={currentVideoTime}
  duration={videoDuration}
  muted={muted}
  markers={markers}
  onPlayClick={play}
  onPauseClick={pause}
  onProgressClick={handleProgressClick}
  onVolumeClick={handleVolumeClick}
  onMuteClick={handleMuteClick}
  onFullScreenClick={handleFullScreenClick}
  onMarkerClick={handleMarkerClick}
/>

传入的 controls 的是控件的数量,通过该数组来控制显示。

播放 / 暂停

{controls.includes('play') ? (
  <button
    className={isPlaying ? 'pause' : 'play'}
    onClick={isPlaying ? onPauseClick : onPlayClick}
  >
    {isPlaying ? 'Pause' : 'Play'}
  </button>
) : null}

显示播放时间

对传入的时间做一些格式化操作

const getTimeCode = (secs: number): string => {
  let secondsNumber = secs ? parseInt(String(secs), 10) : 0;
  let hours = Math.floor(secondsNumber / 3600);
  let minutes = Math.floor((secondsNumber - hours * 3600) / 60);
  let seconds = secondsNumber - hours * 3600 - minutes * 60;
  let hoursStr: string = String(hours);
  let minutesStr: string = String(minutes);
  let secondsStr: string = String(seconds);


  if (hours < 10) {
    hoursStr = '0' + hours;
  }
  if (minutes < 10) {
    minutesStr = '0' + minutes;
  }
  if (seconds < 10) {
    secondsStr = '0' + seconds;
  }

  return `${
    hoursStr !== '00' ? hoursStr + ':' : ''
  }${minutesStr}:${secondsStr}`;
};

const durationTimeCode = getTimeCode(Math.ceil(duration));
const currentTimeCode =
  currentTime !== duration ? getTimeCode(currentTime) : durationTimeCode;
{controls.includes('time') ? (
  <div className="time">
    {currentTimeCode}/{durationTimeCode}
  </div>
) : null}

进度条组件,通过 ref 向父组件暴露子组件的引用。Marker 是标记点组件,接收传入的数组,向外暴露 onMarkerClick 事件。

{controls.includes('progress') ? (
  <div className="progress-wrap">
    <progress ref={progressEl} max="100" onClick={onProgressClick}>
      0% played
    </progress>
    {markers &&
      markers.map((marker, index) => {
        return (
          <Marker
            key={index}
            marker={marker}
            duration={duration}
            onMarkerClick={onMarkerClick}
          />
        );
      })}
  </div>
) : null}

Marker 组件本质上是有 i 标签组成的一个个小点,通过传入的 duration 和 time 计算出 position 的值。

if (duration) {
  const percent = time <= duration ? time / duration : 1;
  return `calc(${percent * 100}% - 2px)`;
}
<i
  id={id}
  className="react-video-marker"
  title={title}
  style={{
    background: color,
    left: getPosition()
  }}
  onClick={() => {
    onMarkerClick(marker);
  }}
/>

控制音量组件

{controls.includes('volume') ? (
  <div className="volume-wrap">
    <progress
      ref={volumeEl}
      max="100"
      value={volume * 100}
      onClick={onVolumeClick}
    >
      {volume * 100}% volume
    </progress>
    <button
      className={muted ? 'no-volume' : 'volume'}
      onClick={onMuteClick}
    >
      Volume
    </button>
  </div>
) : null}

全屏组件

{controls.includes('full-screen') ? (
  <button className="full-screen" onClick={onFullScreenClick}>
    FullScreen
  </button>
) : null}

进度条事件如下:

  • play(点击播放)
  • pause(点击暂停)
  • handleProgressClick(进度条点击事件)
  • handleVolumeClick(改变音量事件)
  • handleMuteClick(点击静音事件)
  • handleFullScreenClick(点击全屏事件)
  • handleMarkerClick(标记点事件)

Controls 组件通过 isPlaying 来判断当前的播放状态。当触发 play 和 pause 事件时,应当改变 isPlaying 的值。

play 和 pause

// play
const play = () => {
  if (!videoRef.current) return;
  const playPromise = videoRef.current.play();
  playPromise &&
    playPromise
      .then(() => { })
      .catch(e => {
        console.log('Operation is too fast, audio play fails');
      });
  setIsPlaying(true);
};


// pause
const pause = () => {
  if (!videoRef.current) return;
  videoRef.current.pause();
  setIsPlaying(false);
};

handleProgressClick

点击进度条,期望就是 video 的时间可以到达点击的位置。

  • e.clientX:鼠标相对于浏览器窗口可视区域的X,Y坐标,可视区域不包括工具栏和滚动条。
  • element.getBoundingClientRect():返回的结果是包含完整元素的最小矩形,并且拥有left, top, right, bottom, x, y, width, 和 height这几个以像素为单位的只读属性用于描述整个边框。除了widthheight 以外的属性是相对于视图窗口的左上角来计算的。

image.png

  • 获取当前页面滚动条横坐标的位置:document.body.scrollLeft

image.png

const handleProgressClick = (e: Event) => {
  const x =
    e['clientX'] -
    progressEl.current!.getBoundingClientRect().left +
    document.body.scrollLeft;
  const percentage =
    (x * progressEl.current!.max) / progressEl.current!.offsetWidth;
  videoRef.current!.currentTime =
    (percentage / 100) * videoRef.current!.duration;
};   (percentage / 100) * videoRef.current!.duration; };

handleVolumeClick

音量的改变与进度条同理。当改变的音量的时候,静音状态应该设置为 false。

const handleVolumeClick = e => {
  const y =
    volumeEl.current!.offsetWidth -
    (e.clientY -
      volumeEl.current!.getBoundingClientRect().top +
      document.body.scrollTop);
  const percentage =
    (y * volumeEl.current!.max) / volumeEl.current!.offsetWidth;
  videoRef.current!.muted = false;
  const volume = videoRef.current.volume;
  onVolumechange?.(volume);
  setVolume(percentage / 100);
};

handleMuteClick,设置 muted 的状态

const handleMuteClick = () => {
  if (muted) {
    videoRef.current!.muted = false;
    setVideoMuted(false);
  } else {
    videoRef.current!.muted = true;
    setVolume(0);
    setVideoMuted(true);
  }
};

handleFullScreenClick

通过 isFullScreen 来控制是否全屏。点击全屏的时候,调用当前元素上的 requestFullscreen 方法,退出全屏时,调用 document 的 exitFullscreen 方法。

const handleFullScreenClick = () => {
  const videoWrap = document.getElementsByClassName('react-video-wrap')[0];
  if (isFullScreen) {
    document.body.classList.remove('react-video-full-screen');
    if (document['exitFullscreen']) {
      document['exitFullscreen']();
    } else if (document['mozCancelFullScreen']) {
      document['mozCancelFullScreen']();
    } else if (document['webkitExitFullscreen']) {
      document['webkitExitFullscreen']();
    } else if (document['msExitFullscreen']) {
      document['msExitFullscreen']();
    }
  } else {
    document.body.classList.add('react-video-full-screen');
    if (videoWrap['requestFullscreen']) {
      videoWrap['requestFullscreen']();
    } else if (videoWrap['mozRequestFullScreen']) {
      videoWrap['mozRequestFullScreen']();
    } else if (videoWrap['webkitRequestFullscreen']) {
      videoWrap['webkitRequestFullscreen']();
    } else if (videoWrap['msRequestFullscreen']) {
      videoWrap['msRequestFullscreen']();
    }
  }
  setIsFullScreen(!isFullScreen);
};

handleMarkerClick

点击标记点,进度条到达标记点的时间

const handleMarkerClick = (marker: object) => {
  videoRef.current!.currentTime = marker['time'];
  onMarkerClick(marker);
};