近些时间,在项目开发过程中,遇到与视频相关的内容,即前端播放直播流rtmp/rtsp/hls等。而针对这个问题,我们则需要使用videojs这个插件来进行开发,当然如果是使用了vue-cli的话,可以采用vue-video-player,这是对videojs做了进一步的封装,可以引入做组件使用。详细过程如下:

1. VIDEOJS: ERROR: The “flash” tech is undefined. Skipped browser support check for that tech

  顾名思义,就是你的插件里的flash找不到了,具体原因在排查过程中觉得可能是内外都有videojs-flash造成的。这个问题也很经典,网上解决方法五花八门,但真正能用的好像都没有。
  针对这个问题,首先要确保所有相关的插件都是使用npm安装而不是cnpm安装,因为cnpm安装 的插件可能会有奇奇怪怪的问题。确保了这个之后,如果是使用了vue-video-player来做开发的话,需要进行以下步骤:

  • 1. vue-video-player本身已内置了videojs-flash,以及video-contrib-hls这两个分别播放rtmp / m3u8的插件,所以安装 vue-video-player无需再另外安装这两个插件
  • 2. 除去第一步,在排除没有禁用网站flash的情况下,如果还报这个错误的话,可参考把node_modules,package-lock.json一起移除掉后,再 npm install
  • 3. 第二步尝试 1次之后如果无效,目前我的解决方法是移除掉所有跟videojs有关的插件,直接npm i vue-video-player@5.0.2 -S这个问题,目前我就是这样就解决了,具体原因推测为videojs-flash互相冲突,网上很多种方法都不一定有用,遇到这个问题一定要耐心多尝试几次,也许就能搞定了


2. 封装视频播放组件

  以下只是其中一个例子,主要用于播放rtmp流的直播视频,需要浏览器开启flash配合使用,具体代码如下:

<!--该组件除了默认的底部控制条外,监听了播放,暂停,结束事件,以及设置了顶部条的最大化和关闭视频-->
<template>
  <!--双击全屏,默认就是这样,这一步主要针对videojs没有配置控制条实现-->
  <div
    @dblclick="onVideoDBClick"
    class="rmtp-video-player"
    @mousemove="showTitleBar = true"
    @mouseleave="showTitleBar = false"
  >
    <div class="head-menu" :class="{showbar: showTitleBar}">
      <slot name="video-name"></slot>
      <div class="right-button">
        <!--这里阻止点击事件冒泡-->
        <i
          class="icon-maximize"
          @click.prevent="onMaximize"
          :title="Maximize ? '还原' : '最大化'"
          :class="Maximize ? 'el-icon-files' : 'el-icon-full-screen'"
        />
        <i class="el-icon-close" @click.prevent="onClose" title="关闭" />
      </div>
    </div>
    <video-player
      class="video-player vjs-custom-skin"
      ref="videoPlayer"
      :playsinline="true"
      :options="videoParams"
      @play="onPlayerPlay"
      @pause="onPlayerPause"
      @ended="onPlayerEnded"
    ></video-player>
  </div>
</template>

