使用 Plyr 搭建一个多视频播放大屏

场景1:

想实现一个铺满视频的大屏,当获取到视频地址后,所有视频自动播放并且可以循环播放,在横屏大屏时展示5*3的数量,在竖屏大屏时展示3*3的数量,在每个视频下半部分展示一下文本或者图标(内容可自定义)

横屏效果和竖屏效果如下:

<template>
  <div class="video-grid" :style="videoGridStyle">
    <div v-for="(video, index) in videoList" :key="index" class="video-container">
      <video :id="`player-${index}`" class="video-item" :poster="video.cover" playsinline muted loop autoplay></video>
      <div class="bottom-shadow">
        <div class="col-name-div">视频{{ index + 1 }}</div>
      </div>
    </div>
  </div>
</template>

在下面的js部分代码中使用到的 sliceVideoList() 方法是因为我要实现这样的需求场景:

在横屏下只展示15个视频,在竖屏下只展示9个视频,至于我为什么请求200个数据这里是具体的需求不过多赘述。

<script setup>
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from 'vue'
import Plyr from 'plyr'
import Hls from 'hls.js'
import 'plyr/dist/plyr.css'
import { getChartData } from "@/request/chart.js";

const videoList = ref([])
const fullList = ref([])
const players = []

const setupVideoPlayer = (videoEl, videoUrl) => {
  const player = new Plyr(videoEl, {
    iconUrl: '',
    controls: ['play', 'fullscreen'],
    settings: [],
    fullscreen: {
      enabled: true,
      fallback: true,
      iosNative: true
    }
  })

  // 如果浏览器不支持在video标签中设置播放参数,可以在这里设置下面这三行内容
  // videoEl.loop = true
  // videoEl.autoplay = true
  // videoEl.muted = true

  const urlObj = new URL(videoUrl)
  if (urlObj.pathname.endsWith('.m3u8')) {
    if (Hls.isSupported()) {
      const hls = new Hls()
      hls.loadSource(videoUrl)
      hls.attachMedia(videoEl)

      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        console.log('HLS 准备完成')
        // player.play()
      })

      hls.on(Hls.Events.ERROR, (event, data) => {
        console.error('HLS 错误:', data)
      })
    } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
      videoEl.src = videoUrl
      videoEl.addEventListener('loadeddata', () => {
        // player.play()
      })
    } else {
      console.error('不支持 HLS 播放')
    }
  } else {
    videoEl.src = videoUrl
    videoEl.addEventListener('loadeddata', () => {
      // player.play()
    })
  }

  return player
}

const initPlayers = async () => {
  await nextTick()
  players.length = 0

  videoList.value.forEach((video, index) => {
    const videoEl = document.getElementById(`player-${index}`)
    if (!videoEl) return

    const player = setupVideoPlayer(videoEl, video.url)
    players.push(player)
  })
}

const getPlayers = async () => {
  const formData = {
    limit: 200
  }

  getChartData(formData).then((res) => {
    // 此处省略获取接口数据的代码...
    fullList.value = res.data.list
    refresh()
  }).catch((err) => {
    console.log(err)
  })
}

const videoGridStyle = ref({})

const refresh = async () => {
  sliceVideoList()
  calcLayout()
  await nextTick()
  await initPlayers()
}

const sliceVideoList = () => {
  const screenW = window.innerWidth
  const screenH = window.innerHeight
  const isLandscape = screenW >= screenH
  const count = isLandscape ? 15 : 9
  videoList.value = fullList.value.slice(0, count)
}

const calcLayout = () => {
  const screenW = window.innerWidth
  const screenH = window.innerHeight
  const gap = 8
  const padding = 16
  const ratio = 9 / 16
  const isLandscape = screenW >= screenH
  const rows = 3
  const cols = isLandscape ? 5 : 3

  const maxW = screenW - padding - gap * (cols - 1)
  const maxH = screenH - padding - gap * (rows - 1)

  let videoW = maxW / cols
  let videoH = videoW / ratio

  if (videoH * rows > maxH) {
    videoH = maxH / rows
    videoW = videoH * ratio
  }

  videoGridStyle.value = {
    gridTemplateColumns: `repeat(${cols}, ${videoW}px)`,
    gridTemplateRows: `repeat(${rows}, ${videoH}px)`,
    justifyContent: 'center',
    alignContent: 'center',
    width: '100vw',
    height: '100vh',
    overflow: 'hidden'
  }
}

onMounted(() => {
  getPlayers()
  window.addEventListener('resize', refresh)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', refresh)
})
</script>
<style scoped>
.video-grid {
  display: grid;
  gap: 8px;
  padding: 8px;
  box-sizing: border-box;
  background-color: #fff;
  overflow: hidden;
}

