用原生JS写一个视频播放条组件
视频播放器都有一个可以点击,可以拖动的播放进度条,本文将使用原生JavaScript来实现一个播放条组件,先看看最终的效果:
1. HTML结构
首先对播放条进行抽象,分析其基本结构。
一般情况下,播放条由整体进度条,当前进度条,缓存进度条,滑块,弹幕高能进度条等等组成,根据不同的功能需要可以进行相应模块的增加。
本文以原理性解释为主,为了简洁性,只实现 整体进度条,当前进度条 和 滑块3个部分。
<div class="progress-bar">
<!-- 整体进度条 -->
<div class="progress-bg"></div>
<!-- 当前进度条 -->
<div class="progress-indicator"></div>
<!-- 滑块 -->
<div class="progress-pointer"></div>
</div>
2. CSS装饰
对HTML进行简单的CSS装饰:
/* progress bar */
.progress-bar {
display: flex;
align-items: center;
position: relative;
height: 50px;
width: 100%;
cursor: pointer;
background: #aaa;
}
/* progress bar background */
.progress-bar .progress-bg {
position: absolute;
left: 0;
height: 5px;
width: 100%;
background-color: hsla(0, 0%, 100%, .2);
}
/* progress bar indicator */
.progress-bar .progress-indicator {
position: absolute;
left: 0;
height: 5px;
width: 100%;
transform-origin: 0 0;
transform: scaleX(0);
background-color: #00a1d6;
}
/* progress bar pointer */
.progress-bar .progress-pointer {
position: absolute;
left: 0;
height: 15px;
width: 15px;
border-radius: 50%;
background-color: #00a1d6;
}
3. 操作定义和状态转换图
在完成了基本 UI 结构的定义和装饰后,来考虑交互性。
首先思考播放条可以接受哪些操作:
鼠标按下,鼠标拖动,鼠标松开,手指接触屏幕,手指滑动,手指离开屏幕。
这些操作将会触发哪些操作?
-
鼠标按下 / 触屏开始:
- 当前进度条移动到鼠标或手指的位置
- 滑块移动到鼠标或手指的位置
-
鼠标按下不松开移动 / 手指触摸后不松手进行滑动:
- 当前进度条随着鼠标或手指的移动而移动
- 滑块随着鼠标或手指的移动而移动
-
鼠标松开 / 手指离开屏幕:
- 进入结束状态
- 进入结束状态
为了便于理解,画出上述操作的状态转换图:
4. 更新操作
播放条组件自身具有的一个状态属性是当前的进度,这里我们用百分比 percentage
来表示,percentage
为 0 表示当前进度为 0,percentage
为 1 表示当前进度为 100%。
每次进行更新操作时,首先获取鼠标或手指的位置,根据这个位置计算出当前的百分比,再由百分比更新DOM:
这样设计可以将更新函数与用户操作进行解耦,更新函数只依赖 播放条组件自身的 percentage
状态。
另外,我们可以创建一个代理对象用来监听 percentage
的变化,这个代理对象可以用来注册一些函数,每当 percentage
变化时,代理对象就通知这些函数执行。
4. 具体代码实现
在了解播放条的实现原理后,开始动手写代码,首先搞一个 Progress
类。
当新建一个对象时,传入播放条的入口dom 和 配置项,在初始化函数中,获取 整体进度条 和 滑块 的 dom 接口,定义播放条的状态。
class Progress {
constructor(progressElement /* 入口dom */, options /* 配置项 */) {
this.$progress = progressElement
this.$indicator = this.$progress.querySelector(".progress-indicator")
this.$pointer = this.$progress.querySelector(".progress-pointer")
this.options = options
this.states = { percentage: 0 }
// 用来存储插件函数
this.plugins = {}
// 代理对象,用来代理组件的状态属性
const that = this
this.stateProxy = new Proxy(this.states, {
set(states, state, value, stateProxy) {
if (value < 0) {
value = 0
} else if (value > 1) {
value = 1
}
// 当状态改变时,通知注册函数执行
if (that.plugins[state]) {
that.plugins[state].forEach((handler) => handler(value))
}
return Reflect.set(states, state, value, stateProxy)
},
get(states, state) {
return Reflect.get(states, state)
},
})
}
}
4.1 获取鼠标和手指的位置
通过计算事件触发时相对 viewport
的距离减去播放条相对 viewport
的距离得到鼠标在播放条里相对坐标,再用这个相对坐标除播放条的宽度得到百分比。
这里在获取事件相对 viewport 的距离时同时考虑了鼠标事件和 Touch 事件。
getPosInElement(event) {
const rect = this.$progress.getBoundingClientRect()
// 同时考虑鼠标事件和 Touch 事件
const eventX = event.clientX ? event.clientX : event.touches[0].clientX
const relativeX = eventX - rect.x
this.stateProxy.percentageX = relativeX / rect.width
}
4.2 更新函数
更新函数做的事情很简单,根据组件的 percentage
状态进行播放条组件 UI 的更新,需要更新的DOM就两个:当前进度条和滑块。
updateUI() {
// update indicator
const percentage = this.stateProxy.percentage
this.$indicator.style = `transform: scaleX(${percentage});`
// update pointer
const progressBarWidth = this.$progress.getBoundingClientRect().width
const progressPointerWidth = this.$pointer.getBoundingClientRect().width
let pointerPos = percentage * progressBarWidth
// 防止滑块越界
if (pointerPos + progressPointerWidth > progressBarWidth) {
pointerPos = progressBarWidth - progressPointerWidth
}
this.$pointer.style = `transform: translateX(${pointerPos}px);`
}
4.3 事件处理函数与注册
根据状态转换图写出不同事件触发时需要进行的操作,这里一个值得注意的点就是在鼠标按下和手指按下时,注册鼠标滑动,手指滑动,鼠标松开和手指松开的事件处理函数,在鼠标松开和手指松开时注销这些处理函数。
如果读者写过 drag 和 drop 相关功能,应该会对这些操作很熟悉,原理是差不多的。
// 鼠标按下或手指按下
startHandler(event) {
this.getPosInElement(event)
this.updateUI()
document.addEventListener("mousemove", this.moveHandler)
document.addEventListener("touchmove", this.moveHandler)
document.addEventListener("mouseup", this.endHandler)
document.addEventListener("touchend", this.endHandler)
}
// 鼠标按下移动,手指滑动
moveHandler(event) {
this.getPosInElement(event)
this.updateUI()
}
// 鼠标松开后
endHandler() {
document.removeEventListener("mousemove", this.moveHandler)
document.removeEventListener("touchmove", this.moveHandler)
document.removeEventListener("mouseup", this.endHandler)
document.removeEventListener("touchend", this.endHandler)
}
在定义完上面的事件处理函数后,只需要将 startHandler
注册到播放条的点击和触摸事件上即可:
this.$progress.addEventListener("mousedown", this.startHandler)
this.$progress.addEventListener("touchstart", this.startHandler)
4.4 实现功能函数插拔
做完以上的工作后,一个看起来可以正常点击滑动的播放条就 “完成” 了。
但是事情还没有结束,此时的播放条只是一个自娱自乐的玩意儿,我们需要它能跟外界交互。
比如,当点击或滑动播放条时,视频应该跳转到相应的位置。
怎么实现这个功能呢?前面我们建立了一个代理对象和注册函数的机制,是时候让它们派上用处了。
实现一个注册函数,用来注册需要实现的功能。
注册函数接收两个参数,第一个参数是要监听的状态属性,第二个参数是一个功能函数,该功能函数的参数是它监听的状态属性。
// 注册函数接收两个参数,第一个参数
on(state, handler) {
if (this.plugins[state]) {
const index = this.plugins[state].indexOf(handler)
if (index === -1) {
this.plugins[state].push(handler)
} else {
return false
}
} else {
this.plugins[state] = []
this.plugins[state].push(handler)
}
return true
}
再来一个注销函数:
off(state, handler) {
if (this.plugins[state]) {
const index = this.plugins[state].indexOf(handler)
if (index !== -1) {
this.plugins[state].splice(index, 1)
return true
}
}
return false
}
注册一个函数用来更新视频的当前播放时间:
// 这只是一段演示代码
function updateVideo(percentage) {
video.currentTime = video.duration * percentage
}
progress.on("percentage", updateVideo)
当 percentage
变化时,stateProxy
里的 set
函数就会触发,在这里我们执行所有跟 percentage
建立了监听关系的函数。
set(states, state, value, stateProxy) {
if (value < 0) {
value = 0
} else if (value > 1) {
value = 1
}
// 当状态改变时,通知注册函数执行
if (that.plugins[state]) {
that.plugins[state].forEach((handler) => handler(value))
}
return Reflect.set(states, state, value, stateProxy)
}
除此之外还能做很多事情,每次要增加新的功能时只需要通过注册函数将功能进行注册,不需要的时候就将其移除,实现类似插件插拔的功能。
5. 还能做什么
由于篇幅限制,本文到这里就结束了。
但是要写一个功能完善,健壮性好的播放条组件,还有很多工作需要做,比如:
- 组件如何应对响应式变化?当浏览器窗口大小变动或者手机横屏时,怎么保证UI的正确?
- 这个组件只支持横向的播放条,能不能支持竖向的?
- 用户不停地滑动滑块,导致插件函数频繁触发,特别是插件函数的计算量较大时会有性能问题,怎么解决?
- 怎么解决无障碍访问问题?
- 怎样对组件进行进一步抽象,使其可以用在不同的地方比如音量调节,音乐播放条?
- 怎么控制 UI 的大小,比如宽高?
- 怎么设计组件对外的API?
- …
这些问题留给有兴趣的读者研究。
6. 参考文献
How to Style a Video Player: the basics | Blue Billywig
Adding more advanced HTML5 video player custom controls | Blue Billywig
转载自:https://juejin.cn/post/7109779731805011982