<script>
  import 'video.js/dist/video-js.css';
  import 'videojs-flash';
  import 'videojs-contrib-hls';
  import {videoPlayer} from 'vue-video-player';
  export default {
    props: {
      videoOptions: [Object], //视频播放选项
      videoUrl: {
        //视频地址
        type: String,
        default: '',
      },
      videoPoster: {
        //视频封面
        type: String,
        default: '',
      },
      maximize: {
        // 是否开启了最大化
        type: Boolean,
        default: false,
      },
    },
    components: {
      // 引入videoplayer
      videoPlayer,
    },
    computed: {
      videoParams: {
        //保持参数即使没传也有默认的结构
        get() {
          return Object.assign(
            {},
            {
              playbackRates: [0.7, 1.0, 1.5, 2.0], // 播放速度
              autoplay: false, // 如果true,浏览器准备好时开始回放。
              muted: false, // 默认情况下将会消除任何音频。
              loop: false, // 导致视频一结束就重新开始。
              preload: 'auto', // 建议浏览器在<video>加载元素后是否应该开始下载视频数据。auto浏览器选择最佳行为,立即开始加载视频(如果浏览器支持)
              language: 'zh-CN',
              sources: [
                {
                  withCredentials: false,
                  type: 'rtmp/flv', // 这里的种类支持很多种:基本视频格式、直播、流媒体等,具体可以参看git网址项目
                  src: '', // url地址
                },
                {
                  withCredentials: false,
                  type: 'application/x-mpegURL',
                  src: '',
                },
              ],
              flash: {hls: {withCredentials: false}},
              html5: {hls: {withCredentials: false}},
              techOrder: ['flash', 'html5'], // 播放rtmp必须加
              poster:
                'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1595909771037&di=e0b6bc4c8a5a0edd7b3e1389165d713e&imgtype=0&src=http%3A%2F%2Fimgsrc.baidu.com%2Fforum%2Fw%3D580%2Fsign%3Dd624a0aa2df5e0feee1889096c6134e5%2Fbb219f0e7bec54e710eab5adb9389b504fc26a19.jpg', // 你的封面地址
              notSupportedMessage: '此视频暂无法播放,请稍后再试', // 允许覆盖Video.js无法播放媒体源时显示的默认信息。
              controlBar: {
                timeDivider: true,
                durationDisplay: true,
                remainingTimeDisplay: false,
                fullscreenToggle: true, // 全屏按钮
              },
            },
            this.videoOptions,
            {
              sources: [
                {
                  withCredentials: false,
                  type: 'rtmp/flv', // 这里的种类支持很多种:基本视频格式、直播、流媒体等,具体可以参看git网址项目
                  src: this.videoUrl, // url地址
                },
                {
                  withCredentials: false,
                  type: 'application/x-mpegURL',
                  src: this.videoUrl,
                },
              ],
            }
          );
        },
        set() {},
      },
    },
    data() {
      return {
        showTitleBar: false,
        Maximize: this.maximize,
      };
    },
    methods: {
      onPlayerPlay(e) {
        // 播放器开始播放事件,目前仅将player对象弹出给父组件
        this.showTitleBar = false;
        this.$emit('play', e);
      },

      onPlayerPause(e) {
        // 播放器暂停播放事件,同上
        this.showTitleBar = true;
        this.$emit('pause', e);
      },

      onPlayerEnded(e) {
        // 直播流没有结束的概念,留着备用
        this.$emit('end', e);
      },

      onVideoDBClick() {
        // 双击全屏
        if (!this.$refs.videoPlayer.player.isFullscreen()) {
          this.$refs.videoPlayer.player.requestFullscreen();
        }
      },

      onMaximize() {
        // 视频单个最大化,占满整个弹窗,类似于视频网站的网页全屏
        this.Maximize = !this.Maximize;
        this.$emit('maximize', this.Maximize);
      },

      onClose() {
        // 关闭视频事件
        this.$emit('close');
      },
    },
  };
</script>

<style lang="less">
  .rmtp-video-player {
    position: relative;
    .head-menu {
      position: absolute;
      width: 100%;
      color: #fff;
      top: 0;
      left: 0;
      right: 0;
      height: 25px;
      background-color: rgba(43, 51, 63, 0.7);
      z-index: 9;
      opacity: 0;
      &.showbar {
        //出现顶部条
        opacity: 1;
        transition: 0.5s;
      }
      .right-button {
        position: absolute;
        right: 5px;
        top: 2px;
        i {
          cursor: pointer;
          margin: 0 5px;
        }
        z-index: 3;
      }
      transition: 5s; //模仿底部条缓慢消失
      .app-title {
        display: inline-block;
        width: 100%;
      }
    }
    .video-player {
      cursor: pointer;
      .video-js {
        width: 100%;
      }
      .vjs-big-play-button {
        left: 0;
        right: 0;
        bottom: 0;
        top: 0;
        width: 1.5em;
        border-radius: 50%;
        margin: auto;
      }
    }
  }
</style>

  以上就是一个简单的视频播放组件的封装,是在vue-video-player的基础上进一步封装一些跟业务相关的处理逻辑,目前还在更新中,暂定为以上例子。由于对该插件还不是很熟悉,上述例子目前仅支持离线的rtmp播放。