.video-item {
  width: 100%;
  height: 100%;
  object-fit: cover;
  background-color: #fff;
}

.video-container {
  position: relative;
  overflow: hidden;
}

::v-deep(.plyr) {
  width: 100% !important;
  height: 100% !important;
  max-width: 100% !important;
  min-width: 0 !important;
  min-height: 0 !important;
}

::v-deep(.plyr__video-wrapper) {
  width: 100% !important;
  height: 100% !important;
}

.bottom-shadow {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  background: linear-gradient(
      to top,
      rgba(0, 0, 0, 1.0) 0%,
      rgba(0, 0, 0, 0.6) 30%,
      rgba(0, 0, 0, 0.3) 60%,
      rgba(0, 0, 0, 0) 100%
  );
  color: white;
  padding: 8px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

.col-name-div {
  width: 90%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bottom-shadow-img {
  display: flex;
  flex-wrap: wrap;
  margin-top: 6px;
  gap: 4px;
}

.icon-wrapper {
  position: relative;
}

.platform-logo {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.icon-corner {
  position: absolute;
  right: -6px;
  bottom: -6px;
  font-size: .8vw;
  border-radius: 50%;
}

.icon-status {
  position: absolute;
  right: -6px;
  bottom: -6px;
  font-size: .8vw;
  z-index: 2;
  fill: #fff;
}

/* 竖屏 */
@media (orientation: portrait) {
  .bottom-shadow {
    font-size: 3vw;
    min-height: 20%;
  }
  .icon-wrapper {
    width: 4.4vw;
    height: 4.4vw;
  }
  .icon-corner {
    position: absolute;
    right: -6px;
    bottom: -6px;
    font-size: 2.4vw;
    border-radius: 50%;
  }
  .icon-status {
    position: absolute;
    right: -6px;
    bottom: -6px;
    font-size: 2.4vw;
    z-index: 2;
    fill: #fff;
  }
}

/* 横屏 */
@media (orientation: landscape) {
  .bottom-shadow {
    font-size: 1.1vw;
    min-height: 24%;
  }
  .icon-wrapper {
    width: 1.5vw;
    height: 1.5vw;
  }
  .icon-corner {
    position: absolute;
    right: -6px;
    bottom: -6px;
    font-size: 1vw;
    border-radius: 50%;
  }
  .icon-status {
    position: absolute;
    right: -6px;
    bottom: -6px;
    font-size: 1vw;
    z-index: 2;
    fill: #fff;
  }
}

.mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.4);
  z-index: 1;
}
</style>

场景2:

因为网络带宽等等的问题,自动播放所有视频的处理不够优雅,因此有了新的需求场景,每行默认播放第一个视频,播放结束后自动播放下一个,直到每行各自播放完最后一个视频后,再循环到第一个视频进行播放。

需要想到的问题是:

  • 每行的轮播都是独立的;
  • 这里利用 Plyr 的 .on('ended', fn) 监听播放完成事件;
  • 用 Map<rowIndex, index> 记录每行当前播放的索引;
  • 竖屏和横屏每行展示数量不同,需要进行计算区分。

html 部分只需改动 video 设置的几个属性,去掉 loop 和 autoplay:

<video :id="`player-${index}`" class="video-item" :poster="video.cover" playsinline muted></video>
<script setup>
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from 'vue'
import Plyr from 'plyr'
import Hls from 'hls.js'
import 'plyr/dist/plyr.css'
import { getChartData } from "@/request/chart.js";

const videoList = ref([])
const fullList = ref([])
const players = []
const currentPlayingIndexByRow = new Map()

const setupVideoPlayer = (videoEl, videoUrl) => {
  const player = new Plyr(videoEl, {
    iconUrl: '',
    controls: ['play', 'fullscreen'],
    settings: [],
    fullscreen: {
      enabled: true,
      fallback: true,
      iosNative: true
    }
  })

  videoEl.muted = true
  videoEl.playsInline = true
  videoEl.loop = false
  videoEl.autoplay = false

  if (videoUrl.endsWith('.m3u8') && Hls.isSupported()) {
    const hls = new Hls()
    hls.loadSource(videoUrl)
    hls.attachMedia(videoEl)
  } else {
    videoEl.src = videoUrl
  }

  return player
}

// 播放指定 index 的视频,并暂停同一行其他的
const playVideoInRow = (rowIndex, videoIndexInRow) => {
  const isLandscape = window.innerWidth >= window.innerHeight
  const cols = isLandscape ? 5 : 3
  const startIdx = rowIndex * cols
  const endIdx = Math.min(startIdx + cols, players.length)

  for (let i = startIdx; i < endIdx; i++) {
    const player = players[i]
    if (i === startIdx + videoIndexInRow) {
      player.play()
      currentPlayingIndexByRow.set(rowIndex, videoIndexInRow)
    } else {
      player.pause()
    }
  }
}

const handleEnded = (rowIndex) => {
  const isLandscape = window.innerWidth >= window.innerHeight
  const cols = isLandscape ? 5 : 3
  const curIdx = currentPlayingIndexByRow.get(rowIndex) || 0
  const next = (curIdx + 1) % cols
  playVideoInRow(rowIndex, next)
}

const restartPlaybackForEachRow = () => {
  const isLandscape = window.innerWidth >= window.innerHeight
  const cols = isLandscape ? 5 : 3
  const rowCount = Math.ceil(videoList.value.length / cols)

  for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
    playVideoInRow(rowIndex, 0)
  }
}

