typescript 从零开发H5视频播放器

843 阅读21分钟

前言

目前比较流行的开源视频播放器应该就是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标签上面的事件也需要重新进行监听。

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

class VideoPlayer {
  // ...
  private switchVideo() {
    // 先获取原来的video标签
    const { videoElement: prevVideoElement, containerElement } =
      this.playerInstance.templateInstance;
    // 获取视频播放地址
    const videoItem = this.getVideoItem();
    // 清晰度切换前
    this.playerInstance.$emit(PlayerEvents.SWITCH_DEFINITION_START, videoItem);
    // video标签原来的状态
    const prevStatus = {
      currentTime: prevVideoElement.currentTime,
      paused: prevVideoElement.paused,
      playbackRate: prevVideoElement.playbackRate,
      volume: prevVideoElement.volume,
    };
    // 获取video的html
    const videoHtml = videoTpl({
      ...this.playerInstance.options,
    });
    // 将字符串转化为dom,生成新的video标签
    const nextVideoElement = new DOMParser().parseFromString(
      videoHtml,
      "text/html"
    ).body.firstChild as HTMLVideoElement;
    // 旧的video标签暂停播放
    prevVideoElement.pause();
    // 新的video标签插入到旧的video标签前,也就是新的video标签在旧的video标签下方
    containerElement.insertBefore(nextVideoElement, prevVideoElement);
    // 初始化新的video标签
    this.initESM(nextVideoElement, videoItem);
    // 设置新video标签的状态
    nextVideoElement.currentTime = prevStatus.currentTime;
    nextVideoElement.volume = prevStatus.volume;
    nextVideoElement.playbackRate = prevStatus.playbackRate;
    if (!prevStatus.paused) {
      nextVideoElement.play();
    }
    // 监听新的video标签的canplay事件
    this.playerInstance.$once(VideoEvents.CANPLAY, () => {
      // 这个时候说明新的video标签已经准备好了,可以移除旧的video标签了,这样子就可以完美解决切换清晰度闪屏的问题了
      containerElement.removeChild(prevVideoElement);
      // 清晰度切换完毕
      this.playerInstance.$emit(PlayerEvents.SWITCH_DEFINITION_END, videoItem);
      // 设置通知
      this.playerInstance.setNotice(t("switch", { quality: videoItem?.label }));
    });
  }
}

广播原生 video 标签的事件

由于切换清晰度的时候,我们选择了插入新的video标签,然后删除旧的vdieo标签,这样势必会导致其他组件监听video标签的事件失效(除非监听视频清晰度切换事件,然后重新进行监听,但这个很麻烦,每个组件都要重复写同样的代码去监听清晰度切换并重新初始化video标签的事件监听)。为了规避这个问题,我们需要在初始化完video标签之后,重新监听video标签的事件,然后通过$emit广播出去。其他组件需要监听video标签事件是通过$on去监听,而不是通过video.addEventListener去监听。代码如下:

class VideoPlayer {
  private initESM(videoElement: HTMLVideoElement, videoItem: VideoListItem) {
    // ..
    this.initVideoEvents(videoElement);
  }

  // 初始化video标签事件
  private initVideoEvents(videoElement: HTMLVideoElement) {
    // 外部统一使用$on来进行监听,因为切换清晰度之后,video标签会被替换掉,所有事件需要重新监听
    for (const key in VideoEvents) {
      const eventName = (VideoEvents as any)[key];
      videoElement.addEventListener(eventName, (event) => {
        this.playerInstance.$emit(eventName, event);
      });
    }
  }
}

注意事项

当其他组件需要读取原生video标签时,请不要在组件初始化的时候缓存video标签,而是每次动态去读取,因为清晰度切换的时候,会删除旧的video标签,插入新的video标签。当然,出于对性能的考虑,你也可以对video标签进行缓存,然后监听switch_definition_end事件,重新刷新video标签的缓存

控制器的显示和隐藏

控制器的显示和隐藏 pc 端和移动端的交互方式是不一样的。

