什么!只靠前端实现视频分片? - 网络篇

前言

上期我们学习了前端视频分片加载的基本处理,完成了仅在web前端通过MSE与mp4box.js库实现视频分片的拼装。接下来,我们要完善请求视频分片的逻辑,完成这个小型的视频分片组件。那我们直接进入正题:

实现视频分片请求

HTTP-Range

分片请求的基础是HTTP-Range:http范围请求,通过这个技术能通过Range请求头指定服务端返回部分资源。然后在返回头Content-Range可以获取到资源的大小。

js 复制代码
const rRange = /(\d+)-(\d+)\/(\d+)/
// 请求视频片段
export async function fetchRange({ videoSrc, from, chunkSize }, options = {}) {
  const to = from + chunkSize - 1
  const currentUrl = `${videoSrc}?from=${from}`
  try {
    const res = await fetch(currentUrl, {
      headers: {
        Range: `bytes=${from}-${to}`,
      },
      ...options,
    })
    const contentRange = res.headers.get('Content-Range') || ''
    const [, beginString, endString, totalString] = contentRange.match(rRange) || []
    const begin = parseInt(beginString, 10)
    const end = parseInt(endString, 10)
    const total = parseInt(totalString, 10)
    // mp4box解析分片需要bytes.fileStart
    const bytes = await res.arrayBuffer()
    bytes.fileStart = begin
    return {
      bytes,
      begin,
      end,
      total,
    }
  } catch (e) {
    return {
      error: e,
    }
  }
}

请求队列

在基础的资源请求之外,我们还要考虑到需求功能,我的需求是随着滚动播放不同的视频。因此需要先对视频预加载少量视频,以便视频可以快速播放,在此基础下,为了防止短时间内加载资源过多占用网络,我们可以实现一个按优先级的请求队列,以播放中 > 按前后排序 的逻辑提高请求效率。

以下是请求队列的实现:

js 复制代码
type QueueInfo = {
  task: () => Promise<any>
  resolve: (value: any) => void
  reject: (reason?: any) => void
  priority: number
}

/**
 * 并发限制
 * 支持修改优先级
 */
export default class TaskQueue {
  constructor(limit) {
    this.limit = limit
  }
  limit: number
  queue: QueueInfo[] = []
  executing: Promise<any>[] = []
  stoped = false

  addTask<T>({ task, priority = 0 }: { task: () => Promise<T>; priority: number }): Promise<T> {
    const taskPromise = new Promise<T>((resolve, reject) => {
      this.queue.push({ task, resolve, reject, priority })
      this.queue.sort((a, b) => b.priority - a.priority) // 根据优先级排序
      this.schedule()
    })

    return taskPromise
  }

  schedule() {
    if (this.executing.length >= this.limit || this.queue.length === 0 || this.stoped) {
      return
    }
    const queueInfo = this.queue.shift()
    if (!queueInfo) {
      return
    }
    const { task, resolve, reject } = queueInfo

    const p = task()

    this.executing.push(p)

    p.then((v) => resolve(v))
      .catch(reject)
      .finally(() => {
        this.executing.splice(this.executing.indexOf(p), 1)
        // 在循环推入任务时,等待调整优先级
        setTimeout(() => {
          this.schedule()
        })
      })
  }

  adjustPriority(taskPromise, newPriority) {
    const executingTaskIndex = this.executing.findIndex(item => item === taskPromise) // 执行中的任务
    const queueTaskIndex = this.queue.findIndex(item => item.task === taskPromise) // 等待中的任务
    if (queueTaskIndex !== -1) {
      this.queue[queueTaskIndex].priority = newPriority
      this.queue.sort((a, b) => b.priority - a.priority) // 根据优先级重新排序
    }
    return queueTaskIndex !== -1 || executingTaskIndex !== -1
  }

  stop() {
    this.stoped = true
  }
  resume() {
    if (this.stoped) {
      this.stoped = false
      this.schedule()
    }
  }
}

实现网络检测

在网络断开后重连的时候,需要检测网络的上线并重新开始下载。可以通过window.addEventListener('online')实现。

