typescript 从零开发视频播放器

Posted 在厕所喝茶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了typescript 从零开发视频播放器相关的知识,希望对你有一定的参考价值。

前言

目前比较流行的开源视频播放器应该就是dplayer,我们公司现在也是正在使用这个开源的视频播放器。但是有些需求dplayer并没有实现,或者是功能有了但是技术上跟公司的所用的方案有所不一样。比如直播弹幕,dplayer需要去实现一个websocket,但是我们是通过发送请求去轮询获取弹幕,所以这一块上面就有很大冲突。而且有些需求我们是直接在dplayer源码上面进行修改或者新增,代码体积越滚越大,这也导致我们维护起来十分困难。所以我就想着自己实现一个视频播放器,功能以及 api 方面尽量向dplayer靠近,减少大家的学习成本和降低上手难度,并以此解决上面的难题。下面我就把自己开发的视频播放器统称为MediaPlayer

项目架构设计

  • 插件化:我们需要实现一个插件的功能,目的是为了可以针对某些需求去扩展功能。比如弹幕等功能,可以做成一个插件。同时插件化天然支持按需加载的功能,只有当你需要用该插件的时候才会去安装下载,这也解决打包出来的代码体积过大的问题。

  • 多包架构:由于我们需要实现一个插件的功能,那么,势必上会有很多子项目,如果这些子项目都是一个独立的仓库,那么将会加大维护的成本。所以,这里使用lerna这个工具来实现一个多包项目,即一个仓库,多个子项目。这也就降低模块与模块之间的耦合度了

  • 核心包:我们需要实现一个具有核心功能的包,也就是我们的视频播放器。核心包需要提供一些常用的api暴露给外面使用,比如视频的播放/暂停,音量设置等等,这些都是一个播放器需要具备的功能。

  • 国际化:由于播放器涉及到一些文字的提示,所以我们要实现多语言显示。播放器默认提供中/英文语言包,如果用户有需要可自行添加其他语言。

技术栈

  • typescript:这么受欢迎的语言没有道理不选用是吧。

  • scss:工作中使用的就是scss

  • art-template:html 模板引擎,开发视频播放器过程中会有一大堆的字符串拼接操作,所以找了这个工具来简化字符串拼接的繁琐。

  • webpack:一开始想用rollup作为打包构建工具的,因为rollup的配置比较简单,不像webpack那样需要安装一大堆的loader。但是最后因为找不到art-template的相关处理插件,只能放弃rollup

双端支持

我们的视频播放器需要支持移动端和 pc 端。所以编写样式和 dom 结构的时候需要考虑 pc 端和移动端。一些功能和交互方式也需要区分移动端和 pc 端

元素的显示和隐藏说明

有些元素的显示和隐藏为了有过渡动画的效果,并不是使用display:none/block进行控制,而是使用opacity:0/1,比如移动端的播放按钮,进度条的时间点提示等等。这些都有一个共同的特点,就是不需要响应事件,所以通过opacity:0/1控制显示和隐藏的元素,还需要添加一个pointer-events: none的样式属性,防止影响到其他元素的事件响应。

组件化开发

我们将遵循组件化开发的思想,将播放器拆分成多个组件进行开发,这样就可以将播放器的各个部分进行解耦。目前拆分的组件有:

  • mobile-play-button移动端播放按钮
  • video-controls控制条
  • video-fullscreen全屏
  • video-loading加载提示
  • video-play-buttonpc 端播放按钮
  • video-playervideo 标签
  • video-progress播放进度条
  • video-speed视频倍数
  • video-time时间(当前时间/总时间)
  • video-tip提示通知
  • video-volume音量控制

组件之间的通讯

组件与组件之间可能需要进行相互通讯。比如用户触发了destroy事件,需要广播到各组件中,让各组件内部自行进行一些销毁工作。这个时候就需要用到我们前端界非常常见的设计模式:发布订阅。代码如下:

import { isArray, isFunction } from "./is";
import { logError } from "./log";

class EventEmit {
  eventMap: Record<string, Array<Function>> = {};
  //   监听事件
  $on(eventName: string, handler: Function) {
    if (!isFunction(handler)) {
      logError("第二个参数不是函数");
      return;
    }
    if (!isArray(this.eventMap[eventName])) {
      this.eventMap[eventName] = [];
    }
    this.eventMap[eventName].push(handler);
    return this;
  }
  // 发射事件
  $emit(eventName: string, data?: any) {
    const eventList = this.eventMap[eventName] || [];
    const length = eventList.length;
    if (length > 0) {
      // 从最后一个开始执行,防止数组塌陷
      for (let i = length - 1; i >= 0; i--) {
        const fn = eventList[i];
        fn.call(this, data);
      }
    }
    return this;
  }

