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>