videojs 实现视频流切换和分屏播放
在做项目时,遇到视频流加载切换和分屏播放的问题。在这里简单记录一下:
实现效果:

一、获取视频流
(1)通过后台请求获得;
一般在通过 axios 请求,在onMounted 中获取数据;
(2)本地组装视频列表;
let root = [
{
id: 1,
label: "分屏画面",
parentId: null
}
];
let equpment = [
{
name: "画面一",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/177cdd67413748f7ae3cbbbb07ae6481/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面二",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/aa2c5c39961342809a3cbad11011d2c4/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面三",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/51f949219dae487baa50137b2a1c67d5/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面四",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/c06812c1aab74b268c79ed2df65386d2/main.m3u8?maxbr=2048&contentid=15120519184043"
},
]
let newEqup = equpment.map((item: any, index: any) => (
{
id: index + 2,
label: item.name,
url: item.url,
parentId: 1,
}
))
let resList = [...root, ...newEqup];
// 得到的结果
(5)[{…}, {…}, {…}, {…}, {…}]
[
{id: 1, label: "分屏画面", parentId: null},
{id: 2, label: "画面一", parentId: 1,url: "xxxxxxxxxxxxxxx"},
{id: 3, label: "画面二", parentId: 1,url: "xxxxxxxxxxxxxxx"},
{id: 4, label: "画面三", parentId: 1,url: "xxxxxxxxxxxxxxx"},
{id: 5, label: "画面四", parentId: 1,url: "xxxxxxxxxxxxxxx"},
]

二、组装成树结构(按项目要求)
因为使用了 el-tree 组件列表需要分级显示,所以需要将列表转树结构;
// 列表转树结构
function listToTree(list: any) {
let tree = [];
let dict = {};
for (let item of list) {
dict[item.id] = { ...item, children: [] };
}
for (let item of list) {
let parent = dict[item.parentId];
if (parent) {
dict[item.id].parent = parent;
parent.children.push(dict[item.id]);
} else {
tree.push(dict[item.id]);
}
}
return tree;
}
tree = listToTree(resList);

