学习笔记 - 实现一个H.265播放器

1,065 阅读4分钟

1. 背景介绍

1.1 业务背景

使用H.265编码传输视频被广泛应用到业务场景中,原有的 flvjs 显得特别无能为力。 H.265编码视频相较于H.264编码视频,改善码流、编码质量、延时和算法复杂度,因此支持H.265编码视频播放刻不容缓。

2. 播放器概述

播放器的核心还是在于解析视频流,最早的调研方案是用FFmpeg,当一路踩坑好不容易把 C 代码编译成 JavaScript 代码后,发现代码体积过大。在寻找解决方案的过程中,看到了GitHub上的一个开源项目WXInlinePlayer,最终选择了OpenH264de265作为解码依赖。

2.1 播放FLV视频的交互流程

播放H.265和H.264编码视频的正常交互流程。

image.png

2.2 播放HLS视频的交互流程

播放H.264编码视频的正常交互流程。

image.png

2.3 播放器内部架构

image.png

2.4 播放器整体架构

image.png

3. 播放器实现

3.1 核心模块实现

思路就是实现 WebGL 渲染图像和播放 AAC 格式音频

  • 将视频流数据解析成 AAC 音频数据 和 YUV 图像数据
  • 使用 AudioContext 相关 API 播放 AAC 音频数据
  • 使用 canvas 渲染 YUV 图像数据

业界比较成熟的解析视频流的方案大多是用 C 语言开发,如果要在浏览器端使用他们,就需要用到一种技术 WebAssembly,并且使用 emsdk(emscripten 1.38.45) 编译 C 代码至 JavaScript 代码。当我们在浏览器端运行编译后的 JavaScript 代码,浏览器端将具备解析和处理H.265编码视频流的能力。

3.1.1 音频播放功能实现

实现音频播放功能,需要使用浏览器提供的 AudioContext 相关API

image.png

3.1.1.1 创建 AudioContext 实例

const audioCtx = new AudioContext(); // 创建实例
const gainNode = audioCtx.createGain(); // 用于控制音量
gainNode.connect(audioCtx.destination);


gainNode.gain.value = 0.5; // 设置音量

3.1.1.2 将 AAC 音频流转换成 AudioContext 需要的 buffer

使用 decodeAudioData 方法转换 buffer,需要注意顺序问题,调用 decodeAudioData 的顺序跟回调函数执行的顺序可能不一致,需要用到时间戳做顺序控制

appendBuffer(buf: ArrayBuffer, timestamp: number): void {
    const { store_ } = this;
    store_.hold(timestamp);
    this.audioCtx.decodeAudioData(buf, (buffer: AudioBuffer) => {
      switch (this.audioState) {
        case AUDIO_WAITING: {
          store_.put(timestamp, buffer);
          const nextBuf = store_.shift();
          if (nextBuf) {
            this.playAudio_(nextBuf);
          }
          break;
        }
        case AUDIO_ENDED:
        case AUDIO_STOPPED:
        case AUDIO_DISPOSED:
          break;
        default:
          this.store_.put(timestamp, buffer);
      }

    }, (e) => {
      this.store_.remove(timestamp);
      logger.error(`timestamp: ${timestamp}`, e);
    });
}

3.1.1.3 实时播放音频流

使用 AudioBufferSourceNode 实例播放之前解码后的 AudioBuffer

playAudio_(buffer: AudioBuffer): void {
  const source: AudioBufferSourceNode = this.audioCtx.createBufferSource();
  source.onended = () => {
    source.disconnect();
    
    // 获取下一次待播放的 AudioBuffer
    // ...
    this.playAudio_(nextBuffer);
  };
  source.buffer = buffer;
  source.connect(this.gainNode);
  source.start();
}

3.1.1.4 音频队列实现

由于播放过程中对 buffer 的存取操作比较频繁,因此使用了链表实现队列而不是 原生数组

interface Item {
  value: any;
  next: any;
  prev: any;
}

function createItem(data: any): Item {
  return { value: data, next: null, prev: null };
}

class Queue {
  private first_: Item | null = null;
  private last_: Item | null = null;
  private length_ = 0;

  get first() {
    const { first_ } = this;
    return first_ && first_.value;
  }

  get last() {
    const { last_ } = this;
    return last_ && last_.value;
  }

  get length() {
    return this.length_;
  }

  clear(): void {
    this.length_ = 0;
    this.first_ = null;
    this.last_ = null;
  }

  push(o: any): number {
    const newLast = createItem(o);
    if (this.length_ === 0) {
      this.first_ = newLast;
      this.last_ = newLast;
    }
    else {
      const { last_: oldLast } = this;
      oldLast!.next = newLast;
      newLast.prev = oldLast;
      this.last_ = newLast;
    }

    return (++this.length_);
  }

  shift(): any {
    const oldFirst = this.first_;
    if (!oldFirst) {
      return null;
    }

    this.length_--;

    const newFirst = oldFirst.next;
    this.first_ = newFirst;

    if (newFirst) {
      newFirst.prev = null;
    }
    else {
      this.last_ = null;
    }

    return oldFirst.value;
  }