  //   监听一次
  $once(eventName: string, handler: Function) {
    if (!isFunction(handler)) {
      logError("第二个参数不是函数");
      return;
    }
    const fn = (...args: any) => {
      handler.call(this, ...args);
      this.$off(eventName, fn);
    };
    this.$on(eventName, fn);
    return this;
  }

  //   取消监听自定义事件
  $off(eventName: string, handler?: Function) {
    if (!this.eventMap[eventName]) {
      return;
    }
    if (!isFunction(handler)) {
      delete this.eventMap[eventName];
    } else {
      const index = this.eventMap[eventName].findIndex((fn) => fn === handler);
      if (index > -1) {
        this.eventMap[eventName].splice(index, 1);
      }
    }
    return this;
  }
  // 移除所有事件
  protected clear() {
    this.eventMap = {};
  }
}

我们只需要让最顶层组件继承这个EventEmit类,然后把最顶层组件的实例当做参数传递给各子组件,各组件就可以利用$on$emit进行通信了。

拖拽行为

整个播放器中有 2 个地方是有拖拽行为的:

  • 进度条:用户可以对进度条进行拖拽,调整当前时间点

  • 音量控制:用户可以拖动音量条,调整音量大小

这 2 个地方的拖拽逻辑都是一样的,不一样的是拖拽过程中的事件处理行为,一个是调整当前时间点,一个是调整音量大小。我们可以把拖拽逻辑封装成一个类,然后把拖拽过程中的一些事件派发出来,让外部去处理一些逻辑。

首先先说一下怎么计算鼠标距离容器的offsetLeft值。很简单,鼠标距离页面左边的距离(可通过event.pageX获取)- 容器距离页面左边的距离(通过计算获取)= 鼠标距离容器左边的距离。offsetLeft计算出来之后,只需要设置一下拖拽元素的left值即可进行 x 轴的拖拽。y 轴的拖拽同理。

现在关键点就是计算容器距离页面左边的距离,很多人一开始可能会想到使用getBoundingClientRect这个的方法,包括我一开始也是使用这个方法。getBoundingClientRect这个方法获取的是容器距离浏览器左边的距离,不是容器距离页面左边的距离,当浏览器不出现滚动条时,容器距离浏览器左边的距离 == 容器距离页面左边的距离。但是当浏览器出现滚动条的时候,容器距离浏览器左边的距离 != 容器距离页面左边的距离。考虑到可能会出现滚动条的情况,所以还需要在getBoundingClientRect的基础上加上页面滚动的距离,才能正确获取容器距离页面左边的距离。代码如下:

export default function getBoundingClientRect(
  el: HTMLElement | null | undefined
) {
  if (isUndef(el)) {
    return {
      left: 0,
      top: 0,
      right: 0,
      bottom: 0,
      width: 0,
      height: 0
    };
  }
  const scrollLeft =
    document.documentElement.scrollLeft || document.body.scrollLeft;
  const scrollTop =
    document.documentElement.scrollTop || document.body.scrollTop;
  const rect = el.getBoundingClientRect();
  const left = rect.left + scrollLeft;
  const top = rect.top + scrollTop;
  const width = rect.width;
  const height = rect.height;
  return {
    left,
    top,
    right: width + left,
    bottom: height + top,
    width,
    height
  };
}

这里我们的拖拽事件需要兼容移动端和 pc 端。移动端是通过touchstarttouchmovetouchend事件实现的,pc 端是通过mousedownmousemovemouseup事件实现的。移动端跟 pc 端的事件区别就是在于获取pageY/pageX值的路径不一样,pc 端是通过event.pageX/pageY获取,移动端是通过event.touches[0].pageX/pageY或者event.changedTouches[0].pageX/pageY获取。了解到了这些差异之后,我们只需要在event对象上做一下兼容判断,即可实现移动端和 pc 端的拖拽行为。

注意!!!! 拖拽过程中必须要给body添加一个user-select: none;样式,否则一旦用户选中了文字,就会丢失touchend/mouseup事件。

