video.js -HTML5 视频播放器

4,415 阅读9分钟

最近项目中,需要搭建一个视频播放的页面;所以就去github上搜了一下,有没有比较好的video第三方库;所以就选了排在最前面的它⬇️:

image.png

这里呢,就浅浅记录一下我的使用历程:

简单搭建video.js

  1. 第一步呢,肯定是先在项目中引入啦,具体的引入方式呢肯定是很多种,我这里就不一一赘述了,自行搜索,我就简单记一下我的喽!

再稍稍补充下,我这里的项目使用的是react;

// 如果项目中使用了typescript,就安装一下@types/video.js
npm i --save video.js @types/video.js
  1. 在我们的页面中引入video.js

这里是官网给出的demo:video.js和ReactJS集成

这里提一嘴:截止到现在,video的最先版本是8.0.4,但是呢,引入最新版本的话,页面一直报错,所以我这里用的是video.js 7.13.3的版本,如果跟我一样报错的话,可以用版本7;

index.tsx:

import { useEffect, useRef } from 'react'
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'

import './index.less'

const videoCom = () => {
  const videoRef = useRef<any>(null)
  const playerRef = useRef<any>(null)

  const videoUrl = 'http://vjs.zencdn.net/v/oceans.mp4'

  const options: VideoJsPlayerOptions = {
    controls: true,
    preload: 'auto',
    language: 'zh-CN',
    width: 900,
    height: 500
  }

  useEffect(() => {
    if (!playerRef?.current && videoRef.current) {
      // 初始化video
      const player = (playerRef.current = videojs(
        videoRef.current,
        options,
        () => {
          console.log('video实例准备好了')
          player.src(videoUrl)
          player.currentTime(1)
        }
      ))
    }
  }, [videoRef])

  useEffect(() => {
    const player: VideoJsPlayer = playerRef.current
    return () => {
      // 组件销毁的时候,销毁视频播放器的实例
      if (player && !player.isDisposed()) {
        player.dispose()
        playerRef.current = null
      }
    }
  }, [playerRef])

  return (
    <div className="video-wrapper">
      <video ref={videoRef} className="video-js vjs-big-play-centered">
        <span>视频走丢了,请稍后再试</span>
      </video>
    </div>
  )
}

export default videoCom

页面效果:

image.png

我们的video.js就引入成功了,但是,项目需求从来都是没有这么简单的!!

需求背景

简单来说,就是页面上有个视频列表,默认呢从第一个视频开始播放;点击对应的视频列表项,就播放对应的视频;如果正在播放的视频结束了,自动播放下一个视频;

视频播放器的样子肯定不能是长这个样子的嘛!

难点1: 自定义播放控制条

上面的例子中,关于controlBar的配置只有这一个属性controls: true,控制controlBar是否展示;那如果我们控制controlBar上展示的具体功能或者顺序怎么办呢?

videojs的配置选项中可以通过controlBar属性进行配置,可以配置的功能组件如下:(截图来源于官网)

image.png

下面我们来自定义一下;

const options: VideoJsPlayerOptions = {
    controls: true,
    preload: 'auto',
    language: 'zh-CN',
    width: 900,
    height: 500,
    controlBar: {
      children: [
        'PlayToggle', // 播放
        'CurrentTimeDisplay', // 当前时间
        'ProgressControl', // 进度条
        'DurationDisplay', // 时长
        'VolumePanel', // 音量
        'FullscreenToggle' // 全屏
      ]
    }
  }

但是出来的效果好像跟我们预想的不太一样:

image.png

当前时间和时长并没有显示出来;

可以看一下上面截图的组件结构:CurrentTimeDisplay (hidden by default);括号里面有写默认是隐藏的,虽然我也不知道,为啥,我们配置了,他还隐藏,但是还是可以让他出现在控制条上的;

打开我们的开发者工具,找到控制条的位置;

image.png

手动改一下他的样式就OK了;

index.less:

.video-wrapper {
  width: 80vw;
  height: 80vh;
  display: flex;
  justify-content: center;
  align-items: center;
  .video-js {
    .vjs-current-time,
    .vjs-duration {
      display: block;
    }
  }
}

页面效果:

image.png

可以看到controlBar上面显示的组件跟我们配置的顺序和数量都是一致的;如果你只是控制controlBar上展示组件顺序和具体哪个组件,这样基本就可以满足了,改某一个组件的样式,可以通过开发者工具进行样式的覆盖,这样基本就可以满足了;

难点2:自定义controlBar上的小组件

接下来就是,我们在视频网站追剧的时候,他们的控制条上有一个按钮方便我们播放下一集,这时候控制条上就没有满足我们需要的小组件了,所以呢,我们就需要自力更生了;

在之前的版本中,video.js 提供了extend()函数,但是在后面的版本中这个函数被移除了,可以使用es6的继承关系;

官网在这一块有介绍:组件

大体的实现逻辑:

  • 首先我们需要继承videojs封装的component类,来封装符合我们需求的小组件;
  • 封装好之后,我们需要在videojs中注册组件,注册完之后,就可以用我们注册的组件名直接使用了;

自定义下一节按钮:nextBtn.tsx

import videoJs, { VideoJsPlayer } from 'video.js'
// 这里我们直接获取的是Button组件,因为我们的需求其实就是控制条的一个button
const VideoButton = videoJs.getComponent('Button')
class NextBtn extends VideoButton {
  constructor(player: VideoJsPlayer, options: videoJs.ComponentOptions) {
    super(player, options)
    // 为我们的小组件加上类名
    // 我之前想这么一下都加上,结果识别不出来,通过开发者工具可以看到button都会有这两个类:vjs-control vjs-button
    // this.addClass('vjs-control vjs-button vjs-next-btn ')
    this.addClass('vjs-control')
    this.addClass('vjs-button')
    // videojs的类名前缀都是以vjs开头的,我们就入乡随俗一下
    this.addClass('vjs-next-btn')
  }
  // 自定义按钮的dom结构,createEl会在使用组件的时候自动调用
  createEl(tag: string, props?: any, attributes?: any): HTMLButtonElement {
    const nextBtnEl: HTMLButtonElement = document.createElement('button')
    const icon = document.createElement('i')
    icon.className = 'iconbtn iconfont icon-xiayige'
    // 将按钮图标放到按钮中
    videoJs.dom.appendContent(nextBtnEl, icon)
    return nextBtnEl
  }
  handleClick(event: videoJs.EventTarget.Event): void {
    // 定义按钮的点击时间
    console.log('你点击了我哦')
  }
}
export default NextBtn

注册并使用组件index.tsx:

import { useEffect, useRef } from 'react'
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import NextBtn from './nextBtn'

import './index.less'

const videoCom = () => {
  const videoRef = useRef<any>(null)
  const playerRef = useRef<any>(null)

  const videoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'

  const options: VideoJsPlayerOptions = {
    controls: true,
    preload: 'auto',
    language: 'zh-CN',
    width: 900,
    height: 500,
    controlBar: {
      children: [
        'PlayToggle', // 播放
        'NextBtn', // 下一个
        'CurrentTimeDisplay', // 当前时间
        'ProgressControl', // 进度条
        'DurationDisplay', // 时长
        'VolumePanel', // 音量
        'FullscreenToggle' // 全屏
      ]
    }
  }

  const onPlayerReady = () => {
    const player: VideoJsPlayer = playerRef.current
    console.log('video实例准备好了')
    player.src(videoUrl)
    player.currentTime(1)
  }

  useEffect(() => {
    if (!playerRef?.current && videoRef.current) {
      // 注册组件  一定要在使用之前注册哦
      videojs.registerComponent('NextBtn', NextBtn)
      // 初始化video
      const player = (playerRef.current = videojs(
        videoRef.current,
        options,
        () => {
          onPlayerReady()
        }
      ))
    }
  }, [videoRef])

  useEffect(() => {
    const player: VideoJsPlayer = playerRef.current
    return () => {
      // 组件销毁的时候,销毁视频播放器的实例
      if (player && !player.isDisposed()) {
        player.dispose()
        playerRef.current = null
      }
    }
  }, [playerRef])

  return (
    <div className="video-wrapper">
      <video ref={videoRef} className="video-js vjs-big-play-centered">
        <span>视频走丢了,请稍后再试</span>
      </video>
    </div>
  )
}