  remove(o: any): number {
    const { first_, last_ } = this;
    if (first_ === null || last_ === null) {
      return -1;
    }

    if (o === first_.value) {
      this.first_ = first_.next;
      this.length_--;
      return 0;
    }

    if (o === last_.value) {
      this.last_ = last_.prev;
      return (--this.length_);
    }

    for (let i = 1, len = this.length_, prev = first_, current = first_.next; i < len; i++) {
      if (current.value === o) {
        prev.next = current.next;
        this.length_--;
        return i;
      }
      prev = current;
      current = current.next;
    }
    return -1;
  }
}

音频解码过程是一个异步过程,解码后进入回调的顺序不一定按照调用顺序进入,因此还需要构造一个 store 来管理,利用时间戳控制顺序存取

class AudioBufferStore {
  private map_: {[key: number]: AudioBuffer} = Object.create(null);
  private readonly keys_: Queue = new Queue();

  hold(timestamp: number): void {
    this.keys_.push(timestamp);
  }

  put(timestamp: number, buf: AudioBuffer): void {
    this.map_[timestamp] = buf;
  }
  
  shift(): AudioBuffer | null {
    const { first } = this.keys_;
    if (first === null) {
      return null;
    }
    const firstBuf = this.map_[first];
    if (!firstBuf) {
      return null;
    }
    this.remove(first);
    return firstBuf;
  }

  clear(): void {
    this.keys_.clear();
    this.map_ = Object.create(null);
  }

  remove(timestamp: number): void {
    this.keys_.remove(timestamp);
    delete this.map_[timestamp];
  }
}

3.1.2 图像渲染功能实现

创建 webgl 上下文实例在 canvas 标签上实时画图

3.1.2.1 创建 webgl 实例

const validContextNames = [
  'webgl',
  'experimental-webgl',
  'moz-webgl',
  'webkit-3d'
];

export function getWebGLContext(canvas: HTMLCanvasElement): WebGLRenderingContext | null {
  let gl: WebGLRenderingContext | null = null;
  let nameIndex = 0;
  while (!gl && nameIndex < validContextNames.length) {
    const contextName = validContextNames[nameIndex];
    try {
      gl = canvas.getContext(contextName) as WebGLRenderingContext;
    }
    catch (e) {
      gl = null;
    }
    if (!gl || typeof gl.getParameter !== 'function') {
      gl = null;
    }
    ++nameIndex;
  }

  return gl;
}

3.1.2.2 渲染图像

渲染的核心就是先从整段 YUV buffer 中依次截取这 3 段 buffer 并排版统一渲染,可参考开源组件 yuv-view

render(data: Uint8Array): void {
  const gl = this.webglContext_;
  if (!gl) {
    console.warn('The WebGLRenderingContext instance was not created');
    return;
  }

  const { width_, height_ } = this;

  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  gl.viewport(0, 0, width_, height_);

  const i420Data = data;
  const yDataLength = width_ * height_;
  const yData = i420Data.subarray(0, yDataLength);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, this.yTexture_);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.LUMINANCE,
    width_,
    height_,
    0,
    gl.LUMINANCE,
    gl.UNSIGNED_BYTE,
    yData
  );

  const cbDataLength = width_ * height_ / 4;
  const cbData = i420Data.subarray(yDataLength, yDataLength + cbDataLength);
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, this.uTexture_);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.LUMINANCE,
    width_ / 2,
    height_ / 2,
    0,
    gl.LUMINANCE,
    gl.UNSIGNED_BYTE,
    cbData
  );

  const crDataLength = cbDataLength;
  const crData = i420Data.subarray(
    yDataLength + cbDataLength,
    yDataLength + cbDataLength + crDataLength
  );
  gl.activeTexture(gl.TEXTURE2);
  gl.bindTexture(gl.TEXTURE_2D, this.vTexture_);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.LUMINANCE,
    width_ / 2,
    height_ / 2,
    0,
    gl.LUMINANCE,
    gl.UNSIGNED_BYTE,
    crData
  );

  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

3.1.3 日志模块实现

3.1.3.1 定义日志级别

enum LOG_LEVEL {
  FATAL = 6,
  ERROR = 5,
  WARN = 4,
  INFO = 3,
  DEBUG = 2,
  TRACE = 1
}

3.1.3.2 日志类实现

[2022-05-13T11:01:36.591+08:00] [DEBUG] SPI - 调用 play()

注意:在国内通过 getTimezoneOffset 获取到的时差是 -480

function pad(number: number): string | number {
  if (number < 10) {
    return `0${number}`;
  }
  return number;
}

function timezone(minutes: number): string {
  let prefix = '+';
  if (minutes < 0) {
    minutes = Math.abs(minutes);
  }
  else {
    prefix = '-';
  }
  return `${prefix}${pad(Math.floor(minutes / 60))}:${pad(Math.floor(minutes % 60))}`;
}