3. 编写视频播放界面

  这里主要是视频播放列表比较重要,弹窗部分就忽略了,可使用element-ui提供的el-dialogs或自己另外封装一个可以实现最大化最小化功能的弹窗都可,视频播放列表由于采用的是弹性布局的样式,必须从外到内都是弹性布局,省略外部弹窗的样式代码,具体如下:

<!--左边是带过滤搜索框的树,右边0为网格视频列表,1为普通缩略图播放列表-->
<template>
  <div class="demo-video-all">
    <div class="left" :class="{rightMaximize: maximize}">
      <app-tree
        ref="tree"
        :show-checkbox="active==='1'"
        default-expand-all
        :check-on-click-node="active==='1'"
        :data="videoBody"
        :default-checked-filter="() => true"
        :default-checked-keys="checkedKeys"
        node-key="id"
        :props="treeProps"
        :expand-on-click-node="false"
        @check-change="handleCheckChanged"
      />
    </div>
    <!--以下视频的最大化均是通过绝对定位完成,如遇到弹性布局的,设置not选择器把其他的都隐藏掉,最大化那个绝对定位铺满弹窗即可,不可丢弃弹性布局-->
    <div class="right">
      <div class="right-option">
        <i
          class="el-icon-s-grid"
          :class="{active: active == '0'}"
          @click="active = '0'"
          title="列表"
        ></i>
        <i
          class="el-icon-picture"
          :class="{active: active == '1'}"
          @click="active = '1'"
          title="缩略图"
        ></i>
      </div>
      <template v-if="active==='1'">
        <template v-if="checkedList[0]">
          <div
            class="first-of-all"
            :class="{maximize: checkedList[0].maximize, someOneMaximize: someOneMaximize}"
          >
            <rtmp-player
              :videoUrl="checkedList[0].uri"
              @play="onPlay($event,checkedList[0].id)"
              @maximize="onMaximize($event, checkedList[0].id)"
              @close="onClose(checkedList[0].id)"
              :ref="`player-${checkedList[0].id}`"
            >
              <template slot="video-name">
                <app-title
                  :value="checkedList[0].name"
                  :italic="false"
                  :size="16"
                  textAlign="center"
                />
              </template>
            </rtmp-player>
          </div>
        </template>
        <template v-if="checkedList.length > 1">
          <div class="is-going-to-play" :class="{maximize: someOneMaximize}">
            <rtmp-player
              v-for="item in checkedList.slice(1)"
              :key="item.id"
              :videoUrl="item.uri"
              @play="onPlay($event, item.id)"
              @maximize="onMaximize($event, item.id)"
              @close="onClose(item.id)"
              :class="{'player-maximize': item.maximize}"
              :ref="`player-${item.id}`"
            >
              <template slot="video-name">
                <app-title
                  :value="item.name"
                  :italic="false"
                  :size="14"
                  textAlign="center"
                />
              </template>
            </rtmp-player>
          </div>
        </template>
      </template>
      <!--以下网格化视频列表分页,是通过弹性布局加浮动合在一起完成-->
      <template v-if="active==='0'">
        <div class="demo-video-page">
          <div class="demo-video-list-page">
            <template v-for="item in videoListBody">
              <rtmp-player :videoUrl="item.uri" :key="item.id">
                <template slot="video-name">
                  <app-title
                    :value="item.name"
                    :italic="false"
                    :size="16"
                    textAlign="center"
                  />
                </template>
              </rtmp-player>
            </template>
          </div>
          <!--分页清除浮动,固定在弹窗底部-->
          <el-pagination
            @size-change="handleSizeChange"
            @current-change="handleCurrentChange"
            :page-sizes="[12, 24, 60, 120]"
            :page-size="listPagination.pagesize"
            :current-page="listPagination.pageindex"
            layout="total, sizes, prev, pager, next, jumper"
            :total="total"
            class="pagination"
          >
          </el-pagination>
        </div>
      </template>
    </div>
  </div>
</template>