export default videoCom

页面效果:

image.png

点击看一下控制台:

image.png

可以看到确实走了我们自定义的click方法;

但是,我们是需要得到当前的视频列表,并且从视频列表中获取下一节视频的,所以要监听按钮的点击事件;

const onPlayerReady = () => {
    const player: VideoJsPlayer = playerRef.current
    console.log('video实例准备好了')
    player.src(videoUrl)
    player.currentTime(1)
    // 获取自定义组件
    const nextBtnCom = player.getChild('controlBar')?.getChild('NextBtn')
    // 监听自定义组件的点击事件
    nextBtnCom?.on('click', () => {
      const index = videoList?.findIndex((item) => item.id === currentId)
      if (index !== -1 && index !== videoList.length - 1) {
        const vUrl = videoList[index + 1].videoUrl
        setCurrentId(videoList[index + 1].id)
        player.src(vUrl)
        player.load()
        player.play()
      }
    })
  }

这样就可以了,但是我要提一下,就是我这里用的是静态数据,所以视频列表是可以取得到的;但是在项目开发中,我们的视频列表肯定是动态获取的,并且记录当前播放的视频也是每次动态变化的,一般都会useState,放在状态里面;

当时我在这卡了很长时间😭,把这个问题的解决方案放在下一个难点上了;

难点3: 自定义组件的事件获取不到主逻辑的最新状态值

这里我怀疑是因为组件监听事件时存在闭包影响的,因为在监听事件中用到的状态值一直都是状态的初始值,不是状态的最新值;

所以我采用的解决方案就是,巧用一下useRef;将在监听事件使用到的状态值封装到ref对象中;

下面是所有文件的代码:

父组件文件:index.tsx:

import { useEffect, useRef, useState } from 'react'
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import VideoCom from './components/videoCom'
import './index.less'

const Home = () => {
  const videoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'
  const [currentId, setCurrentId] = useState(1)
  const vNextRef = useRef<any>(null)

  const videoList = [
    { id: 1, videoUrl },
    { id: 2, videoUrl },
    { id: 3, videoUrl }
  ]
  const options: VideoJsPlayerOptions = {
    controls: true,
    preload: 'auto',
    language: 'zh-CN',
    width: 900,
    height: 500,
    controlBar: {
      children: [
        'PlayToggle', // 播放
        'NextBtn', // 下一个
        'CurrentTimeDisplay', // 当前时间
        'ProgressControl', // 进度条
        'DurationDisplay', // 时长
        'VolumePanel', // 音量
        'FullscreenToggle' // 全屏
      ]
    }
  }

  const onPlayerReady = (player: VideoJsPlayer) => {
    console.log('video实例准备好了')
    player.src(videoUrl)
    player.currentTime(1)
    // 获取自定义组件
    const nextBtnCom = player.getChild('controlBar')?.getChild('NextBtn')
    // 监听自定义组件的点击事件
    nextBtnCom?.on('click', () => {
      const { videoId } = vNextRef.current
      const index = videoList?.findIndex((item) => item.id === videoId)
      if (index !== -1 && index !== videoList.length - 1) {
        const vUrl = videoList[index + 1].videoUrl
        setCurrentId(videoList[index + 1].id)
        player.src(vUrl)
        player.load()
        player.play()
      }
    })
  }

  useEffect(() => {
    // 这里需要依赖的状态值都放到里面即可
    vNextRef.current = { videoId: currentId }
  }, [currentId])
  return (
    <div>
      <VideoCom videoOptions={options} onReady={onPlayerReady} />
    </div>
  )
}

export default Home

封装的video组件:

import { useEffect, useRef } from 'react'
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import NextBtn from './nextBtn'

import './index.less'

interface videoComProps {
  videoOptions: VideoJsPlayerOptions
  onReady: (player: VideoJsPlayer) => void
}

const videoCom = (props: videoComProps) => {
  const { videoOptions, onReady } = props
  const videoRef = useRef<any>(null)
  const playerRef = useRef<any>(null)

  useEffect(() => {
    if (!playerRef?.current && videoRef.current) {
      // 注册组件  一定要在使用之前注册哦
      videojs.registerComponent('NextBtn', NextBtn)
      // 初始化video
      const player = (playerRef.current = videojs(
        videoRef.current,
        videoOptions,
        () => {
          onReady(player)
        }
      ))
    }
  }, [videoRef])

  useEffect(() => {
    const player: VideoJsPlayer = playerRef.current
    return () => {
      // 组件销毁的时候,销毁视频播放器的实例
      if (player && !player.isDisposed()) {
        player.dispose()
        playerRef.current = null
      }
    }
  }, [playerRef])

  return (
    <div className="video-wrapper">
      <video ref={videoRef} className="video-js vjs-big-play-centered">
        <span>视频走丢了,请稍后再试</span>
      </video>
    </div>
  )
}

export default videoCom

controlBar上添加倍速组件

现在呢,想在video标签的控制栏添加上倍速组件,视频初始化的时候默认为一倍速,然后controlBar上显示"正常",当选了其他倍速,则显示当前倍速;

  1. video.js上倍速组件为:PlaybackRateMenuButton;第一步我们现在controlBar上加上倍速组件;

image.png

  1. 然后发现视频播放器上并没有倍速组件;除了需要在controlBar上添加该组件,我们还需要告诉video视频播放器我们需要实现的倍速数组; 即在与controlBar同一层的对象上配置倍速数组:playbackRates: [0.5, 0.75, 1, 1.5, 2],然后就可以看到我们的播放器上显示出了倍速功能了;

    在这里补充一点,如果想要倍速的数组换个顺序,修改playbackRates即可,比如现在页面展示从上到下依次递减,将playbackRates: [2, 1.5, 1, 0.75, 0.5],就变成从上到下依次递增了!

    还有之前没有发现,当你在controlbar上点击倍速按钮时,当前倍速依次切换!!可以试一下😯

image.png

  1. 到了这一步其实倍速功能就已经实现了,但是前面我们又说想让一倍速的时候倍速组件在控制条上显示为正常;首先就需要获取到倍速组件,还得要监听到倍速改变的事件; player.playbackRate()获取/设置倍速;
// 监听倍速改变
    player.on('ratechange', () => {
      const playRate = player.playbackRate()
      console.log('当前播放速度:', playRate)
      const playbackBtn = player.getDescendant([
        'controlBar',
        'PlaybackRateMenuButton'
      ])
      const btnTextEl = playbackBtn?.el().querySelector('.vjs-icon-placeholder')

      if (btnTextEl) {
        btnTextEl.innerHTML = playRate === 1 ? '正常' : `${playRate}X`
      }
    })

image.png

  1. 但是可以看到,我们设置的按钮文字和本身的发生了重叠,通过开发者工具检察元素发现在这个位置展示的是类名vjs-playback-rate-value的元素,因为暂时摸不清楚这个元素会有啥用,所以通过css先将它隐藏掉;

  2. 优化一下代码,最终的展示效果如果:

未完待续...

代码展示

  1. 封装的video组件:index.tsx:
import { useEffect, useRef } from 'react'
import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import NextBtn from './nextBtn'

import './index.less'

interface videoComProps {
  videoOptions: VideoJsPlayerOptions
  onReady: (player: VideoJsPlayer) => void
}