function dateFormat(date: Date): string {
  const timezoneOffset: number = date.getTimezoneOffset();

  return `${date.getFullYear()
  }-${pad(date.getMonth() + 1)
  }-${pad(date.getDate())
  }T${pad(date.getHours())
  }:${pad(date.getMinutes())
  }:${pad(date.getSeconds())
  }.${(date.getMilliseconds() / 1000).toFixed(3).slice(2, 5)
  }${timezone(timezoneOffset)}`;
}

function logInfo(method: string, loggerName: string, level: string, args: any[]) {
  // eslint-disable-next-line no-console
  ((console as any)[method] || console.log)(
    `[${dateFormat(new Date())}] [${level}] ${loggerName} -`,
    ...args
  );
}

export default class Logger {
  name: string;
  _level = LOG_LEVEL.INFO;

  constructor(loggerName: string, level?: number) {
    this.name = loggerName;
    this.level = level;
  }

  get level() {
    return this._level;
  }

  set level(level?: number) {
    if (level == null) {
      level = LOG_LEVEL.INFO;
    }
    this._level = level;

    forOwn(LOG_LEVEL, (val: number, key: string) => {
      const method = key.toLocaleLowerCase();
      const consoleMethod = method === 'error' ? method : 'log';
      (this as any)[method] = level <= val
        ? (...args: any[]) => {
          logInfo(consoleMethod, this.name, key, args);
        }
        : () => {};
    });
  }

  error() {}
  warn() {}
  info() {}
  debug() {}
}

3.1.3.3 统一设置日志级别

一般情况都是在代码比较靠前的位置创建一个 logger 实例,通过 getLogger 方法创建并放入缓存,便于统一设置日志级别

const loggerCache: {[key: string]: ILogger} = {};

function getLogger(name: string, level?: number): ILogger {
  const logger = loggerCache[name];
  if (!logger) {
    return (loggerCache[name] = new Logger(name, level));
  }
  return logger;
}

function setLevel(level: number) {
  forOwn(loggerCache, (logger: ILogger) => {
    logger.level = level;
  });
}

获取一个 logger 实例

const logger = getLogger('SPI');

初始化播放器时,统一设置日志级别

setLevel(options.logLevel);

3.1.4 拉流模块实现

获取实时流和文件流

3.1.4.1 获取实时流

获取实时流的不同之处就是建立连接之后,递归地读取响应流直到结束

function cancelStream(stream: ReadableStream): void {
  if (!stream.locked) {
    stream.cancel();
  }
}

function readArrayBuffer(
  loader: StreamLoader,
  stream: ReadableStream,
  reader: ReadableStreamDefaultReader
): Promise<void> {
  return reader.read().then((result: ReadableStreamDefaultReadResult<ArrayBuffer>) => {
    // 请求已中断
    if (loader.state === LOADER_ABORTED) {
      cancelStream(stream);
      return;
    }

    // 拉流结束
    if (result.done) {
      loader.state = LOADER_ENDED;
      loader.abortController = null;
      loader.emit('end');

      logger.debug(`拉流结束 ${loader.currentUrl}`);
      return;
    }

    const data = result.value;
    loader.byteLength += data.byteLength;
    loader.emit('data', data);
    // 继续读取
    return readArrayBuffer(loader, stream, reader);
  });
}

class StreamLoader extends Emitter {
  abortController: AbortController | null = null;
  state = LOADER_INIT;
  fetchOptions: {[key: string]: any} = {};
  type = 'stream';
  byteLength = 0;
  currentUrl = '';

  constructor(opts: StreamLoaderOptions) {
    super();

    this.initFetchOptions_(opts);
  }

  fetch(url: string): void {
    const params = Object.assign({}, this.fetchOptions);
    params.signal = this.getAbortController_().signal;

    this.byteLength = 0;
    this.state = LOADER_START;
    this.currentUrl = url;

    // 请求资源
    logger.debug('准备请求', url);
    fetch(url, params).then((res: Response) => {
      const stream: ReadableStream | null = res.body;

      // 接口未返回结果
      if (!stream) {
        return Promise.reject(new Error('服务端未返回结果'));
      }

      // 异常状态
      if (!res.ok || !validateHttpStatus(res.status)) {
        return Promise.reject(new Error(`异常状态: ${res.status}`));
      }

      // 请求已中断
      if (this.state === LOADER_ABORTED) {
        cancelStream(stream);
        return;
      }

      logger.debug(`开始拉流 ${url}`);

      this.state = LOADER_FETCHING;
      this.emit('loadstart');

      return readArrayBuffer(this, stream, stream.getReader())
        .catch((e: Error) => {
          // 异常后先把流取消
          cancelStream(stream);

          // 请求中断异常先忽略
          if (this.state === LOADER_ABORTED) {
            return;
          }
          return Promise.reject(e);
        });
    }).catch((e) => {
      // 请求中断异常先忽略
      if (this.state === LOADER_ABORTED) {
        logger.info(`中断请求:${url}`);
        this.emit('aborted');
        return;
      }

      const lastState = this.state;
      this.state = LOADER_ERROR;
      logger.error('拉流异常', url, e);

      // 异常后中断请求
      this.abort();
      this.emit('error', e, lastState);
    });
  }

