本文是系列文章的下篇,如果你还没有读过上篇文章,可以移步:在Vue中自制视频播放器(上)。
在上篇文章中我们已经完成了播放/暂停按钮、停止按钮、静音按钮、播放时间和全屏按钮的制作,代码和演示效果都可以在上一篇文章中找到,在这篇文章中我们就要完成视频播放器的核心部分——进度条。
这篇文章我将改变一下演示的思路,我会在开头直接放出完整代码,然后会在每一个小节中对功能性的代码进行分析,让读者能理解每一部分的代码都是做什么的。
完整代码:
<template>
<div class="video" @pointermove.prevent="handleMouseMove($event)"
@pointerup.prevent="stopDragging"
@pointerleave="handleMouseLeave"
@pointerenter="handleMouseEnter" ref="vcontainer">
<video class="video__player" ref="v" @timeupdate="handleTimeUpdate" @ended="handleEnd">
<source :src="videoSrc"/>
video>
<div class="controller" v-show="isControlVisible">
<div class="controller__progress-wrapper">
<div class="controller__progress" ref="p" @click="handleProgressClick($event)">
<div class="controller__progress controller__progress--passed"
:style="{
width: videoProgressPercent}">div>
<div class="controller__dot"
:style="{
left: videoProgressPercent}"
@pointerdown="startDragging($event)">
<div class="controller__inner-dot">div>
div>
div>
div>
<div class="controller__btn-wrapper">
<div class="controller__btn" @click="togglePlaying">
<font-awesome-icon :icon="['fas', 'play']" v-if="isPaused">font-awesome-icon>
<font-awesome-icon :icon="['fas', 'pause']" v-else>font-awesome-icon>
div>
<div class="controller__btn" @click="stopPlaying">
<font-awesome-icon :icon="['fas', 'stop']">font-awesome-icon>
div>
<div class="controller__btn" @click="toggleMute">
<font-awesome-icon :icon="['fas', 'volume-up']"
v-if="isMuted">font-awesome-icon>
<font-awesome-icon :icon="['fas', 'volume-mute']" v-else>font-awesome-icon>
div>
<div class="controller__timer">
{
{videoTime}}
div>
<div class="controller__btn controller__btn--fullscreen" @click="toggleFullscreen">
<font-awesome-icon :icon="['fas', 'expand']">font-awesome-icon>
div>
div>
div>
div>
template>
<script>
function secToTimer(originalSec) {
const min = Math.floor(originalSec / 60);
const sec = Math.floor(originalSec % 60);
const minStr = min < 10 ? `0${
min}` : String(min);
const secStr = sec < 10 ? `0${
sec}` : String(sec);
return `${
minStr}:${
secStr}`;
}
export default {
name: "MyVideo",
props: [
'videoSrc',
],
data() {
return {
video: null,
isPaused: true,
isMuted: false,
videoTime: '00:00 / 00:00',
isDragging: false,
isControlVisible: false,
hidingEvent: null,
videoProgress: 0,
draggingStartX: 0,
dotOffsetX: 0,
progress: null,
};
},
computed: {
videoProgressPercent() {
return `${
this.videoProgress * 100}%`;
},
},
methods: {
toggleFullscreen() {
const isFullscreen = document.webkitIsFullScreen || document.fullscreen;
if (isFullscreen) {
const exitFunc = document.exitFullscreen || document.webkitExitFullscreen;
exitFunc.call(document);
} else {
const element = this.$refs.vcontainer;
const fullscreenFunc = element.requestFullscreen || element.webkitRequestFullScreen;
fullscreenFunc.call(element);
}
},
handleTimeUpdate() {
this.videoTime = this.refreshTime();
this.videoProgress = this.video.currentTime / this.video.duration;
},
refreshTime() {
if (!this.video) {
return `${
secToTimer(0)} / ${
secToTimer(0)}`;
}
const currTime = this.video.currentTime || 0;
const duration = this.video.duration || 0;
return `${
secToTimer(currTime)} / ${
secToTimer(duration)}`;
},
togglePlaying() {
if (this.video.paused) {
this.playVideo();
} else {
this.pauseVideo();
}
},
stopPlaying() {
this.video.currentTime = 0;
this.pauseVideo();
},
toggleMute() {
this.video.muted = !this.video.muted;
this.isMuted = this.video.muted;
},
handleEnd() {
this.pauseVideo();
},
playVideo() {
this.isPaused = false;
this.video.play();
},
pauseVideo() {
this.isPaused = true;
this.video.pause();
},
setProgress(x) {
const progressRect = this.progress.getBoundingClientRect();
let progressPercent = (x - progressRect.left) / progressRect.width;
if (progressPercent < 0) {
progressPercent = 0;
} else if (progressPercent > 1) {
progressPercent = 1;
}
this.video.currentTime = this.video.duration * progressPercent;
},
hideControlBar() {
const isFullscreen = document.webkitIsFullScreen || document.fullscreen;
if (isFullscreen) {
this.hideCursor();
}
this.isControlVisible = false;
},
showControlBar() {
this.isControlVisible = true;
},
hideCursor() {
document.body.style.cursor = 'none';
},
showCursor() {
document.body.style.cursor = 'default';
},
handleProgressClick(event) {
const clickX = event.clientX;
this.setProgress(clickX);
},
startDragging(event) {
this.pauseVideo();
this.isDragging = true;
this.draggingStartX = event.clientX;
},
moveDragging(event) {
if (this.isDragging) {
const offsetX = event.clientX - this.draggingStartX;
this.dotOffsetX = offsetX < 0 ? 0 : offsetX;
this.setProgress(event.clientX);
}
},
stopDragging() {
this.isDragging = false;
this.dotOffsetX = 0;
},
handleMouseMove(event) {
this.showControlBar();
this.showCursor();
if (this.hidingEvent !== null) {
clearInterval(this.hidingEvent);
}
this.hidingEvent = setInterval(this.hideControlBar, 3000);
this.moveDragging(event);
},
handleMouseLeave() {
this.hideControlBar();
this.stopDragging();
},
handleMouseEnter() {
this.showControlBar();
},
},
mounted() {
this.video = this.$refs.v;
this.progress = this.$refs.p;
},
};
script>
<style scoped>
.video {
position: relative;
}
.video__player {
width: 100%;
height: 100%;
display: flex;
}
.controller {
flex-direction: column;
height: 50px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
}
.controller__btn-wrapper {
position: relative;
height: calc(100% - 5px);
display: flex;
align-items: center;
color: #fff;
padding: 0 18px;
}
.controller__btn {
cursor: pointer;
transition: 0.5s;
margin: 0 20px;
}
.controller__btn:hover {
color: #409eff;
}
.controller__timer {
margin-left: 15px;
}
.controller__btn--fullscreen {
position: absolute;
right: 15px;
}
.controller__progress-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.controller__progress {
height: 5px;
position: relative;
width: calc(100% - 30px);
border-radius: 100px;
background: #dcdcdc;
cursor: pointer;
}
.controller__progress--passed {
position: absolute;
top: 0;
left: 0;
background: #409EFF;
}
.controller__dot {
position: absolute;
z-index: 50;
left: 0;
top: -5px;
width: 15px;
height: 15px;
border-radius: 50%;
background-color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.controller__inner-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background-color: #409EFF;
}
style>
运行效果:
这个进度条的原理就是先创建一个 在上篇文章中,我们讨论了 相关代码: 每次事件被触发时,做一个除法, 根据我的实现,进度条有两个功能,一个是轻点进度条任意部分时,视频会跳到被点击的时间点。另一个功能是拖动进度条时视频的播放时间会实时改变。 相关代码: 我这里绑定了几个事件,其中 这一系列事件的原理就是当按下鼠标时,记录下鼠标相对于可视部分窗口的X坐标,当鼠标正在移动时,根据鼠标当前位置和起始位置的X坐标的差值,除以整个进度条的总长度,就可以得到一个移动的比例,当我们得到这个移动比例时,更新进度条的长度,并用这个比例乘上视频总长度得到一个移动时间,在当前播放时间加上或减去这个移动时间,就得到了新的视频播放时间。 要注意的是,我们要处理用户把鼠标移动到进度条零点左边的位置这种行为,所以在计算时还要考虑边界点,不能让进度按钮的位置变成负值。 有了拖动进度条的实现,用户点击进度条任意位置的跳转也就变得比较简单了,这里要处理的是用户点击位置的X坐标和当前进度点的X坐标的差值,有了这个差值,其余的逻辑与拖动进度条相似。 这里还有一个要注意的小点,当我们发现用户要拖动进度条时,要暂停视频,暂停视频的操作只要绑定到 到这里为止,进度条就完成了。 我们平时在看视频时会发现,如果鼠标一直没有动作,视频控制条会自动隐藏,如果在全屏的情况下,鼠标也会被隐藏,这样用户在看视频的时候会有更好的体验,这一部分我们就来实现自动隐藏控制条的功能,给我们的播放器锦上添花。 相关代码: 这里我们用一个属性 这里我们用 最后是关于隐藏鼠标的,如果用CSS可以直接设置 文章中例子的源代码我已经放到了GitHub上,请点击这里查看或下载。absolute
的子absolute
,作为可以拖动的部分。要注意,这个圆形的进度点上要绑定一些事件,使得它在被拖动的时候能更新视频的播放时间,同时将这个变化反映在进度条的颜色变化上。
进度条随时间自动更新
元素的
timeupdate
事件,每当视频的播放时间改变时这个事件就会触发,也就是说,在视频正常播放的情况下,这个事件会被频繁触发,这个事件可以作为视频进度条更新的时间点。<video class="video__player" ref="v" @timeupdate="handleTimeUpdate" @ended="handleEnd">
<source :src="videoSrc"/>
video>
<div class="controller__progress controller__progress--passed"
:style="{
width: videoProgressPercent}">
div>
handleTimeUpdate() {
this.videoTime = this.refreshTime();
this.videoProgress = this.video.currentTime / this.video.duration;
},
computed: {
videoProgressPercent() {
return `${
this.videoProgress * 100}%`;
},
},
this.video.currentTime / this.video.duration
得到了一个已过时间的比例,这个比例就是进度条蓝色部分的长度比例和圆形按钮的偏移比例,我这里用了一个computed
属性,用来将一个data
中的属性进行实时转换变成CSS中有效的长度单位,关于Vue中computed
属性的相关知识,可以参考官方文档。进度条拖动
<div class="controller__progress-wrapper">
<div class="controller__progress" ref="p" @click="handleProgressClick($event)">
<div class="controller__progress controller__progress--passed"
:style="{
width: videoProgressPercent}">div>
<div class="controller__dot"
:style="{
left: videoProgressPercent}"
@pointerdown="startDragging($event)">
<div class="controller__inner-dot">div>
div>
div>
div>
handleProgressClick(event) {
const clickX = event.clientX;
this.setProgress(clickX);
},
setProgress(x) {
const progressRect = this.progress.getBoundingClientRect();
let progressPercent = (x - progressRect.left) / progressRect.width;
if (progressPercent < 0) {
progressPercent = 0;
} else if (progressPercent > 1) {
progressPercent = 1;
}
this.video.currentTime = this.video.duration * progressPercent;
},
startDragging(event) {
this.pauseVideo();
this.isDragging = true;
this.draggingStartX = event.clientX;
},
moveDragging(event) {
if (this.isDragging) {
const offsetX = event.clientX - this.draggingStartX;
this.dotOffsetX = offsetX < 0 ? 0 : offsetX;
this.setProgress(event.clientX);
}
},
stopDragging() {
this.isDragging = false;
this.dotOffsetX = 0;
},
startDragging
表示拖动开始时鼠标按下的动作,这个事件绑定在了圆形按钮上,moveDragging
表示拖动过程中鼠标的移动事件,而stopDragging
表示拖动结束后鼠标松开的事件,后面这两个事件我绑定在了容器元素上,因为在拖动的过程中,无法保证鼠标一直悬浮在圆形按钮上,只有把事件绑定到最顶层的容器上,才能完整获取到事件的过程和结束。startDragging
这个记录用户开始拖动的事件即可。自动隐藏控制条
<div class="video" @pointermove.prevent="handleMouseMove($event)"
@pointerup.prevent="stopDragging"
@pointerleave="handleMouseLeave"
@pointerenter="handleMouseEnter" ref="vcontainer">
<div class="controller" v-show="isControlVisible">...div>
...
div>
handleMouseEnter() {
this.showControlBar();
},
handleMouseMove(event) {
this.showControlBar();
this.showCursor();
if (this.hidingEvent !== null) {
clearInterval(this.hidingEvent);
}
this.hidingEvent = setInterval(this.hideControlBar, 3000);
this.moveDragging(event);
},
handleMouseLeave() {
this.hideControlBar();
this.stopDragging();
},
showControlBar() {
this.isControlVisible = true;
},
hideControlBar() {
const isFullscreen = document.webkitIsFullScreen || document.fullscreen;
if (isFullscreen) {
this.hideCursor();
}
this.isControlVisible = false;
},
hideCursor() {
document.body.style.cursor = 'none';
},
showCursor() {
document.body.style.cursor = 'default';
},
isControlVisible
来控制视频控制条的显示与隐藏,注意这里不能用v-if
而必须要用v-show
,否则在mounted
生命周期钩子中,会因为控制条没有被渲染而使得this.progress
被赋值为undefined
。在使用v-show
的情况下,元素仍然会被渲染,只是被隐藏起来了而已,所以this.progress
被正确赋值为进度条元素的引用。setInterval
,当鼠标进入视频播放器时,开始计时,3秒后隐藏控制条,我们在使用setInterval
的同时将这个事件的ID保存下来,鼠标一旦移动,我们用clearInterval
清除这个计时器,重新开始计时,保证只有3秒内用户完全没有动作时控制条才会被隐藏。body
的cursor
属性,来控制鼠标在网页中的外观,这个属性有一个特别的值none
,如果被设置成none
了以后鼠标就会被隐藏起来。这里我们的逻辑是在隐藏控制条的同时加一个隐藏鼠标的操作,但是隐藏鼠标前要先判断一下当前是不是在全屏状态下。关于全屏状态的检测上篇已经说过了,是浏览器的API,要考虑浏览器的支持情况,如果发现用户是全屏状态,通过设置document.body.style.cursor = 'none'
隐藏鼠标,否则就不对鼠标做任何改变。document.body.style.cursor = 'default'
是将鼠标外观设置成默认样式,如果它此前被隐藏了,这行代码会把鼠标重新显示出来。源代码