在Vue中自制视频播放器(下)

版权声明:博客内容为原创,禁止转载 https://blog.csdn.net/zyj362633491/article/details/86511133

前言

本文是系列文章的下篇,如果你还没有读过上篇文章,可以移步:在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>

运行效果:
最终效果

进度条基本原理

这个进度条的原理就是先创建一个<div>元素,作为进度条的背景,然后根据进度的百分比,创建另一个定位为absolute的子<div>元素,作为已过进度,同时根据这个百分比创建另外一个<div>元素,用样式将它变成圆形,定位也应该是absolute,作为可以拖动的部分。要注意,这个圆形的进度点上要绑定一些事件,使得它在被拖动的时候能更新视频的播放时间,同时将这个变化反映在进度条的颜色变化上。

进度条随时间自动更新

在上篇文章中,我们讨论了<video>元素的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表示拖动结束后鼠标松开的事件,后面这两个事件我绑定在了容器元素上,因为在拖动的过程中,无法保证鼠标一直悬浮在圆形按钮上,只有把事件绑定到最顶层的容器上,才能完整获取到事件的过程和结束。

这一系列事件的原理就是当按下鼠标时,记录下鼠标相对于可视部分窗口的X坐标,当鼠标正在移动时,根据鼠标当前位置和起始位置的X坐标的差值,除以整个进度条的总长度,就可以得到一个移动的比例,当我们得到这个移动比例时,更新进度条的长度,并用这个比例乘上视频总长度得到一个移动时间,在当前播放时间加上或减去这个移动时间,就得到了新的视频播放时间。

要注意的是,我们要处理用户把鼠标移动到进度条零点左边的位置这种行为,所以在计算时还要考虑边界点,不能让进度按钮的位置变成负值。

有了拖动进度条的实现,用户点击进度条任意位置的跳转也就变得比较简单了,这里要处理的是用户点击位置的X坐标和当前进度点的X坐标的差值,有了这个差值,其余的逻辑与拖动进度条相似。

这里还有一个要注意的小点,当我们发现用户要拖动进度条时,要暂停视频,暂停视频的操作只要绑定到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秒内用户完全没有动作时控制条才会被隐藏。

最后是关于隐藏鼠标的,如果用CSS可以直接设置bodycursor属性,来控制鼠标在网页中的外观,这个属性有一个特别的值none,如果被设置成none了以后鼠标就会被隐藏起来。这里我们的逻辑是在隐藏控制条的同时加一个隐藏鼠标的操作,但是隐藏鼠标前要先判断一下当前是不是在全屏状态下。关于全屏状态的检测上篇已经说过了,是浏览器的API,要考虑浏览器的支持情况,如果发现用户是全屏状态,通过设置document.body.style.cursor = 'none'隐藏鼠标,否则就不对鼠标做任何改变。document.body.style.cursor = 'default'是将鼠标外观设置成默认样式,如果它此前被隐藏了,这行代码会把鼠标重新显示出来。

源代码

文章中例子的源代码我已经放到了GitHub上,请点击这里查看或下载。

猜你喜欢

转载自blog.csdn.net/zyj362633491/article/details/86511133