  retry(): void {
    const { currentUrl } = this;
    if (!currentUrl) {
      return;
    }

    this.abort();
    logger.debug('重新请求:', currentUrl);
    this.fetch(currentUrl);
  }

  abort(): void {
    const { abortController } = this;
    if (abortController && !abortController.signal.aborted) {
      abortController.abort();
      this.state = LOADER_ABORTED;
      this.abortController = null;
    }
  }

  initFetchOptions_(opts: StreamLoaderOptions): void {
    const params: {[key: string]: any} = {
      method: 'GET',
      headers: opts.headers,
      mode: 'cors',
      // mode: 'no-cors',
      cache: 'default',
      referrerPolicy: 'no-referrer-when-downgrade'
    };

    if (opts.cors === false) {
      params.mode = 'same-origin';
    }

    if (opts.withCredentials) {
      params.credentials = 'include';
    }
    else if (opts.credentials) {
      params.credentials = opts.credentials;
    }

    if (opts.referrerPolicy) {
      params.referrerPolicy = opts.referrerPolicy;
    }

    this.fetchOptions = params;
  }

  getAbortController_(): AbortController {
    const abortController = new AbortController();
    this.abortController = abortController;
    return abortController;
  }
}

3.1.4.2 获取文件流

获取文件流跟获取实时流类似,区别在于不用递归去读取数据,只需要继承上面的实现类并重写 fetch 方法

class FileLoader extends StreamLoader {
  type = 'hls';

  fetch(url: string): void {
    const params = Object.assign({}, this.fetchOptions);
    params.signal = this.getAbortController_().signal;

    const dataType = ~url.indexOf('.m3u8') ? 'text' : 'arrayBuffer';

    this.byteLength = 0;
    this.state = LOADER_START;
    this.currentUrl = url;

    // 请求资源
    logger.debug('准备请求', url);
    fetch(url, params)
      .then((res: Response) => {
        // 异常状态
        if (!res.ok || !validateHttpStatus(res.status)) {
          return Promise.reject(new Error(`异常状态: ${res.status}`));
        }

        this.emit('loadstart');

        return (res[dataType] as any)();
      })
      .then((ret: string | ArrayBuffer) => {
        if (ArrayBuffer.isView(ret)) {
          this.byteLength = ret.byteLength;
        }
        this.state = LOADER_ENDED;
        this.abortController = null;
        this.emit('end', ret);

        logger.debug(`请求完成 ${url}`);
      })
      .catch((e) => {
        // 请求中断异常先忽略
        if (this.state === LOADER_ABORTED) {
          logger.info(`中断请求:${url}`);
          this.emit('aborted');
          return;
        }

        const lastState = this.state;
        this.state = LOADER_ERROR;

        logger.error('请求异常', url, e);

        // 异常后中断请求
        this.abort();
        this.emit('error', e, lastState);
      });

    this.state = LOADER_START;
  }
}

3.1.5 解码线程实现

浏览器中不支持多线程,线程实际上是用进程模拟实现,通过对消息订阅相互传递数据

  • 解码进程包含四大块:
    • wasm 核心解码逻辑
    • 桥接解码过程
    • 拉 flv 实时流和拉 hls 切片流
    • 主进程和解码进程之间互通

3.1.5.1 在主进程中创建解码进程

自定义rollup插件,匹配bundlify!为前缀的代码文件内部打包,并作为字符串跟主代码合并在一个文件中,因此,我们需要通过以下方式创建 Worker

function createWorker(codes: string[], opts?: WorkerOptions): Worker {
  const blob = new Blob(codes);
  const url = URL.createObjectURL(blob);
  const worker = new Worker(url, opts);
  worker.objectURL = url;

  const terminate = worker.terminate;
  worker.terminate = function () {
    URL.revokeObjectURL(url);
    return terminate.call(this);
  };
  return worker;
}
import decoderString from 'bundlify!./decoder';

const decoderWorker = createWorker([decoderString], opts);

decoderWorker.onmessage = ({ data }: MessageEvent<DecoderMessage>) => {
  switch (data.type) {
    case MSG_ENV_READY:
      // 已初始化 wasm 环境
      break;

    case MSG_LOAD:
      // 已启动底层解码,尝试下载视频,数据还没有
      break;

    case MSG_LOAD_START: {
      // 连接成功正在下载视频
      break;
    }

    case MSG_DURATION: {
      // 视频时长变化
      break;
    }

    case MSG_METADATA: {
      // 视频信息
      break;
    }

    case MSG_STATUS: {
      const { status } = data;
      this.runtimeState = status;

      switch (status) {
        case RUNTIME_PLAYING:
          // 播放中
          break;

        case RUNTIME_BUFFERING:
          // 缓冲中
          break;

        case RUNTIME_PAUSED:
          // 暂停完成
          break;

        case RUNTIME_ENDED: {
          // 播放结束
          break;
        }

        case RUNTIME_ERROR:
          // 异常情况
          break;
      }
      break;
    }

    case MSG_VIDEO_DATA:
      // 图像数据
      break;

    case MSG_AUDIO_DATA:
      // 音频数据
      break;

    case MSG_CURRENT_TIME: {
      // 当前时长
      break;
    }

    case MSG_SEEK:
      // hls seek 结束
      break;

    case MSG_CHANGE_RATE:
      // hls 变速完成
      break;

    case MSG_STOP:
      // 停止解码
      break;

    case MSG_FETCH_ERROR:
      // 拉流异常
      break;
  }
};