以 pc 端的拖拽为例,流程如下:

  • 给拖拽元素添加mousedown事件监听,在mousedown事件处理函数中给document添加mousemovemouseup事件,同时还要给body添加一个user-select: none;样式,防止用户选中文字。

  • mousemove 事件中,获取鼠标距离容器左边和上边的距离,然后把这些数据发射到外部,让外部执行自己的逻辑

  • mouseup 事件中,获取鼠标距离容器左边和上边的距离,然后把这些数据发射到外部,让外部执行自己的逻辑。移除添加在body上面的user-select: none;样式。同时还要移除注册在document上面的mousemovemouseup事件

interface DragOptions {
  dragElement: HTMLElement | null | undefined;
  wrapperElement: HTMLElement | null | undefined;
}

class Drag extends EventEmit {
  // 鼠标/手指是否按下
  private isMousedown = false;
  // touchmove/mousemove事件处理
  private _onMousemove: (event: any) => void;
  // touchend/mouseup事件处理
  private _onMouseup: (event: any) => void;
  // 参数
  private options: DragOptions;
  // 默认是pc端的事件
  private mousedownEventName = "mousedown";
  private mousemoveEventName = "mousemove";
  private mouseupEventName = "mouseup";
  constructor(options: DragOptions) {
    super();
    this.options = options;
    // 初始化所需要的变量和数据
    this.initVar();
    // 初始化拖拽行为
    this.initDrag();
  }
  private initVar() {
    // 绑定this
    this._onMousemove = this.onMousemove.bind(this);
    this._onMouseup = this.onMouseup.bind(this);
    // 是否为移动端
    const isMobileClient = isMobile();
    if (isMobileClient) {
      // 判断是否为移动端,是则需要改变为移动端的事件
      this.mousedownEventName = "touchstart";
      this.mousemoveEventName = "touchmove";
      this.mouseupEventName = "touchend";
    }
  }
  private initDrag() {
    // 拖拽的元素
    const dragElement = this.options?.dragElement;
    dragElement.addEventListener(
      this.mousedownEventName,
      this.onMousedown.bind(this)
    );
  }
  // 拖拽元素鼠标点击事件处理
  private onMousedown() {
    // 给body添加样式,禁止选中文字,防止鼠标/手指抬起事件丢失
    userSelect(false);
    // 鼠标/手指按下标志位
    this.isMousedown = true;
    // 注册鼠标移动事件和抬起事件
    document.addEventListener(this.mousemoveEventName, this._onMousemove);
    document.addEventListener(this.mouseupEventName, this._onMouseup);
    // 发射出去让外部处理
    this.$emit("mousedown");
  }
  private removeEventListener() {
    // 移除事件监听
    document.removeEventListener(this.mouseupEventName, this._onMouseup);
    document.removeEventListener(this.mousemoveEventName, this._onMousemove);
  }
  // 鼠标移动事件处理
  private onMousemove(event: MouseEvent) {
    if (!this.isMousedown) {
      // 一定要鼠标按下才能开始移动
      return;
    }
    // 获取left,top等值
    const data = this.getInfo(event);
    this.$emit("mousemove", data);
  }
  // 鼠标抬起事件处理
  private onMouseup(event: MouseEvent) {
    // 删除onMousedown函数中给body添加的样式
    userSelect(true);
    // 重置标志位
    this.isMousedown = false;
    // 移除事件监听
    this.removeEventListener();
    const data = this.getInfo(event);
    // 发射出去让外部处理
    this.$emit("mouseup", data);
  }
  private getInfo(event: any) {
    // 获取容器的宽度和记录页面左边的距离
    const { left, width, top, height } = getBoundingClientRect(
      this.options?.wrapperElement
    );
    // 兼容移动端事件
    if (event.touches && event.touches[0]) {
      event = event.touches[0];
    } else if (event.changedTouches && event.changedTouches[0]) {
      event = event.changedTouches[0];
    }
    // 拿到点击的位置距离容器左边的距离
    const offsetX = event.pageX - left;
    const offsetY = event.pageY - top;
    // 判断是否越界,即是否超出最大值和最小值
    const percentX = checkData(offsetX / width, 0, 1);
    const percentY = checkData(offsetY / height, 0, 1);
    return {
      offsetX,
      offsetY,
      percentX,
      percentY,
    };
  }
}

使用方式如下:

class VideoProgress {
  private initDrag() {
    const { progressMaskElement, progressBallElement } =
      this.playerInstance.templateInstance;
    this.dragInstance = new Drag({
      dragElement: progressBallElement,
      wrapperElement: progressMaskElement,
    });
    this.initDragListener();
  }
  private initDragListener() {
    // 鼠标移动
    this.dragInstance?.$on("mousemove", (data: DragDataInfo) => {});
    // 鼠标按下
    this.dragInstance?.$on("mousedown", () => {});
    // 鼠标抬起
    this.dragInstance?.$on("mouseup", (data: DragDataInfo) => {});
  }
}

初始化模板

我们将模板的初始化获取相关 dom 元素的操作放在Template类中实现。代码如下:

class Template {
  private playerInstance: PlayerConstructor;

  containerElement: HTMLElement;

  // ...

  constructor(playerInstance: PlayerConstructor) {
    this.playerInstance = playerInstance;
    // 初始化模板,插入元素
    this.initTemplate();
    // 获取所需要的元素,统一在这里获取,到时候也方便修改
    this.initElement();
  }

  private initTemplate() {
    const el = this.playerInstance.options.el as HTMLElement;
    // 使用art-template生成html字符串
    const html = templateTpl({
      ...this.playerInstance.options,
      isMobile: this.playerInstance.isMobile,
    });
    el.innerHTML = html;
  }

  private getElement<T extends ElementType>(selector: string): T {
    const el = this.playerInstance.options.el as HTMLElement;
    return el.querySelector(selector) as T;
  }

  private initElement() {
    this.containerElement = this.getElement(".player-container");
    // ...
  }
}

video 标签组件

这个组件是一个核心的组件。其功能包括播放视频,切换视频清晰度,广播原生 video 标签的事件

视频播放:
视频播放使用的是 video 标签,支持的格式有MP4WebMOgg,这几种格式的视频文件可以直接通过设置src属性然后进行播放。但是考虑到可能用户需要配合其他 esm 库播放其他格式的视频文件,我们需要添加一个参数让用户自定义 video 标签的初始化。代码如下:

class VideoPlayer {
  // ...
  private initPlayer() {
    // 获取video标签
    const videoElement = this.playerInstance.videoElement;
    // 需要进行播放的视频
    const videoItem = this.getVideoItem();
    // 初始化视频
    this.initESM(videoElement, videoItem);
  }

  private initESM(videoElement: HTMLVideoElement, videoItem: VideoListItem) {
    // 用户自定义video标签初始化
    const { customType } = this.playerInstance.options;
    if (isFunction(customType)) {
      customType(videoElement, videoItem);
    } else {
      // 其他的直接赋值
      videoElement.src = videoItem.url;
    }
  }
}

切换视频清晰度

切换视频清晰度其实就是换个播放地址,我最开始的做法就是直接替换掉原来video标签的src属性,但是会出现一个问题,就是会出现闪屏,这种体验是非常不好的,不能无缝切换。后面想到了两种方法去解决,分别如下:

  • 切换清晰度前先把当前画面截图,并且显示在video标签的上方,然后直接替换掉原来video标签的src属性,等待视频切换完成(触发了canplay事件),就把画面截图清除隐藏。这种做法的好处就是video标签没有被替换,原本注册在video标签上面的事件不用重新监听。缺点就是不能确定视频画面大小,因为用户可能会给video标签添加object-fit的 css 属性,画面会根据video标签大小进行填充,从而导致截图出来的画面跟真正的视频画面大小不一致。不过这种情况很少见,除非真的会有人去设置object-fit这个 css 属性。还有一个缺点就是需要开启跨域功能才能对画面进行截图。

  • 生成一个新的video标签,初始化好新的video标签之后,将新的video标签插入到旧的video标签下面,等新的video标签准备好之后(触发了canplay事件),再把旧的video标签从 dom 中删除。这种做法的优点就是不用考虑视频画面的大小问题,也不用考虑跨域的问题。缺点就是用到video标签的地方需要重新获取,还有注册在video标签上面的事件也需要重新进行监听。

后来综合考虑了一下决定使用第二种方案。代码如下:

typescript 从零开发视频播放器

基于TypeScript从零重构axios完整资源

[OpenGL]从零开始写一个Android平台下的全景视频播放器——2.2 使用GLSurfaceView和MediaPlayer播放一个平面视频(中)

如何在片段中播放视频

从 Angular 2 Typescript 播放 HTML 5 视频

从零開始学android&lt;mediaplayer自带播放器(视频播放).四十九.&gt;