-
背景
需求:基于原生的h5 的功能,增加控制左右旋转视频的功能
-
尝试失败的方案
-
修改原生controls
无法解决以下问题:
- 控制栏在#top-layer层,无法操作dom,只能做极有限的样式配置
- 通过css transform: rotate(),旋转video时,工具栏随着video旋转,无法定位到视频下方
-
寻找现成组件
查找了video.js等相关插件,没找到满足需求的轮子
-
-
最终方案
-
隐藏原生工具栏
video::-webkit-media-controls { display: none !important; } -
重写整个视频控制工具栏
-
播放
// 播放 // videoSelector 作为属性传入到组件中 play() { const video = document.querySelector(this.videoSelector) as any; video && video.play(); } -
暂停
// 暂停播放 // videoSelector 作为属性传入到组件中 pause() { const video = document.querySelector(this.videoSelector) as any; video && video.pause(); } -
全屏
// 全屏 private isFullScreen = false; // videoContainerSelector video 外层dom 作为属性传入到组件中 fullscreen() { const videoBox = document.querySelector(this.videoContainerSelector) as any; videoBox && (videoBox.className = 'video-wrap fullscreen'); // 给video增加全屏样式 this.isFullScreen = true; } -
退出全屏
// 全屏 // videoContainerSelector video 外层dom 作为属性传入到组件中 exitFullscreen() { const videoBox = document.querySelector(this.videoContainerSelector) as any; videoBox && (videoBox.className = 'video-wrap'); // 给video去掉全屏样式 this.isFullScreen = false; } -
旋转视频
// 旋转视频 rotate(rotation) { const video = document.querySelector(this.videoSelector) as any; const transformValue = video?.style.transform; const matches = (transformValue as any).match(/rotate(([^)]+))/); let angle = 0; if (matches) { angle = parseFloat(matches[1]); } video && (video.style.transform = 'rotate(' + (angle + rotation) + 'deg)'); } -
播放速度
// 播放速度 private rate = 1; changePlayBackRate(rate) { const video = document.querySelector(this.videoSelector) as any; video && (video.playbackRate = Number(rate)); video && (this.rate = video.playbackRate); } -
画中画
// 画中画 pictureInPiacture() { const video = document.querySelector(this.videoSelector) as any; video && video?.requestPictureInPicture(); } -
退出画中画
// 退出画中画 exitPitctureInPicture() { if ((document as any).pictureInPictureElement) { (document as any).exitPictureInPicture(); } } -
播放进度条
<div class="progress"> <el-progress :percentage="(currentTime * 100) / Number(duration)" :show-text="false" color="#fff" :stroke-width="4" ></el-progress> </div> <script> private currentTime = 0; private videoPlaying = false; mounted() { let checkVideoTimer: number | null = setInterval(() => { const video = document.querySelector(this.videoSelector) as any; if (video) { video.ontimeupdate = (e) => { this.videoPlaying = true; this.currentTime = video?.currentTime; }; video.onpause = () => { this.videoPlaying = false; }; checkVideoTimer && clearInterval(checkVideoTimer); checkVideoTimer = null; } }, 200); } ... private duration: number | string = 0; private durationchange(e) { this.duration = e.target.duration.toFixed(0); } </script>
-
-
完整代码
``` <template> <div> <el-button v-if="isFullScreen" icon="el-icon-close" class="ex-btn" circle @click="exitFullscreen"></el-button> <div class="custom-video-controls"> <div class="controls"> <div class="left"> <img v-if="!videoPlaying" src="@/assets/icons/video/play.png" @click="play" alt="播放" /> <img v-else src="@/assets/icons/video/pause.png" @click="pause" alt="暂停" /> <span class="duraction">{{ _formatTime(Number(currentTime).toFixed(0)) }} / {{ _formatTime(duration) }}</span> <img src="@/assets/icons/video/rotateleft.png" @click="rotate(-90)" alt="左转90°" /> <img src="@/assets/icons/video/rotateright.png" @click="rotate(90)" alt="右转90°" /> </div> <div> <img v-if="!isFullScreen" src="@/assets/icons/video/fullscreen.png" @click="fullscreen" alt="全屏" /> <img v-else src="@/assets/icons/video/exitfullscreen.png" @click="exitFullscreen" alt="退出全屏" /> <el-dropdown @command="moreCommand" trigger="click" :hide-on-click="false" > <el-button circle><img src="@/assets/icons/video/more.png" alt="更多" /></el-button> <el-dropdown-menu slot="dropdown"> <el-dropdown-item command="download"><span class="dropdown-link"> <img src="@/assets/icons/video/download.png" alt="下载" />下载 </span></el-dropdown-item> <el-dropdown-item command="rate"> <el-dropdown @command="changePlayBackRate" trigger="click" placement="left" > <span class="dropdown-link"> <img src="@/assets/icons/video/rate.png" alt="播放速度" />播放速度 </span> <el-dropdown-menu slot="dropdown"> <el-dropdown-item command="0.25" :class="rate == 0.25 ? 'active' : ''" ><span class="rate-option">0.25</span></el-dropdown-item> <el-dropdown-item command="0.5" :class="rate == 0.5 ? 'active' : ''" ><span class="rate-option">0.5</span></el-dropdown-item> <el-dropdown-item command="0.75" :class="rate == 0.75 ? 'active' : ''" ><span class="rate-option">0.75</span></el-dropdown-item> <el-dropdown-item command="1" :class="rate == 1 ? 'active' : ''" ><span class="rate-option">正常</span></el-dropdown-item> <el-dropdown-item command="1.25" :class="rate == 1.25 ? 'active' : ''" ><span class="rate-option">1.25</span></el-dropdown-item> <el-dropdown-item command="1.5" :class="rate == 1.5 ? 'active' : ''" ><span class="rate-option">1.5</span></el-dropdown-item> <el-dropdown-item command="1.75" :class="rate == 1.75 ? 'active' : ''" ><span class="rate-option">1.75</span></el-dropdown-item> <el-dropdown-item command="2" :class="rate == 2 ? 'active' : ''" ><span class="rate-option">2</span></el-dropdown-item> </el-dropdown-menu> </el-dropdown></el-dropdown-item> <el-dropdown-item command="picture"><span class="dropdown-link"> <img src="@/assets/icons/video/picture.png" alt="画中画" />画中画 </span></el-dropdown-item> </el-dropdown-menu> </el-dropdown> </div> </div> <div class="progress"> <el-progress :percentage="(currentTime * 100) / Number(duration)" :show-text="false" color="#fff" :stroke-width="4" ></el-progress> </div> </div> </div> </template> <script lang="ts"> import { Component, Vue, Prop } from 'vue-property-decorator'; @Component({ name: 'VideoControl' }) export default class VideoControl extends Vue { @Prop({ default: 0 }) duration: number | string; @Prop({ default: '' }) videoContainerSelector: string; @Prop({ default: '' }) videoSelector: string; private currentTime = 0; private videoPlaying = false; mounted() { let checkVideoTimer: number | null = setInterval(() => { const video = document.querySelector(this.videoSelector) as any; if (video) { video.ontimeupdate = (e) => { this.videoPlaying = true; this.currentTime = video?.currentTime; }; video.onpause = () => { this.videoPlaying = false; }; checkVideoTimer && clearInterval(checkVideoTimer); checkVideoTimer = null; } }, 200); } // 播放 play() { const video = document.querySelector(this.videoSelector) as any; video && video.play(); } // 暂停播放 pause() { const video = document.querySelector(this.videoSelector) as any; video && video.pause(); } private _formatTime(seconds) { // 计算小时、分钟和秒数 const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const remainingSeconds = seconds % 60; // 将单个数字的小时、分钟和秒数格式化为两位数 const formattedHours = hours < 10 ? '0' + hours : hours; const formattedMinutes = minutes < 10 ? '0' + minutes : minutes; const formattedSeconds = remainingSeconds < 10 ? '0' + remainingSeconds : remainingSeconds; // 返回格式化后的时间字符串 if (hours) { return formattedHours + ':' + formattedMinutes + ':' + formattedSeconds; } else { return formattedMinutes + ':' + formattedSeconds; } } // 全屏 private isFullScreen = false; fullscreen() { const videoBox = document.querySelector(this.videoContainerSelector) as any; videoBox && (videoBox.className = 'video-wrap fullscreen'); this.isFullScreen = true; } // 全屏 exitFullscreen() { const videoBox = document.querySelector(this.videoContainerSelector) as any; videoBox && (videoBox.className = 'video-wrap'); this.isFullScreen = false; } rotate(rotation) { const video = document.querySelector(this.videoSelector) as any; const transformValue = video?.style.transform; const matches = (transformValue as any).match(/rotate(([^)]+))/); let angle = 0; if (matches) { angle = parseFloat(matches[1]); } video && (video.style.transform = 'rotate(' + (angle + rotation) + 'deg)'); } // 下载视频 downloadVideo() { const video = document.querySelector(this.videoSelector) as any; const videoSrc = video?.currentSrc || ''; fetch(videoSrc, { headers: new Headers({ Origin: location.origin }), mode: 'cors' }) .then((res) => res.blob()) .then((blob) => { const a = document.createElement('a'); document.body.appendChild(a); a.style.display = 'none'; const url = window.URL.createObjectURL(blob); a.href = url; a.download = '视频.mp4'; a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }); } // 播放速度 private rate = 1; changePlayBackRate(rate) { const video = document.querySelector(this.videoSelector) as any; video && (video.playbackRate = Number(rate)); video && (this.rate = video.playbackRate); } // moreCommand moreCommand(command) { switch (command) { case 'download': this.$message.warning('请点击鼠标右键,选择“视频另存为…'); return; case 'rate': return; case 'picture': this.pictureInPiacture(); return; default: return; } } // 画中画 pictureInPiacture() { const video = document.querySelector(this.videoSelector) as any; video && video?.requestPictureInPicture(); } // 退出画中画 exitPitctureInPicture() { if ((document as any).pictureInPictureElement) { (document as any).exitPictureInPicture(); } } } </script> <style lang="scss" scoped> .custom-video-controls { position: absolute; bottom: 0; left: 0; right: 0; background-color: rgba(0, 0, 0, 0.4); .controls { color: #fff; display: flex; align-items: center; justify-content: space-between; img { width: 18px; height: 18px; margin: 12px 8px 2px 8px; cursor: pointer; } button { background: transparent; border: none; } .left { display: flex; align-items: center; .duraction { margin: 12px 12px 2px 4px; } } } .progress { margin: 8px; ::v-deep .el-progress-bar__outer { background-color: #909399 !important; } } .rate-option { padding: 4px 24px; display: block; } } .dropdown-link { display: flex; align-items: center; padding: 8px 24px 8px 0px; img { width: 18px; height: 18px; margin: 0px 20px 0px 0px; } } .active { color: #38bba9; } .ex-btn { position: absolute; top: 40px; left: 50%; transform: translate(-50%, -50%); background-color: rgba(0, 0, 0, 0.4); color: #fff; border: none; opacity: 0; } </style> ```