3.1.5.2 导入 wasm 模块

使用__WASM_DECODER__作为占位符,打包代码时将__WASM_DECODER__替换成核心解码逻辑,解码逻辑是用 C++ 开发并通过 emsdk 编译成 JavaScript

import bridge from './bridge';
import subscription from './subscription';
import { MSG_ENV_READY } from '../constant/messageType';
import logger from './logger';

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const Module: any = {
  onRuntimeInitialized(): void {
    logger.debug('加载解码环境完成');

    // C模块已加载
    postMessage({ type: MSG_ENV_READY });
  }
} as {[key: string]: any};

// eslint-disable-next-line no-unused-expressions
__WASM_DECODER__;

bridge();
subscription();

3.1.5.3 桥接解码过程

这里实际上是定义解码过程中需要回调的函数

(self as any)[Module.bridgeName] = {

  onDuration(duration: number): void {
    // 接收时长并传给主进程
  },

  onMetaData(width: number, height: number): void {
    // 接收视频信息并传给主进程
  },

  onStatus(status: number): void {
    // 接收解码运行状态并传给主进程
  },

  onAudio(ptr: number, length: number, timestamp: number/* , delay_ms: number */): void {
    // 接收音频数据并传递给主进程
  },

  onVideo(ptr: number, length: number, timestamp: number/* , delay_ms: number */): void {
    // 接收图像数据并传给主进程
  },

  onGetFile(urlPtr: number, urlLength: number): void {
    // 根据解析后的ts地址拉流并传给解码模块
  },

  onGetFileAndDecrypt(
    urlPtr: number,
    urlLength: number,
    keyPtr: number,
    keyLength: number,
    ivPtr: number,
    ivLength: number): void {
    // 根据解析后的ts地址拉流并按照密钥和向量解码后再传给解码模块
  },

  onUpdateM3U8() {
    // hls直播时会一直回调该函数进行重新拉流再传给解码模块
  }
};

3.1.5.4 主进程消息订阅

接收主进程的消息进行初始化解码模块、播放、暂停、变速、seek、停止、关闭进程一系列操作

self.onmessage = ({ data }: MessageEvent<DecoderMessage>) => {
  switch (data.type) {
    case MSG_LOAD:
      // 初始化解码模块并拉流
      break;

    case MSG_CHANGE_RATE:
      // 变速
      break;

    case MSG_PLAY:
      // 播放
      break;

    case MSG_PAUSE:
      // 暂停
      break;

    case MSG_STOP:
      // 停止播放
      break;

    case MSG_SEEK:
      // hls seek
      break;

    case MSG_CLOSE:
      // 关闭进程
      break;
  }
};

3.2 videojs-tech-h265 实现

官网没有详细的 tech 开发说明,可以参考官方在 GITHUB 上的 tech 拓展 videojs-flash

3.2.1 自定义 tech 实现

新建一个 class 继承 videojs 内部的 Tech 并实现部分必要函数

class TechH265 extends Tech {
    static formats = {
      'video/h265': 'FLV'
    };
  
    static isSupported() {
      // 判断当前环境是否支持该技术
    };
  
    static canPlayType(type: string) {
      if (TechH265.isSupported() && type in TechH265.formats) {
        return 'maybe';
      }
      return '';
    }

    static canPlaySource(
      srcObj: {type: string, src: string},
    ) {
      return TechH265.canPlayType(srcObj.type);
    }

    constructor(options: any, ready: boolean) {
      super(options, ready);

      // 设置支持变速
      this.featuresPlaybackRate = true;

      this.name_ = 'TechH265';
      
      // ... 其它自定义处理

      // 调用后触发 ready 事件
      this.triggerReady();
    }

    createEl(): HTMLCanvasElement {
      // 创建 canvas 元素并返回
      
      const { options_ } = this;
      const videoOpts: {[key: string]: any} = {
        id: options_.playerId,
        logLevel: options_.logLevel,
        muted: options_.muted
      };
      const player = this.getPlayer_();
      const video = createVideo(videoOpts);
      
      // ... 其它特殊处理

      const { el } = video;
      el.setAttribute('id', options_.techId);
      el.setAttribute('class', 'vjs-tech');
      return el;
    }

    setScrubbing(scrubbing: boolean): void {
      // 拖动播放器进度条时 scrubbing 的值为 true,结束时为 false
    }

    setCurrentTime(seconds: number): void {
      // 拖动播放器进度条和手动设置 currentTime 时触发,
      // 当拖动播放器进度条时,setScrubbing 的调用时机早于当前函数
    }

