Vue实现摄像头视频分屏, 使用flv.js接收rtmp/flv视频流

一、业务需求和调研

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>

猜你喜欢

转载自blog.csdn.net/hero8011/article/details/135238817