从零开发弹幕视频播放器

2022-10-09 10:14:02 浏览数 (2)

这是一个系列文章。本文章将介绍,如何制作一个简单的视频播放器。用少量关键的代码来实现视频播放器核心功能。

点击这个链接,在线预览最终视频播放器 https://nplayer.js.org/

介绍

以前想在网站放播放视频,就需要安装 flash 插件,但是 flash 占用系统资源高。而且它是 Adobe 一项封闭的商业应用,内置 flash 有可能引入相关的安全漏洞,苹果更是大力反对 falsh

现在视频网站几乎都用 html 5 播放视频,它占用资源小更省电、省流量,是一项完全免费并且开放的新标准。无需安装任何插件直接使用 video 标签就行,而且它的兼容性也非常好,所有主流浏览器都支持。

video 标签

代码语言:javascript复制
<video controls poster="/poster.jpg">
    <source
        src="https://interactive-examples.mdn.mozilla.net/media/examples/friday.mp4"
        type="video/mp4"
    >
    <source src="/other.webm" type="video/webm">
    <track 
        src="https://interactive-examples.mdn.mozilla.net/media/examples/friday.vtt"
        kind="captions" 
        srclang="en"
        label="显示在这"
        default
    >
    浏览器不支持 video 标签</video>

video

video 除了上面展示的两个还有很多属性。

代码语言:javascript复制
使用浏览器默认的视频控制器

payload 3 个值如下:

代码语言:javascript复制
    none      不进行预加载
    metadata  预加载视频元数据
    auto      预加载整个视频

source

上面 video 标签下的 source 是用来指定视频的地址,如果浏览器不支持这个格式它就会查看下一个 source,也可以简单的使用 videosrc 属性。

<video src="http://video.mp4">当浏览器不支持 video 会显示</video>

使用 Media Fragments API 可以为视频添加开始和结束时间。

<source src="video.mp4#t=2,5" type="video/mp4">

视频将在 2 秒播放,5 秒结束。它的格式为 #t=[start_time][,end_time],需要确保服务器支持 Range Requests

track

track 元素使用 WebVTT 格式来显示字幕。一个媒体元素的任意两个 track 子元素不能有相同的 kind, srclang, 和 label属性。

  • default 指定 track 默认启用
  • label 给浏览器使用的 text track 的标题,这种标题是用户可读的

src 字幕地址

srclang 文本数据的语言,中文是 zh,如果 kind 属性被设为 subtitles, 那么必须定义此属性。

kind 定义 text track 应该如何使用。如果省略,默认就是 subtitles,它有以下属性值:

代码语言:javascript复制
  subtitles     字幕给观影者看不懂的内容提供解释
  captions      隐藏式字幕提供了音频的转录甚至是翻译
  descriptions  视频内容的文本描述
  chapters      章节标题用于用户浏览媒体资源的时候
  metadata      脚本使用的 track 用户不可见

JS 中的 video

js 中,通过 document.querySelector('video') 等方式获取 video 元素,就可以操作视频行为了,下面介绍 video 常用的事件、属性和方法。

事件

加载相关

事件

描述

loadstart

在媒体开始加载时触发

loadedmetadata

媒体的元数据已经加载完毕,比如视频的宽高,长度等信息

loadeddata

媒体的第一帧已经加载完毕

canplay

在媒体数据已经有足够的数据可供播放时触发

canplaythrough

媒体可以在保持当前的下载速度的情况下不被中断地播放完毕时触发

progress

告知媒体相关部分的下载进度时周期性地触发

durationchange

元信息已载入或已改变,表明媒体长度发生改变。比如媒体已被加载足够的长度从而得知总长度时

从而得知总长度时

错误或特殊情况

事件

描述

error

在发生错误时触发

stalled

在尝试获取媒体数据,但数据不可用时触发

suspend

媒体资源加载终止时触发,这可能是因为下载已完成或因为其他原因

播放时

代码语言:javascript复制
在媒体开始播放时触发可能是初次播放、暂停后恢复或结束后重新开始

属性

通过 video 元素,我们可以获取上面提到的属性,也可以改变它来操作视频,比如设置 video.muted=true 设置静音。

代码语言:javascript复制
返回视频的宽高(width/height
 属性可能被 css 影响)

video 元素上还有 readyState 属性,表示视频当前的状态,它的值 04 的数字,video 上有相关描述的常量属性。

video 常量属性

描述

HAVE_NOTHING

0 没有信息,视频未准备好

HAVE_METADATA

1 视频元数据已准备

