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

前言

上期我们学习了前端视频分片加载的基本处理,完成了仅在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

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

相关推荐
诗书画唱3 分钟前
【前端面试题】JavaScript 核心知识点解析(第二十二题到第六十一题)
开发语言·前端·javascript
excel10 分钟前
前端必备:从能力检测到 UA-CH,浏览器客户端检测的完整指南
前端
前端小巷子17 分钟前
Vue 3全面提速剖析
前端·vue.js·面试
悟空聊架构23 分钟前
我的网站被攻击了,被干掉了 120G 流量,还在持续攻击中...
java·前端·架构
CodeSheep25 分钟前
国内 IT 公司时薪排行榜。
前端·后端·程序员
尖椒土豆sss29 分钟前
踩坑vue项目中使用 iframe 嵌套子系统无法登录,不报错问题!
前端·vue.js
遗悲风29 分钟前
html二次作业
前端·html
江城开朗的豌豆33 分钟前
React输入框优化:如何精准获取用户输入完成后的最终值?
前端·javascript·全栈
CF14年老兵33 分钟前
从卡顿到飞驰:我是如何用WebAssembly引爆React性能的
前端·react.js·trae
画月的亮36 分钟前
前端处理导出PDF。Vue导出pdf
前端·vue.js·pdf