PC 端:视频暂停的时候,显示控制器。视频播放的时候,如果鼠标在播放器内,则显示控制器;如果鼠标在播放器外,则在鼠标移出播放器的四秒后隐藏控制器。

移动端:用户点击非播放按钮的区域,就显示/隐藏控制器,同时播放按钮也要跟着显示/隐藏。

代码如下:

class VideoControls {
  private initListener() {
    if (!this.playerInstance.isMobile) {
      // pc端的情况
      // 监听video标签的play/pause事件
      this.playerInstance.$on(VideoEvents.PLAY, this.onVideoPlay.bind(this));
      this.playerInstance.$on(VideoEvents.PAUSE, this.onVideoPause.bind(this));
      // 监听鼠标的mouseenter/mouseleave事件
      const containerElement =
        this.playerInstance.templateInstance.containerElement;
      this.eventManager.addEventListener({
        element: containerElement,
        eventName: "mouseenter",
        handler: this.onMouseenter.bind(this),
      });
      this.eventManager.addEventListener({
        element: containerElement,
        eventName: "mouseleave",
        handler: this.onMouseleave.bind(this),
      });
    }
    this.eventManager.addEventListener({
      element: this.playerInstance.templateInstance.videoMaskElement,
      eventName: "click",
      handler: this.onVideoMaskClick.bind(this),
    });
  }

  private onVideoMaskClick() {
    if (this.playerInstance.isMobile) {
      // 移动端点击遮罩层,显示/隐藏控制器
      this.toggleMobileControls();
    } else {
      // pc端点击遮罩层,切换播放状态
      this.playerInstance.toggle();
    }
  }

  private toggleMobileControls() {
    this.isEnter = !this.isEnter;
    if (this.isEnter) {
      this.hide();
    } else {
      this.show();
    }
  }

  // 鼠标进入容器事件处理
  private onMouseenter() {
    this.isEnter = true;
    this.showControls();
  }
  // 鼠标离开容器事件处理
  private onMouseleave() {
    this.isEnter = false;
    this.hideControls();
  }
  // 视频播放事件处理
  private onVideoPlay() {
    this.hideControls();
  }
  // 视频暂停事件处理
  private onVideoPause() {
    this.showControls();
  }
  // 显示控制条
  private showControls() {
    // 非播放状态,或者鼠标在播放器内,显示出来
    if (this.playerInstance.paused || this.isEnter) {
      this.show();
    }
  }
  // 隐藏控制条
  private hideControls(time = 4000) {
    // 销毁定时器
    this.destroyTimer();
    // 4秒后隐藏
    this.timer = window.setTimeout(() => {
      if (!this.playerInstance.paused && !this.isEnter) {
        this.hide();
      }
    }, time);
  }

  private show() {
    this.playerInstance.templateInstance.controlsElement.style.transform = "";
    this.playerInstance.$emit(PlayerEvents.SHOW_CONTROLS);
  }

  private hide() {
    this.playerInstance.templateInstance.controlsElement.style.transform =
      "translateY(100%)";
    this.playerInstance.$emit(PlayerEvents.HIDE_CONTROLS);
  }
}

显示时间

主要显示当前的播放时间和视频的总时间。

播放时间可通过监听video标签的timeupdate事件实时获取。视频总时间可通过监听video标签的loadedmetadata事件获取。

由于timeupdate事件触发的频率比较高,所以需要进行防抖,控制在一秒内执行一次,具体做法是获取上一次的秒数,然后跟当前秒数进行对比,判断是否为同一秒,同一秒就不执行下面的操作。

代码如下:

class VideoTime {
  // video标签获取媒体数据事件
  private onVideoLoadedmetadata(event: Event) {
    const videoElement = event.target as HTMLVideoElement;
    const duration = videoElement.duration || 0;
    // 设置总时长
    this.setTotalTime(duration);
  }
  // video标签正在播放事件
  private onVideoTimeupdate(event: Event) {
    const videoElement = event.target as HTMLVideoElement;
    const currentTime = videoElement.currentTime || 0;
    const intCurrentTime = Math.floor(currentTime);
    const intPrevTime = Math.floor(this.currentTime);
    // 保证每一秒执行一次
    if (intCurrentTime === intPrevTime) {
      return;
    }

    this.currentTime = currentTime;
    // 设置当前时间
    this.setCurrentTime(currentTime);
  }
  // 设置总时长
  private setTotalTime(duration: number) {
    const totalTimeElement =
      this.playerInstance.templateInstance.totalTimeElement;
    totalTimeElement.innerHTML = secondToTime(duration);
  }
  // 设置当前时长
  private setCurrentTime(currentTime: number) {
    const currentTimeElement =
      this.playerInstance.templateInstance.currentTimeElement;
    currentTimeElement.innerHTML = secondToTime(currentTime);
  }
}

全屏

全屏分为网页全屏和浏览器全屏。

网页全屏实际上就是固定定位,然后设置宽高为100%,同时还要设置一下z-index层级。

浏览器全屏是借助 html5 的 api 进行浏览器全屏。

代码如下:

class VideoFullscreen {
  // 网页全屏事件处理
  private onWebFullscreen() {
    if (isBrowserFullscreen()) {
      // 浏览器全屏的情况下先退出浏览器全屏
      exitBrowserFullscreen();
    }
    if (this.isWebFullscreen) {
      this.exitWebFullscreen();
    } else {
      this.enterWebFullscreen();
    }
  }
  // 浏览器全屏事件处理
  private onBrowserFullscreen() {
    if (!isBrowserFullscreen()) {
      this.enterBrowserFullScreen();
    } else {
      this.exitBrowserFullscreen();
    }
  }
  // 进入浏览器全屏
  enterBrowserFullScreen() {
    if (this.isWebFullscreen) {
      // 如果是网页全屏需要先退出
      this.exitWebFullscreen();
    }
    const containerElement =
      this.playerInstance.templateInstance.containerElement;
    if (!isUndef(containerElement) && !isBrowserFullscreen()) {
      enterBrowserFullScreen(containerElement);
      this.playerInstance.$emit(PlayerEvents.ENTER_BROWSER_SCREEN);
    }
  }
  // 退出浏览器全屏
  exitBrowserFullscreen() {
    if (this.isWebFullscreen) {
      // 如果是浏览器全屏需要先退出
      this.exitWebFullscreen();
    }
    if (isBrowserFullscreen()) {
      exitBrowserFullscreen();
      this.playerInstance.$emit(PlayerEvents.EXIT_BROWSER_SCREEN);
    }
  }
  // 退出网页全屏
  exitWebFullscreen() {
    this.isWebFullscreen = false;
    const containerElement =
      this.playerInstance.templateInstance.containerElement;
    if (containerElement.classList.contains(WEBFULLSCREENCLASSNAME)) {
      containerElement.classList.remove(WEBFULLSCREENCLASSNAME);
      this.playerInstance.$emit(PlayerEvents.EXIT_WEB_SCREEN);
    }
  }
  // 进入网页全屏
  enterWebFullscreen() {
    this.isWebFullscreen = true;
    const containerElement =
      this.playerInstance.templateInstance.containerElement;
    if (!containerElement.classList.contains(WEBFULLSCREENCLASSNAME)) {
      containerElement.classList.add(WEBFULLSCREENCLASSNAME);
      this.playerInstance.$emit(PlayerEvents.ENTER_WEB_SCREEN);
    }
  }

  private onKeypress(event: KeyboardEvent) {
    // 按下esc键,键盘左上角
    if (event.keyCode === KeyCodeEnum.esc && this.isWebFullscreen) {
      this.exitWebFullscreen();
    }
  }
}

export default VideoFullscreen;

进度条

进度条需要显示 2 个进度,一个是当前的时间进度,通过监听timeupdate事件更新。一个是缓冲的进度,通过监听progress事件更新,代码如下:

class VideoProgress {
  // timeupdate事件处理,视频正在播放
  private onVideoTimeupdate(event: Event) {
    const videoElement = event.target as HTMLVideoElement;
    const currentTime = videoElement.currentTime || 0;
    const intCurrentTime = Math.floor(currentTime);
    const intPrevTime = Math.floor(this.currentTime);
    // 保证每一秒触发一次,防抖
    if (intCurrentTime === intPrevTime) {
      return;
    }
    this.currentTime = currentTime;
    if (!this.isMousedown) {
      // 不是在拖拽进度条的时候需要更新进度条
      this.setPlayedProgress();
    }
  }
  // progress事件处理,视频缓冲事件
  private onVideoProgress(event: Event) {
    const videoElement = event.target as HTMLVideoElement;
    if (videoElement.buffered?.length !== 0) {
      const preloadTime = videoElement.buffered.end(0) || 0;
      this.setLoadedProgress(preloadTime);
    }
  }

  // 设置已播放的进度
  private setPlayedProgress() {
    const videoPlayedElement =
      this.playerInstance.templateInstance.videoPlayedElement;
    const duration = this.playerInstance.duration;
    const currentTime = this.currentTime;
    if (duration > 0 && currentTime > 0) {
      // 计算出百分比
      let percent = currentTime / duration;
      percent = checkData(percent, 0, 1);
      videoPlayedElement.style.width = `${percent * 100}%`;
    }
  }
  // 设置缓冲的进度
  private setLoadedProgress(preloadTime: number) {
    const videoLoadedElement =
      this.playerInstance.templateInstance.videoLoadedElement;
    const duration = this.playerInstance.duration;
    if (duration > 0) {
      let percent = preloadTime / duration;
      percent = checkData(percent, 0, 1);
      videoLoadedElement.style.width = `${percent * 100}%`;
    }
  }
}

进度条还有一个拖拽进度的功能,但是上面已经说过拖拽行为了,所以这里不说了

显示/隐藏 loading

视频卡顿的时候会触发waiting事件,这是需要显示loading。视频可进行播放的时候会触发canplay事件,此时需要隐藏loading。还有切换清晰度前(switch_definition_start事件)也需要显示loading,切换完成之后会触发canplay事件,所以不需要监听切换清晰度后事件(switch_definition_end事件)。

这里有 2 个事件需要注意:canplaycanplaythrough事件。canplay是指可以播放,但可能因缓冲停止时触发。canplaythrough也是指可以播放,且无需因缓冲而停止。但是 ie 上面并不会触发canplaythrough事件。

waiting事件在火狐浏览器上面是有点问题的,就是视频是可以播放的,但是也会触发waiting事件,就是waiting事件是在canplay事件后面触发的,所以需要在waiting事件中判断一下video标签的readyState属性是否为4,为4说明视频已经是就绪状态了,这个时候不需要显示loading

代码如下:

class VideoLoading {
  // waiting事件处理,视频缓冲事件
  private onVideoWaiting(event: Event) {
    const target = event.target as HTMLVideoElement;
    if (target.readyState !== VideoReadyStateEnum.complete) {
      // 显示loading
      this.showLoading();
    }
  }

  // canplay事件处理,视频可播放事件
  private onVideoCanplay() {
    this.hideLoading();
  }
  // switch_definition_start事件处理,切换清晰度的时候也要显示loading
  private onBeforeSwitchDefinition() {
    this.showLoading();
  }
  // 显示loading
  private showLoading() {
    const loadingWrapperElement =
      this.playerInstance.templateInstance.loadingWrapperElement;
    loadingWrapperElement.style.display = "flex";
  }
  // 隐藏loading
  private hideLoading() {
    const loadingWrapperElement =
      this.playerInstance.templateInstance.loadingWrapperElement;
    loadingWrapperElement.style.display = "";
  }
}

快捷键

首先我们定义了 5 个快捷键,分别是:空格键(切换播放状态),↑(音量增大),↓(音量减少),←(时间后退,直播不能后退),→(时间前进,直播不能前进)。还有一点就是只有播放器处于活跃的状态时才可以触发这些快捷键,活跃状态是指用户点击过播放器,当点击播放器之外的地方时,播放器就是非活跃状态。同时还有就是移动端没有这些键盘事件,所以移动端就不需要初始化快捷键这个功能了。代码如下:

class ShortcutKey {
  // ...

  // 标志位,标记用户是否点击了播放器,也就是播放器是否处于活跃状态
  private isFocus = false;
  // 点击播放器外的元素时吧标志位置为false
  private onDocumentClick() {
    this.isFocus = false;
  }
  // 点击播放器
  private onPlayerContainerClick(event: MouseEvent) {
    // 要组织冒泡,防止冒泡到document中
    event.stopPropagation();
    this.isFocus = true;
  }
  // 键盘事件处理
  private onDocumentKeyup(event: KeyboardEvent) {
    // 获取获得焦点的元素
    const activeTag: any = document.activeElement?.nodeName.toUpperCase();
    // 获取元素是否可编辑
    const editable = document.activeElement?.getAttribute("contenteditable");
    // 看看获得焦点的元素是不是input或者textarea
    const flag = Object.values(CanFocusTagEnum).includes(activeTag);
    if (!flag && editable !== "" && editable !== "true" && this.isFocus) {
      // 不可编辑并且播放器处于活跃状态
      this.handleKey(event);
    }
  }
  private handleKey(event: KeyboardEvent) {
    event.preventDefault();
    const { live } = this.playerInstance.options;
    switch (event.keyCode) {
      case KeyCodeEnum.space:
        // 按下空格键切换播放状态
        this.playerInstance.toggle();
        break;
      case KeyCodeEnum.left:
        // 按下左箭头,时间后退5秒
        if (!live) {
          // 直播不能后退
          this.playerInstance.seek(this.playerInstance.currentTime - 5);
        }
        break;
      case KeyCodeEnum.right:
        if (!live) {
          // 按下右箭头,时间前进5秒
          this.playerInstance.seek(this.playerInstance.currentTime + 5);
        }
        break;
      case KeyCodeEnum.up:
        // 按下上箭头,音量增大
        this.playerInstance.setVolume(this.playerInstance.volume + 0.1);
        break;
      case KeyCodeEnum.down:
        // 按下下箭头,音量减少
        this.playerInstance.setVolume(this.playerInstance.volume - 0.1);
        break;
    }
  }
}

其他组件开发

经过上面的前期准备,剩下的组件开发已经显得很简单了,就是监听一下video标签的事件,操作一下 dom。下面有几点是需要注意的

  • 如果是监听timeupdate事件,video-progress播放进度条组件和video-time时间(当前时间/总时间)都会使用到该事件,最好是进行防抖,控制在一秒内执行一次,具体做法是获取上一次的秒数,然后跟当前秒数进行对比,判断是否为同一秒,同一秒就不执行下面的操作,代码如下:
function onVideoTimeupdate(event: Event) {
  const videoElement = event.target as HTMLVideoElement;
  const currentTime = videoElement.currentTime || 0;
  const intCurrentTime = Math.floor(currentTime);
  // 获取上一次记录的秒数
  const intPrevTime = Math.floor(this.currentTime);
  // 保证每一秒触发一次,防抖
  if (intCurrentTime === intPrevTime) {
    return;
  }
  // 保存当前秒数
  this.currentTime = currentTime;
  // ...
}
  • 如果组件内部有其他副作用代码,比如定时器,注册在document或者window对象上面的事件,需要监听destroy事件,然后销毁定时器和事件。

  • 监听原生video标签的事件使用$on,而不是video.addEventListener。否侧你需要自行监听切换清晰度的事件,然后重新监听原生video标签的事件。

  • 如果需要使用到原生video标签,在使用到的时候再去获取,不要在组件初始化的时候就获取保存下来。否则你需要自行监听切换清晰度的事件,然后刷新video标签的缓存值

国际化功能实现

  • 我们的语言包是一个 json 文件,如下:
{
  "live": "直播",
  "goBack": "快退{time}秒",
  "fastForward": "快进{time}秒",
  "volume": "音量{volume}",
  "switch": "已经切换至{quality}",
  "invalidDefinition": "无效清晰度"
}
  • 我们需要使用实现一个函数去获取对应的文本,使用形式如下:
t("live");
// 或者
t("goBack", { time: 10 });
  • t 函数如下:
import zhLang from "./lang/zh.json";
let lang: LangOptions = zhLang;
export const t = function t(path: string, options?: Record<string, any>) {
  let value;
  const array = path.split(".");
  // 当前使用的语言包
  let current: any = lang;
  for (let i = 0, j = array.length; i < j; i++) {
    const property = array[i];
    value = current[property];
    if (i === j - 1) return formatLangTemplate(value, options);
    if (!value) return "";
    current = value;
  }
  return "";
};
  • 我们还需要一个设置使用哪种语言的函数,还有一个可以给用户自定义语言包的函数,代码如下:
// 自定义语言,跟默认语言进行合并
export const use = function use(l: LangOptions) {
  if (isPlainObject(l)) {
    lang = { ...lang, ...l };
  }
};
// 设置使用哪种语言
export const setLang = function setLang(la: string) {
  lang = la === LangTypeEnum.en ? enLang : zhLang;
};
  • 考虑到插件可能也需要实现国际化,所以我们需要把一些属性当做静态方法,挂载到类上面,方便插件读取。代码如下:
class MediaPlayer {
  // 默认语言
  static lang = LangTypeEnum.zh;
  // 自定义语言包
  static langObject: Record<string, any> = {};
  // 自定义语言包
  static useLang(langObject: LangOptions) {
    i18n.use(langObject.player);
    MediaPlayer.langObject = langObject;
    return MediaPlayer;
  }
  // 设置中英文,zh/en
  static setLang(lang: LangTypeEnum) {
    i18n.setLang(lang);
    MediaPlayer.lang = lang;
    return MediaPlayer;
  }
}

比如用户设置了使用英文,那么插件需要读取lang静态属性的值来初始化对应的语言包。

设置自定义语言包的时候也需要考虑插件,使用如下:

MediaPlayer.useLang({
  player: {
    // ...
  },
  // 弹幕插件自定义语言包
  Danmaku: {
    // ...
  },
});

弹幕插件可通过MediaPlayer.langObject.Danmaku获取对应的自定义语言包

插件

介绍

首先插件分为全局注册和局部注册,全局插件是所有播放器实例都会有全局插件的功能,局部插件是只有当前播放器实例才会有局部插件的功能。当然,我们也可以在初始化播放器实例的时候,通过播放器的options参数传入key值为插件的标识,value值为false的键值对,关闭某一个插件的功能(不会初始化插件)。

  • 全局插件是通过MediaPlayer.use(ctor: Function)进行注册的

  • 局部插件是通过播放器的 options 参数中的 plugins 字段进行注册的

为什么要设计局部插件和全局插件?

  • 当你有 10 个播放器实例,其中有 9 个播放器实例需要使用到A插件,那么这个时候可以将A插件注册为全局插件,这 9 个播放器实例就不要每次初始化的时候都注册一次了,剩下的一个播放器实例不需要A插件可以手动关闭掉该插件的功能

  • 当你有 10 个播放器实例,其中只有一个播放器实例需要使用到A插件,那么这个时候可以将A插件注册为局部插件,剩下的 9 个播放器实例也就不会有A插件的功能了。

实现

首先我们需要有一个静态属性用来存储全局注册的插件,然后还需要有一个静态方法给用户全局注册插件。当初始化播放器的时候,会将全局插件和局部插件合并成一个新的数组,然后通过插件的唯一标识(插件的静态属性pluginName,没有pluginName就是使用构造函数的name)过滤掉那些重复的插件。然后再检查options参数中是否包含[pluginName/name]:false的键值对,如果有,也会过滤掉这部分的插件。最终的到需要进行初始化的插件,然后进行初始化。代码如下:

function getPluginName(ctor: any) {
  return ctor.pluginName || ctor.name;
}

class MediaPlayer {
  // 全局插件列表
  static pluginsList: PluginsType = [];

  // 全局注册插件
  static use(ctor: Function) {
    const installed = MediaPlayer.pluginsList.some((plugin) => {
      return getPluginName(plugin) === getPluginName(ctor);
    });
    if (installed) {
      logWarn("插件已经被安装了");
      return MediaPlayer;
    }
    MediaPlayer.pluginsList.push(ctor);
    return MediaPlayer;
  }

