Design a video player
业务最开始实现的视频播放功能用的是 xgplayer
插件,这个插件比较重,更侧重于直播。而我们业务所需要的仅仅是一个美化版本的 <video>
,为此,我们需要基于 video api
实现自己的播放器。
组件
主要有3个模块
-
视频预览
-
进度条
- seek
-
播放控件
- play
- pause
- mute on
- mute off
<div>
<a-player-container ref="videoRef" :width="width" :height="height" @click="autoPlay">
<a-spin v-show="loading" />
</a-player-container>
<a-progress
seekable
:pointer="pointer"
:currentTime="currentTime"
:duration="duration"
@seek="onSeek"
@dragEnd="onSeek"
></a-progress>
<a-player-control
:currentTime="currentTime"
:duration="duration"
:muted="muted"
:playing="playing"
:format="format"
@pause="pause"
@play="play"
@muteOn="openMute"
@muteOff="closeMute"
/>
</div>
API
定义一个类 MyVideo
,至少需要暴露以下几个方法
class MyVideo {
play() {
if (!this.elem) return
if (!this.elem.paused) return
// ready to play for the next frame
if (this.elem.readyState <= 2) return console.log('not ready 2')
this.elem.play()
}
pause() {
if (!this.elem) return
if (this.elem.paused) return
this.elem.pause()
}
openMute() {
if (!this.elem) return
this.elem.muted = true
}
closeMute() {
if (!this.elem) return
this.elem.muted = false
}
seek(ms: number) {
this.elem.currentTime = ms / 1000
}
destroy() {
this.pause()
if (this.elem) {
this.elem.src = ''
this.options.container?.removeChild(this.elem)
this.elem = null
}
}
}
入参
接下来定义入参,很容易想到,至少要告诉我一个 url
吧,其他参数可以是预加载、自动播放、循环播放、事件等和官方 API对齐即可
interface IOption {
url: string
container?: HTMLElement // 包含视频的容器
width?: number // 视频的宽高
height?: number
poster?: string // 视频的封面
preload?: string // metadata, none, auto
autoplay?: boolean
loop?: boolean
muted?: boolean
onLoadedMetadata?: (duration: number) => void
onLoadedData?: () => void
onSeeking?: () => void
onSeeked?: () => void
onWaiting?: () => void
onPlaying?: () => void
onProgress?: (progress: number) => void
onPlay?: () => void
onPause?: () => void
onAbort?: () => void
onEnd?: () => void
onError?: (err: Error) => void
}
加载视频
这里采用动态加载 video
插入到 DOM
的方式
class MyVideo {
async init() {
this.elem = this.createVideo()
this.bindEvents()
this.options.container?.appendChild(this.elem)
}
createVideo() {
const video = document.createElement('video')
video.setAttribute('preload', this.options.preload!)
if (this.options.poster) {
video.setAttribute('poster', this.options.poster)
}
if (this.options.autoplay) {
video.setAttribute('autoplay', 'true')
}
if (this.options.muted) {
video.muted = true
}
if (this.options.loop) {
video.setAttribute('loop', 'true')
}
video.src = this.options.url
if (this.options.width) {
video.width = this.options.width
}
if (this.options.height) {
video.height = this.options.height
}
return video
}
}
定义事件
Video
的官方文档就已经提供了很多钩子,我们直接使用接口,比如
class MyVideo {
bindEvents() {
this.elem.addEventListener('play', () => {
typeof this.options.onPlay == 'function' && this.options.onPlay()
})
}
}
预加载内容
preload
属性支持
-
auto
表示视频内容可以被下载,由浏览器决定 -
metadata
只预加载视频的元信息,比如视频长度 -
none
视频不会被预加载
因为每个浏览器的默认值不一样,同时在我们的业务里,视频时长是很有用的信息,所以我们默认取 metadata
另个预加载的方式是通过使用 video.load()
这个方法(加载的内容也是通过 preload
属性决定的),不过它更适用于视频的 src
发生改变的情况
timeupdate 更新卡顿
这里遇到一个问题,在 timeupdate
通过回调在 UI
层更新进度条的时候,会卡顿,原因是 timeupdate
的触发频率是 250ms
一次
The timeupdate event is fired when the time indicated by the currentTime attribute has been updated. The event frequency is dependent on the system load, but will be thrown between about 4Hz and 66Hz (assuming the event handlers don’t take longer than 250ms to run)
为了解决这个问题,我们需要在 timeupdate
里频繁的触发回调,才能达到丝滑般的滚动条前进效果
this.elem.addEventListener('timeupdate', () => {
this.clearTimer()
const step = () => {
// float second
const currentTime = this.elem ? this.elem!.currentTime * 1000 : 0
typeof this.options.onProgress == 'function' && this.options.onProgress(currentTime)
this.timer = window.requestAnimationFrame(step)
}
step()
})
同时需要在其他可能的地方,防止内存泄露,比如
class MyVideo {
bindEvents() {
this.elem.addEventListener('pause', () => {
this.clearTimer()
})
this.elem.addEventListener('abort', () => {
this.clearTimer()
})
this.elem.addEventListener('ended', () => {
this.clearTimer()
})
this.elem.addEventListener('error', () => {
this.clearTimer()
})
}
clearTimer() {
this.timer && cancelAnimationFrame(this.timer)
this.timer = null
}
destroy() {
this.clearTimer()
....
}
}