三、实现分屏功能
这里的分屏切换,是通过 el-row 和 el-row 实现的;
let col = [24,12,8,6];
// 分别对应 一列,两列,三列,四列
<el-row>
<el-col :span="24"><div class="grid-content ep-bg-purple-dark" /></el-col>
</el-row>
通过改变 span 的数值,从而得到想要的列数; 再通过 v-for 遍历得到想要的行列;
四、点击切换视频流
因为需要点击切换显示多个视频流画面,所以采用了动态生成 video DOM 的方式;这样每次切换都是新的视频;
let dom: any = document.getElementById(innerId.replace("videoDom", "videoPlayer"));
// 向Dom中写入视频组件
dom.innerHTML = "";
const html = `<video id="${innerId}" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" style="width: 100%;height:100%;object-fit:fill;">
<source id="source" src="${url}" type="application/x-mpegURL">
</video>`;
dom.innerHTML = html;
videojs.hook("beforeerror", (player: any, err: any) => {
// Video.js 在切换/指定 source 后立即会触发一个 err=null 的错误,这里过滤一下
if (err !== null) {
playerObj.src(url);
}
// 清除错误,避免 error 事件在控制台抛出错误
return null;
});
playerObj.play();
这里有一个注意点:一个视频对应一个 ID,如果出现 VIDEOJS: WARN: Player “myVideo“ is already initialised. Options will not be applied. 那就是 ID 重复了,就会报错;所以需要循环动态地设置 ID;
<el-row :class="btnOne === 0 ? 'rowOne' : btnOne === 1 ? 'rowTwo' : btnOne === 2 ? 'rowThree' : 'rowFour'"
v-for="(item, index) in btnOne + 1">
<el-col class="col" :span="btnOne === 0 ? 24 : btnOne === 1 ? 12 : btnOne === 2 ? 8 : 6"
v-for="(inner, innerId) in btnOne + 1" @click="currentVideo('videoDom' + index + innerId, $event)">
<div ref="videoList" :id="'videoPlayer' + index + innerId" class="video-item"></div>
</el-col>
</el-row>
接下来切换视频流进行播放:我这里是先选择视频窗口,选中对应的video id,然后将 url 设置到 sources 里;
// 获取当前 DOM id 并添加选中边框
function currentVideo(id: any, e: any) {
innerId.value = id;
videoList.value.forEach((item: any) => (item.style.border = "none"));
e.target.style.border = "1px solid rgb(27 183 75)";
}
/**
* 获取选中的树节点 url
* @param data 选中的树节点
*/
const handleNodeClick = (data: any) => {
let videoUrl:any = resList.find((item: any) => item.label === data.label);
if(videoUrl.url) {
showUrl.value = videoUrl.url;
if(innerId.value) {
initVideo(innerId.value,showUrl.value);
} else {
alert('请先选择视屏窗口!')
}
}
};
五、完整代码
<template>
<div class="ahome">
<div class="home-content">
<div class="equipment">
<div class="equipContent">
<div class="equipLeft">
<div class="treelist">
<p class="list">设备列表</p>
<el-tree :data="tree" node-key="id" :props="defaultProps" @node-click="handleNodeClick"
:default-expanded-keys="[0]" />
</div>
<div class="online">
<p class="list">设备在线率</p>
</div>
</div>
<div class="equipCenter">
<div v-if="showVideo[btnOne]" class="videoShowBox">
<el-row :class="btnOne === 0
? 'rowOne'
: btnOne === 1
? 'rowTwo'
: btnOne === 2
? 'rowThree'
: 'rowFour'
" v-for="(item, index) in btnOne + 1">
<el-col class="col" :span="btnOne === 0 ? 24 : btnOne === 1 ? 12 : btnOne === 2 ? 8 : 6
" v-for="(inner, innerId) in btnOne + 1" @click="currentVideo('videoDom' + index + innerId, $event)">
<div ref="videoList" :id="'videoPlayer' + index + innerId" class="video-item"></div>
</el-col>
</el-row>
</div>
<div class="selectCard">
<div class="pic pic1" @click="onSubmit(0)">单画面</div>
<div class="pic pic2" @click="onSubmit(1)">4画面</div>
<div class="pic pic2" @click="onSubmit(2)">9画面</div>
<div class="pic pic3" @click="onSubmit(3)">16画面</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, computed } from "vue";
import videojs from 'video.js';
import 'video.js/dist/video-js.css'
let myPlayer = ref();
let myPlayerArr: any = ref([]);
let btnOne = ref(0);
let innerId = ref("");
let videoList = ref();
let showVideo = ref([true, true, true, true]);
// 定义树结构
const defaultProps = {
label: "label",
children: "children"
};
const tree: any = ref();
let showUrl = ref("");
let root = [
{
id: 1,
label: "分屏画面",
parentId: null
}
];
let equpment = [
{
name: "画面一",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/177cdd67413748f7ae3cbbbb07ae6481/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面二",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/aa2c5c39961342809a3cbad11011d2c4/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面三",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/51f949219dae487baa50137b2a1c67d5/main.m3u8?maxbr=2048&contentid=15120519184043"
},
{
name: "画面四",
url: "https://dh5.cntv.myalicdn.com/asp/h5e/hls/main/0303000a/3/default/c06812c1aab74b268c79ed2df65386d2/main.m3u8?maxbr=2048&contentid=15120519184043"
},
]
let newEqup = equpment.map((item: any, index: any) => (
{
id: index + 2,
label: item.name,
url: item.url,
parentId: 1,
}
))
let resList = [...root, ...newEqup];
console.log(resList);
function listToTree(list: any) {
let tree = [];
let dict = {};
for (let item of list) {
dict[item.id] = { ...item, children: [] };
}
for (let item of list) {
let parent = dict[item.parentId];
if (parent) {
dict[item.id].parent = parent;
parent.children.push(dict[item.id]);
} else {
tree.push(dict[item.id]);
}
}
return tree;
}
tree.value = listToTree(resList);
console.log(tree.value);
/**
* 获取选中的树节点 url
* @param data 选中的树节点
*/
const handleNodeClick = (data: any) => {
let videoUrl:any = resList.find((item: any) => item.label === data.label);
if(videoUrl.url) {
showUrl.value = videoUrl.url;
if(innerId.value) {
initVideo(innerId.value,showUrl.value);
} else {
alert('请先选择视屏窗口!')
}
}
};
// 获取当前 DOM id 并添加选中边框
function currentVideo(id: any, e: any) {
innerId.value = id;
videoList.value.forEach((item: any) => (item.style.border = "none"));
e.target.style.border = "1px solid rgb(27 183 75)";
}
const initVideo = (innerId: string, url: string) => {
myPlayerArr.value.forEach((item: any, index: any) => {
if (item.id === innerId) {
item.player.pause();
item.player.dispose();
myPlayerArr.value.splice(index, 1);
}
});
console.log("myPlayerArr.value", myPlayerArr.value);
let dom: any = document.getElementById(innerId.replace("videoDom", "videoPlayer"));
// 向Dom中写入视频组件
dom.innerHTML = "";
const html = `<video id="${innerId}" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto" style="width: 100%;height:100%;object-fit:fill;">
<source id="source" src="${url}" type="application/x-mpegURL">
</video>`;
dom.innerHTML = html;
// 初始化声明
let playerObj = videojs(innerId, {
autoplay: true, //自动播放
sources: [
{
src: url,
type: "application/x-mpegURL"
}
]
});
videojs.hook("beforeerror", (player: any, err: any) => {
// Video.js 在切换/指定 source 后立即会触发一个 err=null 的错误,这里过滤一下
if (err !== null) {
playerObj.src(url);
}
// 清除错误,避免 error 事件在控制台抛出错误
return null;
});
playerObj.play();
myPlayerArr.value.push({ id: innerId, player: playerObj });
};
// 分屏切换
const onSubmit = (num: any) => btnOne.value = num;
</script>
<style scoped lang="scss">
.ahome {
position: relative;
width: 100%;
height: 100%;
// background-image: url("../image/back.png");
background-color: #091a36;
background-position: center center;
background-size: 100% 100%;
overflow: hidden;
.home-content {
height: 95vh;
margin-top: 5vh;
.equipment {
color: #0cd9e1;
height: 92vh;
display: flex;
padding: 0 20px 20px 20px;
// background: linear-gradient(90deg, #142964, #0d396940, #283e7a);
.equipContent {
width: 96%;
height: 96%;
margin: 0 auto;
display: flex;
justify-content: space-between;
.equipLeft {
flex: 1;
border: 2px solid #2056bc;
border-radius: 10px;
.list {
font-size: 24px;
font-family: PingFangSC;
font-weight: 600;
line-height: 1.8;
color: rgb(22, 174, 255);
margin-left: 6px;
}
.treelist {
height: 50%;
::v-deep .el-tree {
background: transparent;
color: #fff;
width: 98%;
.el-tree-node {
margin: 6px 0;
.el-tree-node__content {
height: 25px !important;
background: linear-gradient(86deg,
#2056bc 0%,
rgba(32, 86, 188, 0.2) 100%) !important;
}
}
}
}
.online {
height: 50%;
}
}
.equipCenter {
flex: 4;
border: 1px solid rgb(67, 85, 110);
border: 2px solid #2056bc;
border-radius: 10px;
margin: 0 10px;
padding: 10px;
.videoShowBox {
height: 86%;
background: #000;
border-bottom: 1px solid rgb(101, 95, 37);
border-right: 1px solid rgb(101, 95, 37);
.rowOne {
height: 100%;
.col {
border-top: 1px solid rgb(101, 95, 37);
border-left: 1px solid rgb(101, 95, 37);
width: 100%;
height: 100%;
}
}
.rowTwo {
height: 50%;
.col {
border-top: 1px solid rgb(101, 95, 37);
border-left: 1px solid rgb(101, 95, 37);
width: 100%;
height: 100%;
video {
width: 100% !important;
height: 100% !important;
}
}
}
.rowThree {
height: 33.33%;
.col {
border-top: 1px solid rgb(101, 95, 37);
border-left: 1px solid rgb(101, 95, 37);
width: 100%;
height: 100%;
video {
width: 100% !important;
height: 100% !important;
}
}
}
.rowFour {
height: 25%;
.col {
border-top: 1px solid rgb(101, 95, 37);
border-left: 1px solid rgb(101, 95, 37);
width: 100%;
height: 100%;
video {
width: 100% !important;
height: 100% !important;
}
}
}
}
.selectCard {
width: 100%;
height: 15%;
display: flex;
justify-content: space-evenly;
align-items: center;
.pic {
width: 16%;
padding: 18px;
text-align: center;
font-size: 20px;
font-weight: lighter;
cursor: pointer;
}
.pic1,
.pic2,
.pic3 {
box-shadow: inset 0 0 10px #00d0ff;
border-radius: 6px;
background-size: 100% 100%;
}
.pic1:hover,
.pic2:hover,
.pic3:hover {
color: rgb(22, 174, 255);
box-shadow: inset 0 0 10px #6586a9;
}
}
}
}
}
}
}
.video-item {
width: 100% !important;
height: 100% !important;
video {
width: 100% !important;
height: 100% !important;
}
}
</style>