<script>
  import axios from '@/axios/request';
  import rtmpPlayer from '@/components/rtmp-player';
  export default {
    components: {
      rtmpPlayer,
    },
    props: {
      resizeBody: [Object],
      minimize: {
        type: Boolean,
        default: false,
      },
    },
    data() {
      return {
        loading: false,
        //树的 结点数据
        videoBody: [],
        //视频分页列表
        videoListBody: [],
        listPagination: {
          pageindex: 1,
          pagesize: 12,
        },
        //勾选的结点key
        checkedKeys: [],
        checkedList: [],
        pagination: {
          pageindex: 1,
          pagesize: 9999,
        },
        total: 0,
        treeProps: {
          label: 'name',
          children: 'children',
        },
        // 是否有某个视频最大化
        maximize: false,
        //缩略图还是网格
        active: '0',
      };
    },
    computed: {
      // 底部列表是否有 某个视频最大化
      someOneMaximize() {
        return this.checkedList.slice(1).some(item => item.maximize);
      },
    },
    created() {
      this.getVideoList();
      this.getVideoListPage();
    },
    methods: {
      getVideoList() {
        /**
         *    这里是左边的树结点数据的请求接口
         * */
        //   this.videoBody = response.data.item.list;
      },

      getVideoListPage() {
        /**
         *    这里是分页请求视频列表的接口请求
         * */
        //   this.total = response.data.total;
        //   this.videoListBody = response.data.list; // 视频列表
      },
      // 监听树节点的勾选
      handleCheckChanged(node, checked) {
        if (checked) {
          if (!this.checkedKeys.includes(node.id)) {
            this.checkedKeys.push(node.id);
            this.checkedList.push(Object.assign({}, node, {maximize: false}));
          }
        } else {
          this.checkedKeys.splice(
            this.checkedKeys.findIndex(id => id === node.id),
            1
          );
          this.checkedList.splice(
            this.checkedList.findIndex(Node => Node.id === node.id),
            1
          );
        }
      },
      // 视频播放
      onPlay(player, id) {
        const playIndex = this.checkedList.findIndex(Node => Node.id === id);
        if (playIndex > 0) {
          const temp = this.checkedList[0];
          this.$set(this.checkedList, 0, this.checkedList[playIndex]);
          this.$set(this.checkedList, playIndex, temp);
        }
      },

      // 关闭视频,顺便取消树的勾选
      onClose(id) {
        const anotherIndex = this.checkedList.findIndex(Node => Node.id === id);
        const removeKeys = this.checkedList[anotherIndex];
        this.maximize = false;
        this.$nextTick(() => {
          this.$refs.tree.$children[1].setChecked(removeKeys, false);
        });
      },

      //最大化某个视频
      onMaximize(maximize, id) {
        const index = this.checkedList.findIndex(Node => Node.id === id);
        this.checkedList[index].maximize = maximize;
        this.maximize = maximize;
      },

      // 分页页数
      handleSizeChange(val) {
        this.listPagination.pagesize = val;
        this.getVideoListPage();
      },

      // 分页当前页
      handleCurrentChange(val) {
        this.listPagination.pageindex = val;
        this.getVideoListPage();
      },
    },
  };
</script>