  options: PlayerOptions;
  // 存储插件实例
  plugins: Record<string, any> = {};

  constructor(options: PlayerOptions) {
    this.options = options;
    // ...
    // 初始化插件
    this.applyPlugins();
  }

  private applyPlugins() {
    const el = (this.options.el as HTMLElement).querySelector(
      ".player-container"
    );

    // 合并全局插件和局部插件
    const plugins = this.getPluginsList();

    plugins.forEach((Ctor: any) => {
      // 初始化插件,插件会得到2个参数
      const instance = new Ctor(el, this);
      const pluginName = getPluginName(Ctor);
      this.plugins[pluginName] = instance;
    });
  }

  private getPluginsList() {
    // 局部插件
    const localPlugins = this.options.plugins || [];
    // 全局插件
    const globalPlugins = MediaPlayer.pluginsList || [];
    // 插件数组
    const pluginsList = [...globalPlugins, ...localPlugins];
    const list: PluginsType = [];
    for (let i = 0; i < pluginsList.length; i++) {
      const plugin = pluginsList[i];
      // 获取插件的唯一标识
      const pluginName = getPluginName(plugin);
      // 查看是否已经注册过(是否重复了)
      const installed = list.some((ctor) => {
        return getPluginName(ctor) === pluginName;
      });
      if (!installed && this.options[pluginName] !== false) {
        list.push(plugin);
      }
    }
    return list;
  }
}

插件说明

从上面可以看见,插件是通过new去初始化的,所以插件必须是一个构造函数(类)。然后构造函数(类)会接受到 2 个参数,分别如下:

  • el:整个播放器的 dom 元素,当你需要获取某个元素时,请使用el.querySelector(),而不是document.querySelector()。因为当你同时初始化了 2 个播放器的时候,document.querySelector()获取的始终是第一个元素

  • instance:播放器实例,即new MediaPlayer(),你可以使用该实例提供的任意方法。

插件代码示例

Test 插件:

class Test {
  // 提供一个pluginName静态属性
  static pluginName = "Test";
  el = null;
  instance = null;

  constructor(el, instance) {
    // 保存接受到的两个参数
    this.el = el;
    this.instance = instance;
    // 往播放器实例中添加一个sleep方法
    instance.extend({
      sleep: () => {
        console.log("sleep");
      },
    });
    // 开始实现其他的功能
    this.init();
  }
  // 往播放器上面追加一个悬浮按钮,点击的时候发射click自定义事件,并对播放状态进行切换
  init() {
    const div = document.createElement("div");
    div.innerHTML = "切换播放状态";
    div.addEventListener("click", () => {
      // 播放器继承了EventEmit类,通过发布订阅模式,实现事件的监听和发射
      this.instance.$emit("test-click");
      // 切换播放器的播放状态
      this.instance.toggle();
    });
    // 添加到播放器中
    this.el.appendChild(div);
  }
}

使用插件:

import MediaPlayer from "@lin-media/player";

// 全局注册插件
MediaPlayer.use(Test);

const player = new MediaPlayer({
  // ...
  // 或者通过局部注册
  // plugins:[Test]
  // 关闭插件功能
  // Test:false
});

// 监听Test插件发射出来的事件
player.$on("test-click", () => {
  console.log("test-click");
});
// Test插件在播放器实例上面挂载的方法
player.sleep();
// 访问Test插件的实例
player.plugins.Test;

自定义主题

跟以往自定义主题的实现方案不同,我们使用的是 css 原生变量来实现自定义主题的。

以往一些自定义主题方案都是准备好一个变量文件,通过修改变量文件的变量来实现自定义主题的,这样做最大的弊端就是:

  • 每次修改需要重新打包

  • 如果需要实现类似换肤功能的需求,还需要准备多套样式

  • 不能通过 js 灵活控制

