一、业务需求和调研
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>