<style lang="less">
  .demo-video-all {
    padding: 10px;
    flex: 1;
    display: flex;
    overflow: hidden;
    .el-tree {
      margin: 10px 0;
      background: transparent;
    }
    .el-tree-node__label {
      color: rgba(255, 255, 255, 0.8);
    }
    .el-tree-node__content:hover {
      background: rgba(0, 84, 160, 0.5) !important;
    }
    .el-tree-node.is-current .el-tree-node__content {
      background: transparent;
    }
    .app-tree__search .el-input__inner {
      background-color: transparent;
      width: 98%;
      border-radius: 0;
      line-height: 30px;
      border-color: rgba(255, 255, 255, 0.3);
      color: rgba(255, 255, 255, 0.8);
      &:focus {
        border-color: rgba(0, 84, 160, 0.8);
        box-shadow: 0 0 10px 3px rgba(0, 84, 160, 0.3);
      }
    }
    .left {
      width: 200px;
      border-right: 1px solid rgba(0, 84, 160, 0.8);
      padding: 0 15px 0 20px;
      opacity: 1;
      transition: 0.5s;
      &.rightMaximize {
        opacity: 0;
      }
      .el-tree {
        overflow-y: hidden;
        max-height: 600px;
        padding-right: 18px;

        &:hover {
          overflow-y: auto;
        }
      }
    }
    ::-webkit-scrollbar {
      width: 5px;
      height: 5px;
    }

    ::-webkit-scrollbar-thumb {
      border-radius: 8px;
      background: rgba(0, 84, 160, 0.8);
    }

    ::-webkit-scrollbar-track {
      border-radius: 8px;
    }

    .right {
      flex: 1;
      display: flex;
      flex-direction: column;
      overflow: hidden;
      .first-of-all {
        width: 65%;
        height: 100%;
        margin: 0 auto;
        display: flex;
        flex-direction: column;
        .rmtp-video-player {
          display: flex;
          flex-direction: column;
          flex: 1;
          .video-player {
            display: flex;
            flex-direction: column;
            flex: 1;
            .video-js {
              width: 100%;
              flex: 1;
              padding-top: 45%;
            }
          }
        }
        &.maximize {
          width: 100%;
          position: absolute;
          top: 0;
          left: 0;
          bottom: 0;
          right: 0;
          z-index: 9999;
          .rmtp-video-player {
            .video-player {
              .video-js {
                padding-top: 0;
              }
            }
          }
        }
        &.someOneMaximize {
          opacity: 0;
        }
        opacity: 1;
        transition: 0.5s;
      }
      .is-going-to-play {
        width: 100%;
        overflow: auto hidden;
        height: 250px;
        margin: 20px 10px;
        display: flex;
        flex-flow: row nowrap;
        &.maximize {
          flex: 1;
          width: 100%;
          height: 100%;
          position: absolute;
          left: -10px;
          top: -20px;
          z-index: 9999;
          overflow: hidden;
          transition: 0.5s;
          .rmtp-video-player {
            position: absolute;
            left: 0;
            margin: 0;
            padding: 0;
            right: 0;
            width: 100%;
            height: 100%;
            &:not(.player-maximize) {
              visibility: hidden;
              opacity: 0;
            }
            .video-player {
              display: flex;
              flex-direction: column;
              flex: 1;
              .video-js {
                padding-top: 0;
              }
            }
          }
        }
        .rmtp-video-player {
          flex: 0 0 250px;
          margin: 0 10px;
          display: flex;
          flex-direction: column;
          transition: 0.5s;
          .video-player {
            display: flex;
            flex-direction: column;
            flex: 1;
            .video-js {
              width: 100%;
              flex: 1;
            }
          }
        }
      }
      .right-option {
        position: absolute;
        right: 0;
        top: 0;
        z-index: 999;
        border-left: solid 1px #409eff;
        border-bottom: solid 1px #409eff;
        border-radius: 0 0 0 8px;
        width: 30px;
        i {
          display: inline-block;
          margin: 0 5px;
          font-size: 18px;
          line-height: 26px;
          cursor: pointer;
          &:hover {
            color: #1ccdcc;
          }
          &.active {
            color: #1ccdcc;
          }
        }
      }
      .demo-video-page {
        flex: 1;
        display: flex;
        flex-direction: column;
        overflow: hidden;
        .demo-video-list-page {
          flex: 1;
          overflow: hidden auto;
          .rmtp-video-player {
            float: left;
            position: relative;
            width: 300px;
            margin: 5px 12px;
            .head-menu {
              i {
                display: none;
              }
            }
            .video-player {
              display: flex;
              flex-flow: column wrap;
              .video-js {
                width: 100%;
                flex: 0 0 150px;
              }
            }
          }
        }

        .el-pagination {
          border-top: solid 1px rgba(0, 145, 202, 0.8);
          padding-bottom: 10px;
        }

        .pagination {
          text-align: center;
          border-top: none !important;
          margin: 5px auto;
          clear: both;
          display: block;
          width: 100%;
        }
      }
    }
  }
</style>

  目前暂定如此,有两种模式来切换播放视频,网格化暂定只是普通播放,不能最大化和关闭且个跟树的结点断开关联;缩略图式播放目前为在播视频为大视频方式播放,待播放视频为一个横向列表排在其下方,点击播放时与大视频交换,大视频播放,交换后小视频暂停。
  目前除了兼容多种格式,剩下的就是这个问题还未解决,交换后两个视频都被初始化了,无法js强制执行播放和暂停方法。(待解决)

  至于其他的功能,紧跟后续需求变动而更新

————未完待续,敬请期待————

  • alipay
  • wechat

一个好奇的人