考虑到播放器使用到的样式变量并不是很多,所以决定用 css 原生变量来解决上述的问题,使用css原生变量的好处有:

  • 通过复写 css 变量,将原有的变量进行覆盖,就可以轻松换主题色,无需进行重新打包。

  • 动态改变主题色,由于 css 原生变量可以通过 js 进行修改,所以你可以轻松的变换播放器的主题色。

你可以通过下面两种方式来改变主题色:

  • 通过 css 改变主题色
:root {
  --player-theme: green;

  /* ... */
}
  • 通过 js 改变主题色
document.documentElement.style.setProperty("--player-theme", "green");

// ...

下面说一下 css 原生变量怎么使用

.color {
  color: var(--theme-color, #fff);
}

我们需要使用 var 来使用一个变量,其中第一个参数是一个变量名,必填;第二个参数是默认值,可选,当找不到对应的变量名时,就会使用这个默认值,推荐大家都填写这个值。

然后就是变量名的定义和查找。变量名的定义必须以--开头。查找规则是从当前元素开始找,一直往上查找其父元素,父元素的父元素,如此类推,当找到最顶层元素,也就是html元素的时候,如果还是找不到,color:var(--theme-color,#fff)就会使用默认变量,也就是#fff。下面举几个简单的例子

例子一:

<div class="box">
  <div class="brother"></div>
  <div class="text">123123</div>
</div>
:root {
  --text-color: red;
}
.box {
  --text-color: green;
}
.brother {
  --text-color: blue;
}
.text {
  color: var(--text-color,#fff);
}

.text最终颜色为green,因为其父元素.box存在--text-color样式变量

例子二:

<div class="box">
  <div class="brother"></div>
  <div class="text">123123</div>
</div>
:root {
  --text-color: red;
}
.brother {
  --text-color: blue;
}
.text {
  color: var(--text-color,#fff);
}

.text最终颜色为red,因为一直找到根元素才发现--text-color样式变量,:root就是指根元素,即html

例子三:

<div class="box">
  <div class="brother"></div>
  <div class="text">123123</div>
</div>
.brother {
  --text-color: blue;
}
.text {
  color: var(--text-color,#fff);
}

.text最终颜色为#fff,因为一直往上找,找不到--text-color样式变量,所以使用默认值

上面三个例子中,都存在.brother,但是大家不要被这个.brother误导,变量的查找规则是,找他的父元素,父元素找不到,再找他的父元素的父元素,如此类推。.brother只是.text的兄弟元素。

这里推荐大家把样式变量都写在根元素当中,也就是:root,方便查找和管理

总结

其实要实现一个播放器的功能并不是很难,只要你了解了video标签的属性和事件之后,很多问题就可以迎刃而解。

在开发这个播放器的时候我也遇见了不少困难,比如视频切换清晰度的时候会闪屏,自己想了一些解决方案,但是不是很好,然后去查看了dplayer的源码,最终借鉴了dplayer的实现方式。

开发的时候,我是先实现了 pc 端的功能,然后再去实现移动端的功能,其实移动端和 pc 端在功能上面没多大区别,只是少了一些按钮,还有一些交互方式有所不一样。

我觉得自己在实现播放器的过程中做的比较好的地方有:

  • 将播放器拆分成多个组件,实现组件化的开发,这样就可以将每个部分进行解耦,减少耦合度,独立开发维护

  • 插件机制。其实插件这部分的功能主要是借鉴了better-scroll。插件这个功能,可以将一些不是经常使用的功能分离出去,这些插件也是可以单独进行开发和维护的。需要使用的时候在进行安装下载,减少了代码的打包体积,天然支持按需加载。

当然,还有一些做的不好的地方:

  • 急于动手,欠缺整体思考。很多东西都是想到就搞,没有考虑到整体,导致后面在开发的时候发现了一些问题,需要重新去修改

  • 架构设计上面做的不是很好。播放器开发完成之后,我下载了dplayer的源码,对比了一下自己的架构设计很dplayer的架构设计,发现一些地方设计的不是很好。后来参考了dplayer的架构设计,改进了一下自己的架构设计。

最后,感兴趣的同学可点击下方链接查看源码