    setPlaybackRate(rate: number): void {
      // 重置和切换倍速时被调用
      // 触发 ratechange 事件后,界面上才会同步修改
      this.trigger('ratechange');
    }

    setVolume(volume: number): void {
      // 设置音量
      // 触发 volumechange 事件后,界面上才会同步修改
      this.trigger('volumechange');
    }

    setMuted(muted: boolean) {
      // 是否静音
      // 触发 volumechange 事件后,界面上才会同步修改
      this.trigger('volumechange');
    }

    src(src: string): void {
      // 设置播放源
    }

    load(): void {
      // 加载播放源
    }

    seekable(): any {
      const duration = this.duration();
      if (duration === 0) {
        return createTimeRange();
      }
      return createTimeRange(0, duration);
    }

    controls(): boolean {
      // 是否使用本地播放器控制条
      return false;
    }

    defaultPlaybackRate(): number {
      // 如果属性 featuresPlaybackRate 设置为true,当前函数一定要定义
      return 1;
    }

    supportsFullScreen(): boolean {
      // 是否支持全屏
    }

    enterFullScreen(): void {
      // 进入全屏模式
    }

    dispose(): void {
      // 销毁播放器时回调
    }

    play(): void {
      // 播放
    }

    pause(): void {
      // 暂停
    }

    reset(): void {
      // 重置播放器时触发
    }
  
    currentTime(): number {
      // 获取当前时间,秒为单位
    }
    
    duration(): number {
      // 获取播放时长
    },
  
    ended(): boolean {
      // 是否结束
    }
    
    muted(): boolean {
      // 是否静音
    }
  
    networkState(): number {
      // 参考 HTMLMediaElement 的 networkState
    }
  
    paused(): boolean {
      // 是否已暂停
    }
  
    playbackRate(): number {
      // 获取倍速值
    }
  
    readyState(): number {
      // 参考 HTMLMediaElement 的 readyState
    }
  
    seeking(): boolean {
      // 是否正在 seek
    }
  
    volume(): number {
      // 获取音量大小
    }
  
    autoplay(bool?: boolean): boolean | void {
      // 设置自动播放或者获取该值
    }
  }

3.2.2 注册 Tech

videojs.registerTech('h265', TechH265);

3.2.3 使用 Tech

// 初始化播放器
const player = videojs(el, {
  techOrder: ['h265']
});
player.src({
  src: '播放地址',
  type: 'video/h265',
});
player.autoplay(true);
player.load();

3.3 遗留问题

播放器虽然已经对内和对外使用,但还存在以下几个问题:

3.3.1 音频播放延迟问题

使用 AudioBufferSourceNode播放音频时,需要一定量的 buffer 才能播放,因此暂时处理为把每 32 帧音频做了合并,作为一段可播放的 buffer 造成延时,具体延迟的时间不定跟视频有关,后续会设计成按时间合并,具体规则还在思考中。

3.3.2 音频倍速播放问题

使用 AudioBufferSourceNode设置playbackRate属性变速播放音频时,除了播放速度有改变,音调也发生了变化,通过自定义AudioWorkletProcessor处理也没能达到预期,还在摸索中。

3.3.3 性能问题

多倍速解码 2K 视频流和解码 4K 视频流存在性能瓶颈,目前正在尝试多线程解码,但是多线程解码依赖于浏览器需要支持SharedArrayBuffer,页面通过开启coepcoop策略支持SharedArrayBuffer,同时又影响到跨域资源的加载,尤其是加载cdn资源。

4. 总结

本次只实现了播放内核和 videojs 插件,不算一个完整的播放器,UI这层还是依赖于 videojs。对于播放内核的实现也总结了以下几个容易上手的知识点:

4.1 知识点 - WebAssembly

WebAssembly 的简称是 wasm,使用 emsdk 编译 C 语言代码至 wasm文件 和 JavaScript 文件,跟前端业务代码融为一体,实现最终的业务需求

4.1.1 实战演练

实现 JavaScript 调用 C 函数,和 C 函数调用 JavaScript 的过程

使用 vscode 开发 C 语言前需要在项目的根目录添加一个配置文件.vscode/c_cpp_properties.json (详情可参考 官网 ),防止编辑器提示找不到emscripten.h

{
    "env": {
        "myDefaultIncludePath": [
            "/Users/jesse/emsdk/fastcomp/emscripten/system/include/**",
            "${workspaceFolder}/**"
        ]
    },
    "configurations": [
        {
            "name": "Mac",
            "includePath": [
                "${myDefaultIncludePath}"
            ],
            "browse": {
                "path": [
                    "${workspaceFolder}"
                ],
                "limitSymbolsToIncludedHeaders": true,
                "databaseFilename": ""
            },
            "defines": [],
            "macFrameworkPath": [
                "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks"
            ],
            "compilerPath": "/usr/bin/clang",
            "cStandard": "c11",
            "cppStandard": "c++17",
            "intelliSenseMode": "clang-x64"
        }
    ],
    "version": 4
}