js 复制代码
// 网络检测
type Callback = () => void
export default class NetworkDetect {
  callbackList: Callback[] = []
  isOnce = false
  constructor(options = { isOnce: false }) {
    this.isOnce = options.isOnce
  }
  addHandler(cb: () => void) {
    this.callbackList.push(cb)
  }
  removeHandler(cb) {
    const index = this.callbackList.findIndex(cb)
    this.callbackList.splice(index, 1)
  }
  handler() {
    this.callbackList.forEach(cb => {
      cb()
    })
    if (this.isOnce) {
      this.unWatch()
    }
  }
  watch() {
    window.addEventListener('online', this.handler.bind(this))
  }
  unWatch() {
    this.callbackList = []
    window.removeEventListener('online', this.handler)
  }
}

还有在部分情况下需要检测wifi可以使用navigator.connection.type判断,但是需要注意这个兼容性很差,基本只有安卓的chrome才能正常运行。

js 复制代码
/**
 * 检查网络是否为wifi
 */
export function checkNetworkIsWifi() {
  // 限制非wifi环境下不自动播放视频
  const navigator = window.navigator
  if (typeof navigator !== 'undefined') {
    const connection = navigator?.connection || navigator?.mozConnection || navigator?.webkitConnection
    if (connection?.type === 'wifi') {
      return true
    }
  }
  return false
}

实现调整播放进度

调整播放进度的难点在于加载中的视频需要修改目前资源加载顺序,比如刚开始加载播放的视频被调整播放到末尾时,需要优先加载末尾部分的视频资源,并在播放完从头开始时再次继续下载并解析前面的未结束的视频。

为了实现调整播放进度的功能,非常重要的一点就是获取到播放时间对应的buffer节点,以便在资源加载的时候获取到指定的位置。而这个能力可以通过mp4box.js的seek方法查询到指定时长的buffer节点。

但需要注意的是seek方法会影响到视频解析的位置,即使用mp4box.js加载的视频切片需要按seek后的位置来处理。且获取到的位置是上一sample片的起始位置。

mp4box.seek方法会设置mp4box加载下一sample分片位置

具体视频分片下载逻辑如下图:

具体整合代码如下:

js 复制代码
import { fetchRange } from './api'
import TaskQueue from './task-queue'
import { chunkHandlerType } from '../../types'
import NetworkDetect from './utils/network-detect'

type VideoInfo = {
  videoSrc: string // 视频链接
  chunkList: number[] // 剩余未加载分片起始位置列表
  currentChunk?: number // 当前加载中分片起始位置
  nextChunk: number // 下一个分片起始位置
  currentTask?: Function // 当前任务
  adjustNextChunk?: number // 调整的下一个分片起始位置
  hasInit: boolean // 是否已经初始化/加载完第一分片
  total: number // 视频总大小
  chunkHandler: chunkHandlerType // 分片处理函数
  abortController?: AbortController // 取消请求控制器
}
type DownloadManagerParams = {
  limit?: number // 同时下载的视频数量限制
}

// 分片起始位置列表
function getChunkList(total: number, chunkSize: number) {
  const chunkList: number[] = []
  let nextChunk = chunkSize
  while (nextChunk < total) {
    chunkList.push(nextChunk)
    nextChunk += chunkSize
  }
  return chunkList
}

export default class DownloadManager {
  constructor({ limit = 2 }: DownloadManagerParams = {}) {
    this.videoMap = new Map()
    this.limit = limit
    this.taskQueue = new TaskQueue(limit)
    this.chunkSize = 500 * 1024
    this.networkDetect = new NetworkDetect({ isOnce: true })
  }
  videoMap: Map<string, VideoInfo> // 视频信息
  limit: number // 同时下载的视频数量限制
  currentPlayingVideo = '' // 当前播放的视频
  taskQueue: TaskQueue // 下载队列
  chunkSize: number // 分片大小
  networkDetect: NetworkDetect // 网络检测

  // 销毁前清除监听
  beforeDestroy() {
    this.networkDetect.unWatch()
  }

