一、业务需求和调研
1. 现有的平台系统播放实时视频。
因为用户电脑都是Linux系统,无法直接使用海康前端SDK,讨论决定由后台推视频流,简单调研后发现最流行的是flv,而且有B站开源的flv.js适配。前期后台推给我RTMP前缀的视频流,我尝试使用video.js,西瓜视频等都失败了,后来后端改为http前缀的,对接成功。这里还要讲一下flv.js的文档, 不知道是我理解有误, 还是文档没有更新, 还是让人一身冷汗的:
第二句讲: FLV实时流在所有浏览器无法工作
但是点进去livestream.md:
这里又讲: 根据IO限制, flv.js目前在各类新版浏览器支持HTTP FLV实时流
总而言之,即便是chrome已经不支持flash,但是用B站这款flv.js还是可以实现在现代浏览器播放HTTP FLV视频流的。
2. 分屏,先点击分屏,然后选择需要播放的视频设备,在该分屏播放对应的视频流。
3. 开启新的视频的同时,以及离开本页面时要关闭之前的视频流,以减轻服务器压力。这一点跟主流需求还是很不同的,因为通常都会理解为在分屏可以同时观看多个摄像头的实时画面,所以即使我已经实现了需求,但还是感觉分屏在这里是有些鸡肋的。
二、实现效果
这里展示4屏和6屏,1屏就不用展示了,下面代码中还有9屏和16屏可选,目前我这里用不到,就先注释掉了。
三、鸣谢
感谢二位大佬的解决方案,这是我实现本业务需求的基础:
ID: 抄一下你代码
全网最详细!vue中使用flv.js 播放直播监控视频流
ID: 三体人1379号
vue实现视频播放1,4,6,9,16宫格布局
四、代码实现
1. 子组件, 也就是视频播放器,您也可以根据不同的视频流资源配置不同的播放器:
<template> <div :class="{ player: true, selected: isSelected }" @click="handlePlayerClick"> <!-- {{ title }}号窗口 --> <video class="cell-player-1" ref="videosmallone" preload="auto" muted controls autoplay type="rtmp/flv" > <source src="" /> </video> </div> </template> <script> import flvjs from 'flv.js' export default { props: { title: { type: Number, default: 1 }, activePlayer: { type: Number, default: null } }, data() { return { player: null, loading: false, videoUrl: '', videoToken: '' } }, beforeUnmount() { if (this.player) { this.player.pause() this.player.unload() this.player.detachMediaElement() this.player.destroy() this.player = null } }, computed: { // Use a computed property to determine if the player is active isSelected() { return this.activePlayer === this.title }, playerClass() { return ['player', `cell-player-1`, { active: this.title === this.activePlayer }] } }, methods: { handlePlayerClick() { // 在点击事件中调用父组件的方法,传递数据 this.$emit('playerClick', this.title) // console.log('class', this.playerClass) }, openVideo(data) { // Implement this method to update the data in the player component // Use the passed data to update the player's state or perform other operations // console.log(`Setting data for player ${this.title}:`, data) this.init(data.data.url) }, init(val) { //这个val 就是一个地址,例如: http://192.168.2.201:85/live/9311272c49b845baa2b2810ad9bf3f68.flv 这是个服务器返回给我的一个监控视频流地址 setTimeout(() => { //使用定时器是因为,在mounted声明周期里调用,可能会出现DOM没加载出来的原因 var videoElement = this.$refs.videosmallone // 获取到html中的video标签 if (flvjs.isSupported()) { //因为我这个是复用组件,进来先判断 player是否存在,如果存在,销毁掉它,不然会占用TCP名额 if (this.player !== null) { this.player.pause() this.player.unload() this.player.detachMediaElement() this.player.destroy() this.player = null } this.player = flvjs.createPlayer( //创建直播流,加载到DOM中去 { type: 'flv', url: val, //你的url地址 isLive: true, //数据源是否为直播流 hasAudio: false, //数据源是否包含有音频 hasVideo: true, //数据源是否包含有视频 enableStashBuffer: true //是否启用缓存区 }, { enableWorker: false, //不启用分离线程 enableStashBuffer: false, //关闭IO隐藏缓冲区 autoCleanupSourceBuffer: true, //自动清除缓存 lazyLoad: false } ) this.player.attachMediaElement(videoElement) //放到dom中去 this.player.load() //准备完成 //!!!!!!这里需要注意,有的时候load加载完成不一定可以播放,要是播放不成功,用settimeout 给下面的this.player.play() 延时几百毫秒再播放 this.player.play() //播放 } }, 1000) } } } </script> <style scoped> .player { background-color: black; height: 100%; border: 1px solid grey; color: white; text-align: center; } .selected { background-color: black; height: 100%; border: 2px solid green; color: white; text-align: center; } .cell-player-1 { width: 100%; height: 100%; box-sizing: border-box; } </style>
复制
2. 父组件结构:
<template> <div style="height: 100%"> <a-form layout="inline" class="header"> <a-form-item> <div class="cell-tool"> <div class="bk-button-group"> <a-button :class="{ active: cellCount === 1 }" @click="cellCount = 1" style="margin-right: 5px" >1屏</a-button > <a-button :class="{ active: cellCount === 4 }" @click="cellCount = 4" style="margin-right: 5px" >4屏</a-button > <a-button :class="{ active: cellCount === 6 }" @click="cellCount = 6" style="margin-right: 5px" >6屏</a-button > <!-- <button @click="cellCount = 9" size="small">9</button> <button @click="cellCount = 16" size="small">16</button> --> </div> </div> </a-form-item> <a-form-item label="选择设备:"> <a-tree-select v-model="value" style="width: 200px" :dropdown-style="{ maxHeight: '600px', overflow: 'auto' }" :tree-data="treeData" placeholder="请选择设备" :treeDefaultExpandAll="true" > </a-tree-select> </a-form-item> <a-form-item> <div style="display: inline-block"> <SavaButton type="search" @click="playRealtimeVideo">播放</SavaButton> <SavaButton type="delete" @click="resetSearchForm()" style="margin-left: 8px" >重置</SavaButton > </div> </a-form-item> </a-form> <div class="main-body"> <div class="left"> <div class="left-upper"></div> <div class="left-lower"></div> </div> <div class="right"> <!-- 然后在这里添加分屏的布局 --> <div class="cell"> <div class="cell-player"> <div :class="cellClass(i)" v-for="i in cellCount" :key="i"> <player :title="i" @playerClick="handlePlayerClick" v-if="cellCount != 6" :activePlayer="activePlayer" :ref="`player${i}`" ></player> <player :title="i" @playerClick="handlePlayerClick" v-if="cellCount == 6 && i != 2 && i != 3" :activePlayer="activePlayer" :ref="`player${i}`" ></player> <template v-if="cellCount == 6 && i == 2"> <div class="cell-player-6-2-cell"> <player :title="i" @playerClick="handlePlayerClick" :activePlayer="activePlayer" :ref="`player${i}`" ></player> <!-- original config is ++i --> <player :title="i + 1" @playerClick="handlePlayerClick" :activePlayer="activePlayer" :ref="`player${i + 1}`" ></player> </div> </template> </div> </div> </div> <div class="right-lower"></div> </div> </div> </div> </template>
复制
3. 核心业务逻辑:
<script> import player from './player/player.vue' import { reqStationAndCamera, reqGetRealtimeVideo, reqCloseVideo1 } from '@/api/camera' export default { components: { player }, data() { return { queryParam: { id: '' }, cellCount: 1, value: '', treeData: [], activePlayer: 1, oldToken: '', // 保存已经开启视频的token, 用于关闭视频 oldTokensArray: [] } }, created() { this.getStationAndCamera() }, mounted() { // Add the beforeunload event listener when the component is mounted window.addEventListener('beforeunload', this.closeOldVideos) }, beforeUnmount() { // This method will be called before the component is unmounted or the page is unloaded this.closeOldVideos() // Remove the beforeunload event listener before the component is unmounted window.removeEventListener('beforeunload', this.closeOldVideos) }, watch: { value(value) { console.log(value) } }, methods: { changeScreen() { // 处理切换分屏的逻辑 }, // 这里是整理数据用于下拉框选择播放视频源的设备 getStationAndCamera() { reqStationAndCamera({ city: '', camera: 1 }).then((res) => { // 创建一个空数组用于存储treeData const treeData = [] // 遍历后台返回的数组 res.forEach((station) => { // 提取一级菜单的信息 const firstLevelNode = { title: station.stationName, value: station.id, key: `level1-${station.id}`, // 使用id作为key disabled: true, // 设置一级菜单为不可选 children: [] // 用于存储二级菜单 } // 遍历devices数组,提取二级菜单的信息 station.devices.forEach((device) => { const secondLevelNode = { title: device.deviceName, value: device.id, key: `level2-${device.id}` // 使用id作为key // 如果有三级菜单,可以在这里继续处理 } // 将二级菜单添加到一级菜单的children数组中 firstLevelNode.children.push(secondLevelNode) }) // 将一级菜单添加到treeData数组中 treeData.push(firstLevelNode) }) // 打印加工后的treeData console.log('Processed treeData:', treeData) this.treeData = treeData }) }, async playRealtimeVideo() { if (!this.value) { this.$message.error('请选择设备') // 中止程序,可以使用return或者throw语句,根据您的需求选择 return // 中止程序执行 } else { this.queryParam = { id: this.value } } // console.log('realtime video param', this.queryParam) const RealtimeVideoParams = this.queryParam const playerRef = `player${this.activePlayer}` // 使用 $refs 引用 player 组件实例 const playerInstance = this.$refs[playerRef] // console.log('playerInstance:', playerInstance) try { const res = await this.getRealtimeVideo(RealtimeVideoParams) // Check if 'res' is undefined or not if (res !== undefined) { console.log('new data res', res) this.$message.success('获取视频成功, 正在打开', 5) const newDataForClickedPlayer = res console.log('newDataForClickedPlayer:', newDataForClickedPlayer) if (playerInstance) { // Pass data to the newly clicked player playerInstance[0].openVideo(newDataForClickedPlayer) // Check if there was a previously clicked player if (this.activePlayer !== null) { // console.log('active player', this.activePlayer) // Perform any operations specific to the previously clicked player // playerInstance[0].closeVideo(historyVideoData) } } this.closeOldVideos() } this.oldToken = res.data.token } catch (error) { console.error('Error in play realtime video:', error) } }, resetSearchForm() { this.value = '' this.queryParam = { id: '' } }, getRealtimeVideo(queryParam) { return new Promise((resolve, reject) => { reqGetRealtimeVideo(queryParam) .then((res) => { console.log('realtime video', res) resolve(res) }) .catch((error) => { console.error('Error fetching realtime video:', error) reject(error) }) }) }, handlePlayerClick(title) { // console.log('clicked window', title) // Update the active player in the parent component this.activePlayer = title // console.log('active player', this.activePlayer) }, closeOldVideos() { if (this.oldToken) { this.oldTokensArray.push(this.oldToken) // Map old tokens array to an array of promises const closePromises = this.oldTokensArray.map((oldToken) => reqCloseVideo1(oldToken) .then((resc) => { console.log('close old video', resc) this.$message.warn('已关闭其他视频') }) .catch((e) => { console.log('close error', e) }) ) // Use Promise.all to wait for all promises to resolve Promise.all(closePromises) .then(() => { // All videos closed successfully console.log('All videos closed successfully') }) .catch((error) => { // Handle errors if any of the requests fail console.log('Error closing videos:', error) }) } } }, computed: { cellClass() { return function (index) { switch (this.cellCount) { case 1: return ['cell-player-1'] case 4: return ['cell-player-4'] case 6: if (index == 1) return ['cell-player-6-1'] if (index == 2) return ['cell-player-6-2'] if (index == 3) return ['cell-player-6-none'] return ['cell-player-6'] case 9: return ['cell-player-9'] case 16: return ['cell-player-16'] default: break } } } } } </script>
复制
4. 样式, 这里有些ant D穿透样式, 可以去掉:
<style lang="less" scoped> .header { background-color: #034d94; padding: 10px 25px; border-radius: 10px; } .main-body { width: 100%; height: 90%; display: flex; .right { width: 100%; height: 100%; .cell { margin-top: 0.5%; display: flex; flex-direction: column; height: 100%; } } } .bk-button-group .active { background-color: skyblue; color: #fff; /* Add any other styles for the active button */ } .cell-tool { height: 40px; line-height: 40px; margin-top: -1px; // padding: 0 7px; } .cell-player { flex: 1; display: flex; flex-wrap: wrap; justify-content: space-between; width: 100%; height: 100%; } .cell-player-4 { width: 50%; height: 50% !important; box-sizing: border-box; } .cell-player-1 { width: 100%; height: 100%; box-sizing: border-box; } .cell-player-6-1 { width: 66.66%; height: 66.66% !important; box-sizing: border-box; } .cell-player-6-2 { width: 33.33%; height: 66.66% !important; box-sizing: border-box; display: flex; flex-direction: column; } .cell-player-6-none { display: none; } .cell-player-6-2-cell { width: 100%; height: 50% !important; box-sizing: border-box; } .cell-player-6 { width: 33.33%; height: 33.33% !important; box-sizing: border-box; } .cell-player-9 { width: 33.33%; height: 33.33% !important; box-sizing: border-box; } .cell-player-16 { width: 25%; height: 25% !important; box-sizing: border-box; } .ant-select { width: 180px; } /deep/.ant-time-picker-input { background-color: #034d94; border: 1px solid rgba(255, 255, 255, 0.4); color: #fff; &::placeholder { color: #bfbfb5; } } /deep/ .ant-select-selection--single { background-color: #034d94; border: 1px solid rgba(255, 255, 255, 0.4); color: #fff; &::placeholder { color: #bfbfb5; } } /deep/ .ant-select-arrow { color: white; } /deep/.page-search-none { padding: 0; } /deep/.ant-svg { color: #fff; } /deep/.ant-time-picker-icon .ant-time-picker-clock-icon, .ant-time-picker-clear .ant-time-picker-clock-icon { color: #fff; } li.ant-select-tree-treenode-disabled > span:not(.ant-select-tree-switcher), li.ant-select-tree-treenode-disabled > .ant-select-tree-node-content-wrapper, li.ant-select-tree-treenode-disabled > .ant-select-tree-node-content-wrapper span { color: red !important; } </style>
复制