HAVE_CURRENT_DATA

2 视频当前位置数据可用,但是下一帧数据没有

HAVE_FUTURE_DATA

3 当前和至少下一帧数据可用

HAVE_ENOUGH_DATA

4 有足够的数据可以播放

还有一个 networkState 属性表示当前网络状况。

video 常量属性

描述

NETWORK_EMPTY

0 还没初始化

NETWORK_IDLE

1 处于活跃状态,但还没使用网络

NETWORK_LOADING

2 浏览器在下载数据

NETWORK_NO_SOURCE

3 没有找到数据源

方法

代码语言:javascript复制
在没有开始播放的情况下加载或重新加载视频来源,比如修改 src

其中 canPlayType 方法参数接收 mime-type 字符串或在加上可选的编解码器,返回如下 3 个值。

canPlayType 返回值

描述

''(空字符串)

容器和(或编解码器)不受支持

maybe

容器和编解码器可能受支持,但是浏览器需要下载部分视频才能确认

probably

格式似乎受支持

它的参数可能是:

  • video/ogg
  • video/mp4
  • video/webm
  • video/ogg; codecs="theora, vorbis"
  • video/mp4; codecs="avc1.4D401E, mp4a.40.2"
  • video/webm; codecs="vp8.0, vorbis"

视频播放器

代码语言:javascript复制
<div class="player player-loading">
    <video
      playsinline
      x5-playsinline
      src="https://interactive-examples.mdn.mozilla.net/media/examples/flower.webm"
    />
    <div class="loading"></div>
    <div class="controls">
      <div class="bar">
        <div class="bar_buffered"></div>
        <div class="bar_played"></div>
      </div>
      <div class="control_items">
        <button class="play_btn"></button>
        <div class="time">0 / 0</div>
        <button class="fullscreen"></button>
      </div>
    </div></div>

其中 video 设置了 playsinline 属性,是为 IOS 视频播放时不自动进入全屏。x5-playsinline 是让腾讯 x5浏览器内核不自动进入全屏。X5 是腾讯基于 Webkit 开发的浏览器内核,应用于 Android 端的微信、QQ 等应用。更多关于 x5 video 属性参考这里

代码语言:javascript复制
.player-loading .loading { opacity: 1; }.player-playing .play_btn::after { content: '暂停'; }.player-controls-hide { cursor: none; }.player-controls-hide .controls { opacity: 0; }.player-fullscreen .fullscreen:after { content: '退出全屏'; }

加点简单的 CSS,这里主要关注使用 JS 实现功能的核心代码,样式部分就省略了。

代码语言:javascript复制
const player = document.querySelector('.player')const video = document.querySelector('video')

控制器的显示和隐藏

关于控制器显示/隐藏需要注意几点:

  1. 当视频没有播放时控制器要显示出来
  2. 当视频播放时需要等一会儿再将控制器隐藏
  3. 当视频播放时点击鼠标或移动鼠标需要将控制器显示
  4. 当视频播放结束时控制器显示出来
代码语言:javascript复制
let controlsTimer = nullfunction showControls() {    clearTimeout(controlsTimer)
    player.classList.remove('player-controls-hide')    updatePlayedBarAndTime() // 更新进度条,查看下一小节}function delayHideControls() {    showControls();
    controlsTimer = setTimeout(() => {        if (video.played.length && !video.paused) {
            player.classList.add('player-controls-hide')
        }
    }, 3000)
}
player.addEventListener('click', delayHideControls)
player.addEventListener('mousemove', delayHideControls)
video.addEventListener('play', delayHideControls)
video.addEventListener('pause', showControls)

这里主要是通过判断 video.played.length && !video.paused 来判断是否隐藏控制器,也就是视频播放过并且视频正在播放,这里没有监听 ended 事件,因为播放完毕也会触发 pause 事件。

进度条和时间显示

代码语言:javascript复制
const playedBar = document.querySelector('.bar_played')const bufferedBar = document.querySelector('.bar_played')const time = document.querySelector('.time')function updatePlayedBarAndTime(currentTime, percentage) {
    currentTime = currentTime == null ? Math.round(video.currentTime) : currentTime
    percentage = percentage == null ? Math.min(video.currentTime / video.duration, 1) : percentage
    time.textContent = currentTime   ' / '   Math.round(video.duration)
    playedBar.style.transform = 'scaleX('  percentage  ')'}