  // 添加视频,视频第一分片进入下载队列
  addVideo(unitId: string, videoSrc: string, chunkHandler: chunkHandlerType) {
    if (this.videoMap.has(unitId)) {
      return this.videoMap.get(unitId)
    } else {
      // 第一分片任务
      const abortController = new AbortController()
      const task = () => {
        return fetchRange({ videoSrc, from: 0, chunkSize: this.chunkSize }, { signal: abortController.signal })
      }

      this.videoMap.set(unitId, {
        videoSrc,
        chunkList: [],
        nextChunk: 0,
        currentTask: task,
        hasInit: false,
        chunkHandler,
        total: 0,
        abortController,
      })

      this.taskQueue
        .addTask({
          task,
          priority: 1,
        })
        .then(params => {
          const videoInfo = this.videoMap.get(unitId)
          if (params.bytes && videoInfo) {
            const { bytes, begin, end, total } = params
            const chunkList = getChunkList(total, this.chunkSize)
            // 不考虑moov后置的情况
            const nextChunk = chunkList[0]
            const isEof = chunkList.length === 0
            // 更新初始视频信息
            videoInfo.chunkList = chunkList
            videoInfo.nextChunk = nextChunk
            videoInfo.currentTask = undefined
            videoInfo.hasInit = true
            videoInfo.total = total
            chunkHandler({ bytes, begin, end, total, isEof, needNextRequest: true })
          } else if (params.error) {
            chunkHandler({ error: params.error })
            this.networkDetect.addHandler(() => {
              this.addVideo(unitId, videoSrc, chunkHandler)
            })
            this.networkDetect.watch()
          }
        })
    }
  }

  // 开始播放(提高当前分片优先级/开始加载下一分片)
  playingVideo(unitId: string) {
    this.currentPlayingVideo = unitId
    const videoInfo = this.videoMap.get(unitId)
    if (!videoInfo) {
      return
    }
    // 视频已完全加载
    if (videoInfo.hasInit && videoInfo.chunkList.length === 0) {
      // console.log('video has loaded')
      return
    }

    // 提高已进入队列的任务优先级
    if (videoInfo.currentTask) {
      this.taskQueue.adjustPriority(videoInfo.currentTask, 2)
    } else if (videoInfo.nextChunk) {
      if (videoInfo.adjustNextChunk) {
        videoInfo.nextChunk = videoInfo.adjustNextChunk
        videoInfo.adjustNextChunk = undefined
      }
      videoInfo.currentChunk = videoInfo.nextChunk
      // console.log('nextChunk', videoInfo.nextChunk)
      const abortController = new AbortController()
      const task = () => {
        return fetchRange(
          { videoSrc: videoInfo.videoSrc, from: videoInfo.nextChunk, chunkSize: this.chunkSize },
          { signal: abortController.signal },
        )
      }
      videoInfo.currentTask = task
      videoInfo.abortController = abortController
      this.taskQueue
        .addTask({
          task,
          priority: 2,
        })
        .then(params => {
          if (params.bytes && videoInfo) {
            const { bytes, begin, end, total } = params
            const chunkList = videoInfo.chunkList
            // console.log('chunkList', videoInfo.adjustNextChunk, begin, chunkList)

            // 更新视频信息
            videoInfo.currentTask = undefined
            let needNextRequest = true
            let skipChunkHanlder = false
            if (videoInfo.adjustNextChunk) {
              videoInfo.nextChunk = videoInfo.adjustNextChunk
              videoInfo.adjustNextChunk = undefined
              skipChunkHanlder = true
            } else {
              // 删除已加载的分片
              const currentChunkIndex = videoInfo.chunkList.indexOf(begin)
              if (currentChunkIndex !== -1) {
                videoInfo.chunkList.splice(currentChunkIndex, 1)
              }

              const nextChunkIndex = chunkList.findIndex(item => item >= begin)
              if (nextChunkIndex !== -1) {
                const nextChunk = chunkList[nextChunkIndex]
                videoInfo.nextChunk = nextChunk
              } else {
                videoInfo.nextChunk = chunkList[0]
                needNextRequest = false
              }
            }
            const isEof = videoInfo.chunkList.length === 0
            videoInfo.chunkHandler({ bytes, begin, end, total, isEof, needNextRequest, skipChunkHanlder })
          } else if (params.error) {
            videoInfo.currentTask = undefined
            videoInfo.chunkHandler({ error: params.error })
            this.networkDetect.addHandler(() => {
              this.playingVideo(this.currentPlayingVideo)
            })
            this.networkDetect.watch()
          }
        })
        .catch(e => {
          videoInfo.currentTask = undefined
          videoInfo.chunkHandler({ error: e })
        })
    }
  }