新建一个 main.c

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int sum(int a, int b)
{
  return a + b;
}

EMSCRIPTEN_KEEPALIVE
int call_js_sum(int a, int b)
{
  return EM_ASM_INT({ return js_sum($0, $1); }, a, b);
}

新建一个 index.html

<html>
<head>
  <title>hello wasm</title>
  <style>
    section {
      font-size: 20px;
    }
    #success {
      color: #ff0000;
    }
  </style>
</head>
<body>
  <script src="./main.js"></script>
  <script>
    function $(selector) {
      return document.querySelector(selector);
    }
    Module.onRuntimeInitialized = () => {
      $('#success').innerHTML = "wasm 加载成功";
    };
    function js_sum(a, b) {
      return a + b;
    }
    function onSum(a, b) {
      $('#result').innerHTML = Module._sum(a, b);
    }
    function onSum2(a, b) {
      $('#result2').innerHTML = Module._call_js_sum(a, b);
    }
  </script>
  <section id="success"></section>
  <section id="error"></section>
  <section>
    <span>1 + 1</span>
    <button onclick="onSum(1,1);">=</button>
    <span id="result"></span>
  </section>
  <br />
  <section>
    <span>1 + 2</span>
    <button onclick="onSum2(1,2);">=</button>
    <span id="result2"></span>
  </section>
</body>
</html>

新建一个 Makefile 文件

input = ${shell pwd}/main.c
output = ${shell pwd}/main.js

all: hello-wasm

hello-wasm:
	emcc ${input} -s WASM=1 -o ${output}

使用 make 命令编译 main.c

$ make hello-wasm
emcc /Users/jesse/workspace/hello-wasm/main.c -s WASM=1 -o /Users/jesse/workspace/hello-wasm/main.js

启动本地服务加载 html 资源查看最终效果

4.2 知识点 - AudioContext

AudioContext 是音频处理的一个上下文环境,在处理或播放音频之前,都需要创建一个 AudioContext 实例,并且可以全局共享同一个。(音频测试文件 test.aac

4.2.1 实战演练

实现播放 AAC 音频文件

<html>
<head>
  <title>hello audio</title>
  <style>
    section {
      font-size: 20px;
    }
  </style>
</head>
<body>
  <script>
    const audioCtx = new AudioContext();
    const gainNode = audioCtx.createGain();
    gainNode.gain.value = 1;
    gainNode.connect(audioCtx.destination);
    function play() {
      fetch('/test.aac')
        .then((res) => res.arrayBuffer())
        .then((buf) => {
          audioCtx.decodeAudioData(buf, (audioBuf) => {
            const source = audioCtx.createBufferSource();
            source.buffer = audioBuf;
            source.connect(gainNode);
            source.start();
          });
        });
    }
  </script>
  <section>
    <button onclick="play();">播放</button>
  </section>
</body>
</html>

启动本地服务加载 html 资源查看最终效果

4.3 知识点 - WebGL

WebGL是一个JavaScript API,可在任何兼容的Web浏览器中渲染高性能的交互式3D和2D图形,而无需使用插件。WebGL提供了在浏览器中创建具有桌面应用体验的应用的最终特性。

4.3.1 实战演练

实现用 WebGL 的方式在 canvas 上绘制一个点

  • 绘制一个点有以下几步:
    • 获取 canvas 元素
    • 获取 WebGL 绘图上下文 WebGLRenderingContext
    • 初始化着色器
    • 设置 canvas 的背景色
    • 最后再通过 drawArrays 绘制点
  • 潜在的点:
    • 创建着色器用到的代码片段是基于 GLSL(OpenGL Shading Language)
    • gl_Position必须被赋值并且是vec4类型(vec4(X, Y, Z, 1.0))
    • gl_FragColor用于控制绘制点的颜色并且是vec4类型(vec4(R, G, B, A)),但是R、G、B这3个分量的取值范围是0到1的浮点数,因此在指定颜色值时需要除以255
<html>
<head>
  <title>hello webgl</title>
</head>
<body>
  <button onclick="drawPoint()">画点</button>
  <br/>
  <canvas id="webgl" width="400" height="400">
  <script>
    const names = ['webgl', 'experimental-webgl', 'webkit-3d', 'moz-webgl'];
    // 创建 webgl 上下文实例
    function getWebGLContext(canvas, opts) {
      let context = null;
      for (var ii = 0; ii < names.length; ++ii) {
        try {
          context = canvas.getContext(names[ii], opts);
        } catch (e) { }
        if (context) {
          break;
        }
      }
      return context;
    }
    // 顶点着色器脚本(控制坐标和尺寸)
    const vShaderScript = `
      void main() {
        gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
        gl_PointSize = 20.0;
      }
    `;
    // 片元着色器脚本(控制颜色)
    const fShaderScript = `
      void main() {
        gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
      }
    `;
    // 加载着色器
    function loadShader(gl, type, source) {
      // 创建着色器
      const shader = gl.createShader(type);
      if (shader == null) {
        console.warn('unable to create shader');
        return null;
      }
      // 设置着色器和脚本
      gl.shaderSource(shader, source);
      // 编译着色器
      gl.compileShader(shader);
      // 检查编译结果
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.warn('Failed to compile shader: ' + gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
      }
      return shader;
    }
    // 创建着色器程序
    function createProgram(gl, vshader, fshader) {
      // 创建顶点着色器
      const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
      // 创建片元着色器
      const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
      if (!vertexShader || !fragmentShader) {
        return null;
      }
      // 创建着色器程序 https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/createProgram
      const program = gl.createProgram();
      if (!program) {
        return null;
      }
      // 添加预先定义好的顶点着色器和片元着色器
      gl.attachShader(program, vertexShader);
      gl.attachShader(program, fragmentShader);
      // 关联着色器
      gl.linkProgram(program);
      // 检查编译状态
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        var error = gl.getProgramInfoLog(program);
        console.warn('Failed to link program: ' + error);
        gl.deleteProgram(program);
        gl.deleteShader(fragmentShader);
        gl.deleteShader(vertexShader);
        return null;
      }
      return program;
    }
    // 初始化着色器
    function initShaders(gl, vshader, fshader) {
      const program = createProgram(gl, vshader, fshader);
      if (!program) {
        console.warn('Failed to create program');
        return false;
      }
      gl.useProgram(program);
      return true;
    }
    const gl = getWebGLContext(document.getElementById('webgl'));
    function drawPoint() {
      if (!initShaders(gl, vShaderScript, fShaderScript)) {
        console.warn('Failed to intialize shaders.');
        return;
      }
      // 预置背景色
      // (1.0, 0.0, 0.0, 1.0) 红色
      // (0.0, 1.0, 0.0, 1.0) 绿色
      // (0.0, 0.0, 1.0, 1.0) 蓝色
      // (1.0, 1.0, 0.0, 1.0) 黄色
      // (1.0, 0.0, 1.0, 1.0) 紫色
      // (0.0, 1.0, 1.0, 1.0) 青色
      // (1.0, 1.0, 1.0, 1.0) 白色
      gl.clearColor(0.0, 0.0, 0.0, 1.0); // 黑色
      // 设置背景色
      gl.clear(gl.COLOR_BUFFER_BIT);
      // 画圆点 https://developer.mozilla.org/zh-CN/docs/Web/API/WebGLRenderingContext/drawArrays
      gl.drawArrays(
        gl.POINTS, // 绘制一系列点
        0, // 从第0个点开始绘制
        1  // 绘制需要用到1个点
      );
    }
  </script>