video.addEventListener('durationchange', updatePlayedBarAndTime)
video.addEventListener('timeupdate', () => {  if (!player.classList.contains('player-controls-hide')) {    updatePlayedBarAndTime()
  }
})
video.addEventListener('progress', () => {  const percentage = video.buffered.length ? video.buffered.end(video.buffered.length - 1) / video.duration : 0;
  bufferedBar.style.transform = 'scaleX('  Math.min(percentage, 1)  ')'})

通过监听 timeupdate 事件在控制器显示的情况下更新 DOM,progress 事件更新视频缓存进度条 video.buffered.end(video.buffered.length - 1) 可以获取最后一段 TimeRange 的结束时间。

播放控制

代码语言:javascript复制
const playPauseBtn = document.querySelector('.play_btn')

playPauseButton.addEventListener('click', evt => {
  evt.stopPropagation() // 为了防止父级处理事件,比如视频控件
  if (video.paused) {
    video.play()
  } else {
    video.pause()
  }
})
video.addEventListener('play', () => {
  player.classList.add('player-playing')
})
video.addEventListener('pause', () => {
  player.classList.remove('player-playing')
})
video.addEventListener('ended', () => {
  player.classList.remove('player-playing')
  video.currentTime = 0})

这里没有在按钮点击事件中处理视频播放暂停 UI 变化而是在 video 事件中处理,是为了让 UI 更精准,不止有这个按钮会控制视频播放和暂停。这里没有展示控制视频播放速率,控制播放速率直接设置 video.playbackRate 就行。

控制进度条

代码语言:javascript复制
const bar = document.querySelector('.bar')const { width: barWidth, x: barLeft } = bar.getBoundingClientRect()let barPending = falselet barLastX = 0let videoSeekTime = -1function onBarPointerStart(evt) {
    evt.preventDefault()
    bar.setPointerCapture(ev.pointerId)
    bar.addEventListener('pointermove', this.onPointerMove, true)
    video.pause()
    barLastX = evt.pageX
    const [percentage, seekTime] = calcPercentageAndSeekTime(evt.pageX)    updatePlayedBarAndTime(seekTime, percentage)
}function onBarPointerMove(evt) {
    evt.preventDefault()
    barLastX = evt.pageX
    if (barPending) return
    barPending = true
    requestAnimationFrame(handleBarMove)
}function onBarPointerEnd(evt) {
    evt.preventDefault()
    bar.releasePointerCapture(evt.pointerId)
    bar.removeEventListener('pointermove', onBarPointerMove, true)    if (videoSeekTime > 0) video.curentTime = videoSeekTime
    video.play()
}function calcPercentageAndSeekTime() {    const percentage = Math.max(Math.min((barLastX - barLeft) / barWidth, 1), 0)
    videoSeekTime = percentage * video.duration
    return [percentage, videoSeekTime]
}function handleBarMove() {    if (!barPending) return
    const [percentage, seekTime] = calcPercentageAndSeekTime()    updatePlayedBarAndTime(seekTime, percentage)
    barPending = false}

bar.addEventListener('pointerdown', onBarPointerStart, true)
bar.addEventListener('pointerup', onBarPointerEnd, true)
bar.addEventListener('pointercancel', onBarPointerEnd, true)

这里使用 PointerEvent 来实现监听控制条的拖拽,它的好处是兼容 PC 的鼠标拖拽和移动的手势拖拽,结束时通过设置 video.curentTime 来跳到指定时间点。控制音量与这个相似。

全屏

代码语言:javascript复制
const isIos = /(iPad|iPhone|iPod)/gi.test(navigator.platform)const fullscreenBtn = document.querySelector('.fullscreen')
fullscreenBtn.addEventListener('click', evt => {
    evt.stopPropagation()    if (document.fullscreenElement) {        if (isIos) {
            video.webkitExitFullscreen()
        } else {            document.exitFullscreen()
        }
    } else {        if (isIos) {
            video.webkitEnterFullscreen()
        } else {
            player.requestFullscreen()
        }
    }
})document.addEventListener('fullscreenchange', () => {
  player.classList.toggle('player-fullscreen', document.fullscreenElement)
})

这里需要注意对 IOS 的兼容。对于老浏览器请求、退出和全局全屏元素都需要添加浏览器前缀。想要跨浏览器兼容的全屏 API 可以使用 screenfull.js

Loading 处理

代码语言:javascript复制
video.addEventListener('canplay', () => {
    player.classList.remove('player-loading')
})
video.addEventListener('waiting', () => {
    player.classList.add('player-loading')    
    const startWaitingTime = video.currentTime
    const checkCanPlay = () => {      if (startWaitingTime !== video.currentTime) {
        player.classList.remove('player-loading')
        video.removeEventListener('timeupdate', checkCanPlay)
      }
    }
    video.addEventListener('timeupdate', checkCanPlay)
})