  // 暂停播放
  pauseVideo(unitId: string) {
    if (this.currentPlayingVideo === unitId) {
      this.currentPlayingVideo = ''
      const videoInfo = this.videoMap.get(unitId)
      // 特殊情况下存在未下载待播放的视频资源
      if (videoInfo?.currentTask) {
        this.taskQueue.adjustPriority(videoInfo.currentTask, 0)
      }
    }
  }

  // 调整播放进度
  adjustProgress(unitId: string, progressOffset: number) {
    // console.log('adjustProgress', videoSrc, progressOffset)
    const videoInfo = this.videoMap.get(unitId)
    if (!videoInfo) {
      return
    }
    if (progressOffset) {
      // videoInfo.abortController?.abort('adjustProgress')
      // videoInfo.currentTask = undefined
      videoInfo.adjustNextChunk = progressOffset
      this.playingVideo(unitId)
    } else {
      this.playingVideo(unitId)
    }
  }

  // 限制播放时长,减少加载资源
  limitDownloadTotal(unitId: string, limitDurationEndByte: number) {
    const videoInfo = this.videoMap.get(unitId)
    if (!videoInfo) {
      return
    }
    videoInfo.total = limitDurationEndByte
    videoInfo.chunkList = videoInfo.chunkList.filter(item => item < limitDurationEndByte + this.chunkSize)
  }
}

监控下载的资源大小

为了评估成本,我们还需要对加载资源进行统计,这里需要注意排除掉http缓存的资源。判断http缓存可以使用PerformanceObserver方法。

js 复制代码
function observeNetworkRequestIsCache(url) {
  return new Promise(resolve => {
    if (typeof PerformanceObserver === 'function') {
      const observer = new PerformanceObserver(list => {
        list.getEntries().forEach(entry => {
          if (entry?.name?.includes(url)) {
            observer.disconnect()
            resolve(!entry?.transferSize)
          }
        })
      })
      observer.observe({ entryTypes: ['resource'] })
    } else {
      resolve(false)
    }
  })
}

总结

除了视频加载的逻辑外,还有一个通过document.elementFromPointIntersectionObserver检测元素在视口内位置的功能由于与本文关系不大,不在此处占用篇幅,如果有人感兴趣我再另外写文。

视频分片后对资源加载的逻辑会比想象中更复杂,很多逻辑需要手动实现,且浏览器的支持度也不是很高,功能实现上也还有一些瑕疵,比如目前使用mse的视频是以blob文件的形式播放,无法复用视频资源等,后续如果有优化的方案也会再分享出来,如果有大佬有建议或优化方案也希望能再评论区分享下。

参考

Web 视频播放的那些事儿对于视频的在线播放,按视频内容的实时性可以分为点播(VOD)和直播(Live Streamin - 掘金

MP4Box.js | mp4box.js

什么!只靠前端实现视频分片?

相关推荐
风清扬雨7 分钟前
Vue3具名插槽用法全解——从零到一的详细指南
前端·javascript·vue.js
大熊猫今天吃什么35 分钟前
【一天一坑】空数组,使用 allMatch 默认返回true
前端·数据库
!win !1 小时前
Tailwind CSS一些你需要记住的原子类
前端·tailwindcss
前端极客探险家1 小时前
打造一个 AI 面试助手:输入岗位 + 技术栈 → 自动生成面试问题 + 标准答案 + 技术考点图谱
前端·人工智能·面试·职场和发展·vue
橘子味的冰淇淋~2 小时前
【解决】Vue + Vite + TS 配置路径别名成功仍爆红
前端·javascript·vue.js
利刃之灵2 小时前
03-HTML常见元素
前端·html
kidding7232 小时前
gitee新的仓库,Vscode创建新的分支详细步骤
前端·gitee·在仓库创建新的分支
听风吹等浪起2 小时前
基于html实现的课题随机点名
前端·html
leluckys2 小时前
flutter 专题 六十三 Flutter入门与实战作者:xiangzhihong8Fluter 应用调试
前端·javascript·flutter
kidding7232 小时前
微信小程序怎么分包步骤(包括怎么主包跳转到分包)
前端·微信小程序·前端开发·分包·wx.navigateto·subpackages