</body>
</html>

启动本地服务加载 html 资源查看最终效果

4.4 知识点 - Web Worker

Web Worker 是浏览器为了脚本在后台线程中运行提供了一种简单的方法,并且执行任务时不干扰用户界面。

Web Worker 的作用就是为 JavaScript 创造多线程环境,允许主进程创建 Worker 进程,将一些任务分配给后者运行。在主进程运行的同时,Worker 进程在后台运行,两者互不干扰。等到 Worker 进程完成计算任务,再把结果返回给主进程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 进程负担了,主进程(如:UI 交互)就会比较流畅,不会被阻塞或拖慢。

4.4.1 实战演练

实现简单的主、子进程之间的数据互通。

注意:postMessage 传输的内容中包含 TypedArray,需要将它的 buffer 转移给接收方用于提高性能,如:postMessage(uint8, [uint8.buffer])

<html>
<head>
  <title>hello worker</title>
  <style>
    section {
      font-size: 20px;
    }
    #success {
      color: #ff0000;
    }
  </style>
</head>
<body>
  <script>
    function $(selector) {
      return document.querySelector(selector);
    }
    function createWorker(codes, opts) {
      const blob = new Blob(codes);
      const url = URL.createObjectURL(blob);
      const worker = new Worker(url, opts);

      const terminate = worker.terminate;
      worker.terminate = function () {
        URL.revokeObjectURL(url);
        return terminate.call(this);
      };
      return worker;
    }
    function workerCode() {
      self.onmessage = ({ data }) => {
        switch(data.type) {
          case 'sum':
            postMessage({
              type: 'sum',
              payload: data.a + data.b
            });
            break;
        }
      }
      postMessage({
        type: 'loaded'
      });
    }
    const worker = createWorker([`(${workerCode.toString()})()`]);
    worker.onmessage = ({ data }) => {
      switch(data.type) {
        case 'loaded':
          $('#success').innerHTML = "初始化Worker成功";
          break;
        case 'sum':
          $('#result').innerHTML = data.payload;
          break;
      }
    };
    function onSum(a, b) {
      worker.postMessage({type: 'sum', a, b});
    }
  </script>
  <section id="success"></section>
  <section id="error"></section>
  <section>
    <span>1 + 1</span>
    <button onclick="onSum(1,1);">=</button>
    <span id="result"></span>
  </section>
</body>
</html>

启动本地服务加载 html 资源查看最终效果

Respect