并不是所有浏览器在 waiting 事件触发后,当可播放时还会触发 canplay 事件。所以这里通过 timeupdate 事件来比对时间,确认已经可以播放视频了。

不过并不是所有浏览器能正确触发 waiting 事件,所以我们需要自己检测是否停住等待加载视频。

代码语言:javascript复制
let prevCurrentTime = 0let playerLoading = falselet loadingTimer = setInterval(() => {    const currentTime = video.currentTime
    if (!playerLoading && prevCurrentTime === currentTime && !video.paused) {
        player.classList.add('player-loading')
        playerLoading = true
    } else if (playerLoading && currentTime !== prevCurrentTime) {
        player.classList.remove('player-loading')
        playerLoading = false
    }
    prevCurrentTime = currentTime
}, 100)

这里实现比较简单主要是通过定时器去不断获取视频 currentTime 通过比对它来确定视频是否卡住等待播放。还可以将上面监听 progress 事件获取到的 buffered 时间,比对 currentTime 来决定是否去除 player-loading

字幕

代码语言:javascript复制
if (video.textTracks && video.textTracks.length) {    const defaultLang = navigator.language?.split('-')[0] || 'zh'
    let saw = false
    video.textTracks.forEach(track => {        if (track.language === defaultLang) {
            track.mode = 'showing'
            saw = true
        } else {
            track.mode = 'hidden'
        }
    })    if (!saw) video.textTracks[0].mode = 'showing'
    // 这里只是默认显示一个 textTrack
    // 应该有个菜单可以让用户选择字幕,这里就省略了}
代码语言:javascript复制
::cue {  color:#fff;  background: transparent;  text-shadow: 2px 3px 5px rgba(0, 0, 0, .5);
}

上面我们通过控制 textTrackmode 属性控制显示哪个 textTrack,通过 ::cue 伪类设置字幕样式,但是如果要更精准的控制字幕,我们就需要自己使用 DOM 元素来显示字幕。

代码语言:javascript复制
if (video.textTracks && video.textTracks.length) {    const container = document.querySelector('.player_subtitle') // 字幕容器
    const track = video.textTracks[0]
    track.mode = 'hidden'
    track.oncuechange = () => {        const cue = track.activeCues[0] // cue 代表一条字幕
        container.innerHTML = ''
        if (cue) {            const subtitle = document
                                .createElement('div')
                                .appendChild(cue.getCueAsHTML())
                                .innerHTML
            // 因为 getCueAsHTML 返回的是 document-fragment
            
            container.innerHTML = subtitle.split(/rn|n|r/).map(t => `<p>${t}</p>`).join('')
        }
    }
}

通过监听 oncuechange 获取当前 cue,然后获取它的内容,然后加入到自定义字幕容器中。 cue 对象长下面这样。

画中画

Picture-in-Picture(画中画)可以让视频弹出来小屏播放,就算最小化浏览器或者切换其他 tab 页也可以播放。

代码语言:javascript复制
if (document.pictureInPictureEnabled) { // 是否启用画中画
    const pip = document.querySelector('.pip')
    pip.style.display = 'block'
    pip.addEventListener('click', () => {        if (document.pictureInPictureElement) { // 当前画中画的元素
            video.exitPictureInPicture()
        } else {
            video.requestPictureInPicture() // 返回 Promise,里面是 pipWindow
        }
    })

    video.addEventListener('enterpictureinpicture', pipWindow => {        console.log(pipWindow.width, pipWindow.height)
        player.classList.add('player-pip')
    })

    video.addEventListener('leavepictureinpicture', () => {
       player.classList.remove('player-pip')
    })
}

截图

视频截图是通过在 canvas 渲染视频实现的。

代码语言:javascript复制
const screenshotBtn = document.querySelector('.screenshot_btn')

screenshotBtn.addEventListener('click', () => {    const canvas = document.createElement('canvas')
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight
    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)    const fileName = video.currentTime   '.png'
    canvas.toBlob(blob => {        const url = URL.createObjectURL(blob)        const a = document.createElement('a')
        a.href = url
        a.download = fileName
        a.style.display = 'none'
        document.body.appendChild(a)
        a.click()        document.body.removeChild(a)        URL.revokeObjectURL(url)
    }, 'image/png')
})

源码

https://github.com/woopen/nplayer(欢迎点赞

0 人点赞