const videoCom = (props: videoComProps) => {
  const { videoOptions, onReady } = props
  const videoRef = useRef<any>(null)
  const playerRef = useRef<any>(null)

  useEffect(() => {
    if (!playerRef?.current && videoRef.current) {
      // 注册组件  一定要在使用之前注册哦
      videojs.registerComponent('NextBtn', NextBtn)
      // 初始化video
      const player = (playerRef.current = videojs(
        videoRef.current,
        videoOptions,
        () => {
          onReady(player)
        }
      ))
    }
  }, [videoRef])

  useEffect(() => {
    const player: VideoJsPlayer = playerRef.current
    return () => {
      // 组件销毁的时候,销毁视频播放器的实例
      if (player && !player.isDisposed()) {
        player.dispose()
        playerRef.current = null
      }
    }
  }, [playerRef])

  return (
    <div className="video-wrapper">
      <video ref={videoRef} className="video-js vjs-big-play-centered">
        <span>视频走丢了,请稍后再试</span>
      </video>
    </div>
  )
}

export default videoCom

  1. 封装的video组件:index.less:
.video-wrapper {
  width: 80vw;
  height: 80vh;
  display: flex;
  justify-content: center;
  align-items: center;
  .video-js {
    .vjs-current-time,
    .vjs-duration {
      display: block;
    }
    .vjs-next-btn {
      width: 32px;
      height: 32px;
      .iconbtn {
        color: #fff;
      }
    }
    .vjs-playback-rate-value {
      display: none;
    }
  }
}

  1. 父组件:index.tsx:
import { useEffect, useRef, useState } from 'react'
import { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'
import VideoCom from './components/videoCom'
import './index.less'

const Home = () => {
  const videoUrl = 'https://vjs.zencdn.net/v/oceans.mp4'
  const [currentId, setCurrentId] = useState(1)
  const vNextRef = useRef<any>(null)

  const videoList = [
    { id: 1, videoUrl },
    { id: 2, videoUrl },
    { id: 3, videoUrl }
  ]
  const options: VideoJsPlayerOptions = {
    controls: true,
    preload: 'auto',
    language: 'zh-CN',
    width: 900,
    height: 500,
    playbackRates: [0.5, 0.75, 1, 1.5, 2], // 倍速数组
    controlBar: {
      children: [
        'PlayToggle', // 播放
        'NextBtn', // 下一个
        'CurrentTimeDisplay', // 当前时间
        'ProgressControl', // 进度条
        'DurationDisplay', // 时长
        'VolumePanel', // 音量
        'FullscreenToggle', // 全屏
        'PlaybackRateMenuButton' // 倍速
      ]
    }
  }

  const onPlayerReady = (player: VideoJsPlayer) => {
    console.log('video实例准备好了')
    player.src(videoUrl)
    player.currentTime(1)
    // 获取自定义组件
    const nextBtnCom = player.getChild('controlBar')?.getChild('NextBtn')
    // 初始化倍速
    player.playbackRate(1)
    const playbackBtn = player.getDescendant([
      'controlBar',
      'PlaybackRateMenuButton'
    ])
    const btnTextEl = playbackBtn?.el().querySelector('.vjs-icon-placeholder')
    if (btnTextEl) {
      btnTextEl.innerHTML = '正常'
    }
    // 监听自定义组件的点击事件
    nextBtnCom?.on('click', () => {
      const { videoId } = vNextRef.current
      const index = videoList?.findIndex((item) => item.id === videoId)
      if (index !== -1 && index !== videoList.length - 1) {
        const vUrl = videoList[index + 1].videoUrl
        setCurrentId(videoList[index + 1].id)
        player.src(vUrl)
        player.load()
        player.play()
      }
    })
    // 监听倍速改变
    player.on('ratechange', () => {
      const playRate = player.playbackRate()
      if (btnTextEl) {
        btnTextEl.innerHTML = playRate === 1 ? '正常' : `${playRate}X`
      }
    })
  }

  useEffect(() => {
    // 这里需要依赖的状态值都放到里面即可
    vNextRef.current = { videoId: currentId }
  }, [currentId])
  return (
    <div>
      <VideoCom videoOptions={options} onReady={onPlayerReady} />
    </div>
  )
}

export default Home