const initPlayers = async () => {
  await nextTick()
  players.length = 0
  currentPlayingIndexByRow.clear()

  const isLandscape = window.innerWidth >= window.innerHeight
  const cols = isLandscape ? 5 : 3

  for (let index = 0; index < videoList.value.length; index++) {
    const videoEl = document.getElementById(`player-${index}`)
    if (!videoEl) continue

    const player = setupVideoPlayer(videoEl, videoList.value[index].url)
    players.push(player)

    const rowIndex = Math.floor(index / cols)
    const colIndex = index % cols

    player.on('ready', () => {
      // 默认每行第一个播放
      if (colIndex === 0) {
        playVideoInRow(rowIndex, 0)
      } else {
        player.pause()
      }
    })

    player.on('ended', () => {
      handleEnded(rowIndex, cols)
    })
  }
}

const getPlayers = async () => {
  const formData = { limit: 200 }

  getChartData(formData).then((res) => {
    // 省略接口数据处理部分代码

    fullList.value = Array.from(pidMap.values())
    refresh()
  }).catch((err) => {
    console.log(err)
  })
}

const videoGridStyle = ref({})

const refresh = async () => {
  sliceVideoList()
  calcLayout()
  await initPlayers()
  restartPlaybackForEachRow()
}

const sliceVideoList = () => {
  const screenW = window.innerWidth
  const screenH = window.innerHeight
  const isLandscape = screenW >= screenH
  const count = isLandscape ? 15 : 9
  videoList.value = fullList.value.slice(0, count)
  console.log(videoList.value)
}

const calcLayout = () => {
  const screenW = window.innerWidth
  const screenH = window.innerHeight
  const gap = 8
  const padding = 16
  const ratio = 9 / 16
  const isLandscape = screenW >= screenH
  const rows = 3
  const cols = isLandscape ? 5 : 3

  const maxW = screenW - padding - gap * (cols - 1)
  const maxH = screenH - padding - gap * (rows - 1)

  let videoW = maxW / cols
  let videoH = videoW / ratio

  if (videoH * rows > maxH) {
    videoH = maxH / rows
    videoW = videoH * ratio
  }

  videoGridStyle.value = {
    gridTemplateColumns: `repeat(${cols}, ${videoW}px)`,
    gridTemplateRows: `repeat(${rows}, ${videoH}px)`,
    justifyContent: 'center',
    alignContent: 'center',
    width: '100vw',
    height: '100vh',
    overflow: 'hidden'
  }
}

onMounted(() => {
  getPlayers()
  window.addEventListener('resize', refresh)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', refresh)
})
</script>

这里存在一个问题(可选),当我切换分辨率的时候,可能还有正在播放的视频,这里我要重新计算播放顺序的话,需要先暂停在播放中的视频,再去重新计算(去调用计算的方法,此处省略)。使用以下代码可以暂停:

const pauseAllPlayers = () => {
  players.forEach(player => {
    player.pause?.()
  })
}

const refresh = async () => {
  pauseAllPlayers()
  sliceVideoList()
  calcLayout()
  await initPlayers()
}

补充: 

如果需要点击列表中的某个视频置顶吸顶显示,在吸顶的 div 中定义的 list 在 v-for 的时候,不要使用 index 去作为 key 和 id,会因为 index 在数组中不变化导致视频数据不能正常更新:

<div v-for="(video, index) in topList" :key="video.pid">
  <video :id="`sticky-player-${video.pid}`" preload="none" :poster="video.cover" playsinline muted autoplay loop></video>
</div>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青松果核

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值