如何设计和实现一款 Video Player.

开篇

一些视频、学习类的网站,通常都会涉及 Video 视频播放能力的支持和使用。HTML5 <video> 标签可以帮忙我们在页面中实现视频播放。

就目前,市面上也开源了一些体系成熟、开箱即用的 video 库可以快速提供给我们视频播放以及很好的 UI 操作体验,如:video.js、xgplayer 西瓜播放器 等。

但在公司业务的复杂场景下,以及产品 UI 设计上的不同,第三方库可能很难接入和满足业务需求,这将需要我们自行研发和定制视频播放能力。

通常,对于一个视频播放器,会涉及以下功能:

  1. 播放,鼠标在整个视频区域单击切换 播放/暂停,双击切换 全屏/退出全屏;
  2. 重播,鼠标点击视频区域中心的 Replay Icon 进行重播;
  3. 缓冲,视频缓冲时,区域中心展示 loading;
  4. Control 播放/暂停;
  5. Control 进度条功能;
  6. Control 进度锚点;
  7. Control 倍速;
  8. Control 音量;
  9. Control 全屏;
  10. 历史播放记录实现;
  11. 视频水印;
  12. 外部动态切换视频播放进度。

实现效果图如下:

如何设计和实现一款 Video Player.

一、熟悉 HTMLMediaElement

HTML5 video 是一个 HTMLMediaElement 类型元素,元素本身包含了一些媒体相关属性和方法。

参考文档:HTML DOM API – HTMLMediaElement

1. instance properties:

// 配置属性
.src, string, 媒体资源访问地址
.autoplay, boolean,默认 false,自动播放音视频,大多数场景下不建议使用
.controls, boolean,默认 false,显示原生操作控件界面,通常都会关闭它进行个性化定制开发
.controlsList, string, 帮助用户要屏蔽哪些原生 controls,常见的设置值有:`controlsList="nodownload nofullscreen noremoteplayback"`,设置多个值通过 空格 分割
.muted, boolean,默认 false,决定媒体资源是否静音
.loop, boolean, 默认 false,获取和设置视频循环播放规则,

// 状态属性
.currentTime, number,以 秒 为单位,返回当前资源播放的进度位置
.duration, number,以 秒 为单位,返回资源的总时长,在资源没有初始化时,得到值为 NaN,通常在 ondurationchange 事件中获取视频总时长
volume, number, 获取音量大小,0 - 1
.playbackRate, number, 设置播放倍速
.paused, boolean, 判断是否处于暂停状态
.ended, boolean,判断是否播放结束
.readyState, number,获取媒体的就绪状态,0 是指资源未初始化完成,3 是指有一小段资源加载成功可以使用,4 是指有足够的资源可以使用,通常结合它来处理视频缓冲展示 loading 逻辑

2. instance methods.

.load(), 重新加载媒体资源
.pause(), 暂停资源的播放,成功后返回 undefined
.play(), 播放资源,成功后返回 Promise

3. listener events.

.onloadedmetadata(), 加载元数据后会触发 loadedmetadata 事件。
.canplay(), 用户环境可以播放时触发,每次切换播放位置时也会触发,可以在这个事件中获取资源的宽高,如 video.videoWidth、video.videoHeight
.ondurationchange(), video.duration 属性更新时触发改事件,可用于监听 video duration
.onended(), 视频播放完成后触发的事件。注意,如果 video.loop 存在,该事件将失效
.onloadeddata(), 当前播放位置这一帧加载完成后触发
.onloadstart(), 资源开始加载时触发
.onpause(), 暂停时触发
.onplay(), 播放时触发
.onratechange(), 切换倍速时触发
.onvolumechange(), 切换音量时触发
.ontimeupdate(), 监听视频播放进度,通常在这里获取最新 currentTime
.onplaying(), 在第一次开始播放、资源加载缓冲恢复后触发,用于处理缓冲 loading 逻辑
.onwaiting(), 当播放因为暂时缺乏数据而停止时,等待事件将被触发(视频缓冲),用于处理缓冲 loading 逻辑

在了解了 video 视频自身能力后,我们从 使用样例、程序设计、功能实现 三个角度来分析如何设计一款属于自己的 Video Player

二、使用样例

熟悉「使用样例」可以帮助我们理解后面的代码实现,一个简单的 Player 使用示例如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Player 样例使用</title>
</head>
  <style>
    #player-container{
      margin: 100px auto;
      border-radius: 5px;
    }
  </style>
<body>
  <div id="player-container"></div>
  <script src="./dist/Player.js"></script>
  <script>
    const player = new Player({
      el: '#player-container',
      url: 'xxx.mp4',
      poster: 'https://img1.baidu.com/it/u=413643897,2296924942&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500',
    });
  </script>
</body>
</html>

在这里有两个必传配置:

  1. 我们要为 Player 提供一个容器 el,它会将 Video 渲染在这个容器内;
  2. url 为视频播放源,通常为 mp4 资源地址。

poster 为一个可选值,用于指定视频封面图。

此外,还有一些可选配置参数,如:

  1. width,number | string, 指定视频宽度,默认 600px
  2. height,number | string, 指定视频高度,默认 350px
  3. playbackRateList,number[], 自定义倍速可选列表
  4. autoplay,boolean, 是否自动播放(静音方式自动播放)
  5. loop,boolean, 是否循环播放
  6. ignores,string[], 忽略内置的插件,如不使用默认提供的 StartPlugin 插件:[‘dsplayer-start’]
  7. plugins, { name: string, method: (player: Player) => {} }[], 扩展自定义插件
  8. lang,string,默认语种
  9. historyTime,上次的历史播放记录
  10. timelines,锚点列表数据
  11. playbackRate,指定默认播放倍速
  12. volume,指定默认音量大小

由上面的示例可知,我们首要实现的是 Player 构造函数(类),下面我们一起来看下代码程序结构设计。

三、程序设计

1. 构建配置

我们采用 rollup 作为打包工具,打包生成 Player js 文件。rollup 的简单配置如下:

rollup 的配置使用,可以参考这篇文章:Rollup 入门与实战

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { babel } from '@rollup/plugin-babel';
import { terser } from "rollup-plugin-terser";
import postcss from 'rollup-plugin-postcss';

const extensions = [ '.ts', '.tsx', '.js', '.jsx'];

export default () => ({
  input: path.resolve(__dirname, 'index.ts'),
  output: {
    file: path.resolve(__dirname, 'dist/Player.js'),
    format: 'umd',
    name: 'Player',
  },
  plugins: [
    resolve({
      extensions, // 指定 import 模块后缀解析规则
    }),
    postcss(),
    commonjs(),
    babel({
      extensions,
      presets: [
        '@babel/preset-env',
        ["@babel/preset-typescript", {
          "isTSX": true,
          "allExtensions": true,
        }]
      ],
      babelHelpers: 'bundled',
    }),
    terser(),
  ],
});

2. 项目结构

如何设计和实现一款 Video Player.

Player 的每一个功能都以插件的形式接入其中,统一存放在 Plugins 目录下,插件的结构定义如下:

export default {
  name: 'xxx', // 插件名称
  method: function(player: Player) { // 插件接收 player 作为参数
    ... 核心实现
  }
};

plugin 可以类比为框架的组件,视频播放器是由一个个 plugin 插件 组合而成,它们基于 player 不断的去增强播放器能力。

player.ts 是我们要实现的 Player 类,index.ts 作为程序的入口文件,它会注册内置插件到 Player 类上:

import Player from "./player";
import {
  ControlProgressPlugin,
  ControlRatePlugin,
  ControlVolumePlugin,
  ControlFullScreenPlugin,
  ...
} from './Plugins';

Player.installAll([
  ControlProgressPlugin,
  ControlRatePlugin,
  ControlVolumePlugin,
  ControlFullScreenPlugin,
  ...
]);

export default Player;

3. Player 类的实现

3.1 属性和公共方法

作为播放器的核心,Player 上存储了由外部传入的配置 config、视频播放能力的数据状态 state、上层的 DOM 引用 root、video 等。

此外,还提供了 插件注册、事件发布订阅机制 等实例方法和静态方法:

import './style/index.scss'; // 样式
import { IDSPlayerOptions, IDSPlayerConfig, IDSPlayerState, IDSPlayerPlugin, TEventMap } from './interface';
import { createDOM, addClassName, removeClassName } from './utils'; // DOM 操作工具函数
import { defaultDSPlayerIcons } from './constants/icons'; // 图标集合

class DSPlayer {
  // Player 事件系统
  _eventMap: TEventMap<string> = {} as TEventMap<string>;
  // Player 配置
  config: IDSPlayerConfig;
  // Player 状态
  state: IDSPlayerState;
  // Player 容器
  root: HTMLElement;
  // Video 容器(可用于渲染水印)
  videoContainer: HTMLDivElement;
  // Video Ele
  video: HTMLVideoElement;
  // controls el
  controls: HTMLDivElement;
  // 插件集合
  static plugins: { [key: string]: Function } = {};
  
  constructor(options: IDSPlayerOptions) {
    ...
  }
  
  // 由外部决定,在合适的时机,进行销毁操作,如 React 组件销毁、页面销毁 等。
  onDestroy() {
    this.emit('destory', null);
    this._eventMap = {};
  }
  
  // 简单发布订阅
  public on(type: string, handler: Function) {}
  public once(type: string, handler: Function) {}
  public emit<D = any>(type: string, data?: D) {}
  public off(type: string, handler: Function) {}

  // 插件注册和执行
  callPlugins() {
    Object.keys(DSPlayer.plugins).forEach(name => {
      const method = DSPlayer.plugins[name];
      if (!method || typeof method !== 'function') {
        console.warn(`plugin ${name} is invalid.`);
      } else if (this.config.ignores.indexOf(name) === -1) {
        method.call(this, this);
      }
    });
  }
  static install(plugin: IDSPlayerPlugin) {
    const { name, method } = plugin;
    if (!DSPlayer.plugins[name]) {
      DSPlayer.plugins[name] = method;
    }
  }
  static installAll(list: IDSPlayerPlugin[]) {
    for (let i = 0; i < list.length; i ++) {
      DSPlayer.install(list[i]);
    }
  }
}  
3.2 初始化

在 Player 初始化时,需要对用户传入的配置做校验和合并,比如校验 root 的合法性:

constructor(options: IDSPlayerOptions) {
    const root = typeof options.el === 'string' ? document.querySelector(options.el) : options.el;
    if (!(root instanceof HTMLElement)) {
      throw 'el cannot be empty.';
    }
    ...
}

传入的配置包含两部分:configstate

  1. config 数据作为配置数据,在视频播放和操作过程中不会发生变化;
  2. state 可以类比为 React 组件 state,会根据状态来切换视图更新。

Tips: 因为是原生编写,state 作为状态的判定条件,结合 root class 的切换来实现视图更新 和 模块显示与隐藏(display: none)。

接下来会对 options 和默认配置进行合并:

this.config = {
  url: options.url,
  width: options.width || 600,
  height: options.height || 350,
  // video 相关配置
  poster: options.poster || '',
  playbackRateList: options.playbackRateList || [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0],
  autoplay: options.autoplay || false,
  loop: options.loop || false,
  ignores: options.ignores || [], // 忽略内置插件
  plugins: options.plugins || [], // 自定义插件集合
  lang: options.lang || (['zh', 'zh-cn'].indexOf(navigator.language) > -1 ? 'zh-cn' : 'en'), // 语种
  hideDuration: options.hideDuration || 5000,
  historyTime: options.historyTime || 0, // 历史播放记录
  icons: Object.assign(JSON.parse(JSON.stringify(defaultDSPlayerIcons)), options.icons || {}), // icons 集合
  timelines: options.timelines || [], // 锚点数据 list
};

this.state = {
  paused: true, // 是否暂停
  fullScreen: false, // 是否全屏
  playedEnd: false, // 是否播放结束
  played: false, // 是否播放过
  bufferLoading: false,
  progressMoving: false,
  currentTime:  0, // 当前播放时长
  duration: options.duration || 0, // 视频总时长
  buffered: 0, // 视频缓冲时长
  playbackRate: options.playbackRate || 1, // 倍速
  volume: options.volume || 1, // 音量
  muted: options.muted || false,
}

初始化的最后一步是创建 DOM、注册插件、以及 video 元素初始化:

constructoy(options: IDSPlayerOptions) {
  ...
  
  this.root = this.initRoot(root);
  this.videoContainer = this.initVideoContainer();
  this.video = this.initVideo();
  this.controls = this.initControls();
  DSPlayer.installAll(this.config.plugins);
  this.callPlugins();
}

initRoot(root: HTMLElement) {
  // 添加默认 state 状态 class(通过操作 class 切换视图)
  addClassName(root, 'dsplayer-root dsplayer-pause dsplayer-notstarted dsplayer-loading');
  const { width, height } = this.config;
  root.innerHTML = '';
  root.style.width = typeof width === 'number' ? `${width}px` : width;
  root.style.height = typeof height === 'number' ? `${height}px` : height;
  return root;
}

initVideoContainer() {
  const videoContainer = createDOM('div', '', {}, 'dsplayer-video-container') as HTMLDivElement;
  this.root.appendChild(videoContainer);
  return videoContainer;
}

initVideo() {
  const { url: src, poster, preload, loop, autoplay } = this.config;
  const videoConfig: { [key: string]: any } = {
    src,
    poster,
    playsinline,
  }
  // 以下属性,不能合并在 videoConfig 中,因为它们在设置时,不需要考虑值,只设置属性就能生效
  if (loop) videoConfig.loop = 'loop';
  // 事件绑定
  const video = createDOM('video', '', videoConfig, 'dsplayer-video') as HTMLVideoElement;
  [
    'pause', 'play', 'ended', 'timeupdate', 'durationchange', 'ratechange', 'volumechange', 
    'canplay', 'canplaythrough', 'loadstart', 'loadeddata', 'loadedmetadata', 'playing', 'waiting', 'progress',
  ].forEach(name => {
    video.addEventListener(name, () => {
      this.emit(name, this); // 每个 Plugin 监听各自需要的事件,进行处理
    });
  });
  this.videoContainer.appendChild(video);

  // 自动播放策略(方案:autoplay + muted,实现无声自动播放。若要实现有声自动播放,需要添加用户鼠标移入视频区域逻辑(用户交互))
  if (autoplay) {
    video.autoplay = true;
    video.muted = this.state.muted = true;
    setTimeout(() => this.handlePlay(false));
  }

  return video;
}

initControls() {
  const controls = createDOM('div', '', {}, 'dsplayer-controls') as HTMLDivElement;
  controls.onclick= event => event.stopPropagation();
  controls.ondblclick = event => event.stopPropagation();
  this.root.appendChild(controls);
  return controls;
}
3.3 功能方法

Player 实例上还提供了通用的交互方法,如:播放/暂停、全屏/取消全屏。

handlePlay(paused: boolean) { // 暂停状态:true 表示暂停,false 表示播放
  if (paused === this.state.paused) return;

  this.removePlayClassStatus();    
  addClassName(this.root, [
    paused ? 'dsplayer-pause' : 'dsplayer-play',
  ].filter(Boolean).join(' '));
  this.state = Object.assign(this.state, {
    paused,
    playedEnd: false,
    played: true,
    bufferLoading: false,
  });
  if (paused) { // 暂停
    this.video.pause();
  } else { // 播放
    const resultPromise = this.video.play();
    resultPromise && resultPromise.catch(error => console.error('Auto-play was prevented. ' + error));
  }
}

handleFullScreen(fullScreen: boolean) {
  if (fullScreen === this.state.fullScreen) return;
  // 具体实现,由 ControlFullScreenPlugin 进行重写。
}

至此,Player 类的核心实现已经完成。有了基座承载,下面 Plugins 会基于 Player 实例自身所提供的信息及能力,扩展对应的交互及功能。

四、功能实现

插件的职责划分很明确,每个插件会提供处理与各自有关的 UI 和 交互能力。下面我们来看看几个常见的功能插件。

1. StartPlugin,视频区域播放/暂停

功能描述:在播放中心提供 start Icon 操作视频播放,并且在视频区域通过鼠标 单击调用 player.handlePlay() 切换播放/暂停,双击调用 player.handleFullScreen() 切换全屏/退出全屏。

代码实现:

import { createDOM } from '../utils';
import Player from '../player';

const StartPlugin = {
  name: 'dsplayer-start',
  method: function(player: Player) {
    const { root, config, state } = player;
    const { content, text } = config.icons.play;
    const startArea = createDOM(
      'div',
      `<div class="dsplayer-icon-start">${content}</div>${text ? `<div class="dsplayer-icon-text">${text[config.lang]}</div>` : ''}`,
      {},
      'dsplayer-start',
    );
    root.appendChild(startArea);

    startArea.onclick = event => {
      event.stopPropagation();
      player.handlePlay(false);
    }

    let dbclickTimer = 0;
    root.onclick = event => {
      event.stopPropagation();
      if (dbclickTimer) { // 双击
        window.clearTimeout(dbclickTimer);
        dbclickTimer = 0;
        player.handleFullScreen(!state.fullScreen);
      } else { // 单击
        dbclickTimer = window.setTimeout(() => {
          if (!state.played) return;
          player.handlePlay(!state.paused);
          dbclickTimer = 0;
        }, 200);
      }
    }
  }
}

export default StartPlugin;

2. LoadingPlugin,缓冲 Loading

功能描述:在视频缓冲加载时,显示 loading 加载效果。结合 waitingplaying 监听事件来实现。

代码实现:

import { createDOM, removeClassName, addClassName } from '../utils';
import Player from '../player';

const BufferLoadingPlugin = {
  name: 'dsplayer-buffer-loading',
  method: function(player: Player) {
    const { root } = player;
    const loadingArea = createDOM(
      'div',
      `<span></span><span></span>`,
      {},
      'dsplayer-buffer-loading',
    );
    root.appendChild(loadingArea);

    player.on('canplaythrough', () => {
      removeClassName(root, 'dsplayer-loading');
    });
    player.on('waiting', () => {
      const { currentTime, buffered } = player.state;
      if (currentTime > buffered) {
        addClassName(root, 'dsplayer-loading');
      }
    });
    player.on('playing', () => {
      removeClassName(root, 'dsplayer-loading');
    });
  }
}

export default BufferLoadingPlugin;

3. ControlPlay,操作栏的播放/暂停

功能描述:在 Controls 区域提供播放/暂停 Icon,调用 player.handlePlay() 来切换视频播放状态。

代码实现:

import { createDOM } from '../utils';
import Player from '../player';

const ControlPlayPlugin = {
  name: 'dsplayer-control-play',
  method: function(player: Player) {
    const { config, state, controls } = player;
    const { controlPause, controlPlay } = config.icons;
    const playArea = createDOM(
      'div',
      `<div class="dsplayer-icon-play">${controlPause.content}</div>
      <div class="dsplayer-icon-pause">${controlPlay.content}</div>`,
      {},
      'dsplayer-control-play',
    );
    playArea.onclick = event => {
      event.stopPropagation();
      event.preventDefault();
      player.handlePlay(!state.paused);
    }
    controls.appendChild(playArea);
  }
}

export default ControlPlayPlugin;

4. ControlFullscreen,全屏处理

功能描述:在 Controls 区域提供全屏/退出全屏 Icon,并且提供 player.handleFullScreen() 方法的具体实现,点击来切换全屏状态。

代码实现:

import { createDOM, addClassName, removeClassName } from '../utils';
import Player from '../player';

const ControlFullScreenPlugin = {
  name: 'dsplayer-control-fullscreen',
  method: function(player: Player) {
    const { root, controls, config, state } = player;
    const { controlFullScreen, controlExitFullScreen } = config.icons;
    const fullScreenArea = createDOM(
      'div',
      `<div class="dsplayer-icon-requestfull">${controlFullScreen.content}</div>
      <div class="dsplayer-icon-exitfull">${controlExitFullScreen.content}</div>`,
      {},
      'dsplayer-control-fullscreen',
    );
    controls.appendChild(fullScreenArea);

    fullScreenArea.onclick = event => {
      event.stopPropagation();
      event.preventDefault();
      player.handleFullScreen(!state.fullScreen);
    }

    // 插件实现 player.handleFullScreen
    player.handleFullScreen = (fullScreen: boolean) => {
      if (fullScreen === this.state.fullScreen) return;

      // 全屏
      if (fullScreen) {
        const element = root as any;
        if (element.requestFullscreen) {
          element.requestFullscreen();
        } else if (element.webkitRequestFullScreen) {
          element.webkitRequestFullScreen();
        } else if (element.mozRequestFullScreen) {
          element.mozRequestFullScreen();
        } else if (element.msRequestFullscreen) {
          element.msRequestFullscreen();
        }
      }
      // 退出全屏 
      else {
        const doc = document as any;
        if (doc.exitFullscreen) {
          doc.exitFullscreen();
        } else if (doc.webkitCancelFullScreen) {
          doc.webkitCancelFullScreen();
        } else if (doc.mozCancelFullScreen) {
          doc.mozCancelFullScreen();
        } else if (doc.msExitFullscreen) {
          doc.msExitFullscreen();
        }
      }
    }
    
    // 监听退出全屏事件(键盘 ESC 退出)
    const onFullScreenChange = () => {
      const doc = document as any;
      const fullScreenEle = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement;
      if (fullScreenEle && fullScreenEle === root) { // 进入全屏
        addClassName(root, 'dsplayer-fullscreen');
        state.fullScreen = true;
        player.emit('requestFullscreen');
      } else { // 退出全屏
        removeClassName(root, 'dsplayer-fullscreen');
        state.fullScreen = false;
        player.emit('exitFullscreen');
      }
    }
    document.addEventListener('fullscreenchange', onFullScreenChange);

    player.once('destory', () => {
      document.removeEventListener('fullscreenchange', onFullScreenChange);
    });
  }
}

export default ControlFullScreenPlugin;

5. ControlProgress,播放进度

功能描述:在 Controls 区域提供视频播放进度视图和交互,接收外部通知更新视频播放进度,视频历史播放记录,以及锚点逻辑。

这里我们分三部分来介绍:

第一部分为进度的 DOM 结构以及鼠标 点击、移动 切换视频进度的交互实现:

import { createDOM, timeTranslate } from '../utils';
import Player from '../player';
// DSPlayerLocale 定义了文案的多语种词条
import DSPlayerLocale from '../constants/locale';
const ControlProgressPlugin = {
name: 'dsplayer-control-progress',
method: function(player: Player) {
const { controls, video, state, config } = player;
const playerProgress = createDOM(
'div',
`
<span class="control-progress-time progress-current-time">${timeTranslate(state.currentTime)}</span>
<div class="control-progress-content">
<div class="control-progress-outer">
<div class="control-progress-cache"></div>
<div class="control-progress-played">
<span class="control-progress-point"></span>
</div>
<div class="control-progress-timelines"></div>
</div>
<div class="control-progress-tips"></div>
</div>
<span class="control-progress-time progress-duration">${timeTranslate(state.duration)}</span>
`,
{},
'dsplayer-control-progress',
);
controls.appendChild(playerProgress);
const currentTimeEle = playerProgress.querySelector('.progress-current-time') as HTMLElement;
const durationEle = playerProgress.querySelector('.progress-duration') as HTMLElement;
const outerEle = playerProgress.querySelector('.control-progress-outer') as HTMLElement;
const cacheEle = playerProgress.querySelector('.control-progress-cache') as HTMLElement;
const playedEle = playerProgress.querySelector('.control-progress-played') as HTMLElement;
const timelinesEle = playerProgress.querySelector('.control-progress-timelines') as HTMLElement;
const tipsEle = playerProgress.querySelector('.control-progress-tips') as HTMLElement;
const changeProgress = () => {
const { currentTime, duration, buffered } = state;
const playProgress = Math.ceil((currentTime / duration) * 1000) / 10;
const catchProgress = Math.ceil((buffered / duration) * 1000) / 10;
playedEle.style.width = `${playProgress}%`;
cacheEle.style.width = `${catchProgress}%`;
}
// Video 进度事件
player.on('loadedmetadata', () => {
state.duration = video.duration;
durationEle.innerHTML = timeTranslate(state.duration);
changeProgress();
...
});
player.on('timeupdate', () => {
state.currentTime = video.currentTime;
state.buffered = video.buffered.end(video.buffered.length - 1);
if (!state.progressMoving) {
currentTimeEle.innerHTML = timeTranslate(state.currentTime);
changeProgress();
}
});
// 鼠标操作进度事件
outerEle.onmousedown = event => {
event.stopPropagation();
const { left: containerLeft, width: containerWidth } = outerEle.getBoundingClientRect();
const computedProgress = (event: MouseEvent) => {
let mouseWidth = event.clientX - containerLeft;
if (mouseWidth < 0) mouseWidth = 0;
else if (mouseWidth > containerWidth) mouseWidth = containerWidth;
state.currentTime = (mouseWidth / containerWidth) * state.duration;
currentTimeEle.innerHTML = timeTranslate(state.currentTime);
changeProgress();
if (!state.progressMoving) {
video.currentTime = state.currentTime;
player.handlePlay(false); // 播放
}
}
const onMouseMove = (event: MouseEvent) => {
event.stopPropagation();
state.progressMoving = true;
computedProgress(event);
};
const onMouseUp = (event: MouseEvent) => {
event.stopPropagation();
state.progressMoving = false;
computedProgress(event);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
}
}
export default ControlProgressPlugin;

第二部分为 监听 customtimeupdate 外部用户操作交互事件通知,让视频定位到指定位置进行播放:

const ControlProgressPlugin = {
name: 'dsplayer-control-progress',
method: function(player: Player) {
...
player.on('customtimeupdate', (currentTime: number) => {
video.currentTime = Math.min(currentTime, state.duration); // 进入 timeupdate 事件执行进度更新逻辑
if (state.paused && (currentTime <= state.duration)) player.handlePlay(false); // play.
});
}
}

第三部分为 历史播放记录处理 以及 锚点 的逻辑处理:

// 锚点结构类型:
export interface IDSPlayerTimeline {
title: string;
desc: string;
time: number; // 时间(s)
}[];
const ControlProgressPlugin = {
name: 'dsplayer-control-progress',
method: function(player: Player) {
...
// Video 进度事件
player.on('loadedmetadata', () => {
...
// 历史播放记录处理
if (config.historyTime > 0 && config.historyTime < state.duration) {
video.currentTime = config.historyTime;
player.handleLayout();
// 通过发布订阅通知给 TipsPlugin(消息展示插件),展示对应信息
player.emit('showtips', { text: DSPlayerLocale['dsplayer.history.tip'][config.lang], duration: 0 });
}
// 渲染锚点
timelinesEle.innerHTML = config.timelines.filter(t => t.time <= state.duration).map((timeline, index) => (
`<span 
data-index="${index}"
class="control-progress-timeline" 
style="left: ${(timeline.time / state.duration) * 100}%;"></span>`
)).join('');
timelinesEle.onmouseover = event => {
event.stopPropagation();
const timelineEle = event.target as HTMLElement;
if (timelineEle.nodeName === 'SPAN') {
tipsEle.innerHTML = config.timelines[Number(timelineEle.getAttribute('data-index'))].desc;
tipsEle.style.left = `${timelineEle.offsetLeft - 9 + (timelineEle.offsetWidth / 2)}px`;
tipsEle.style.display = 'block';
}
}
timelinesEle.onmouseout = event => {
event.stopPropagation();
const timelineEle = event.target as HTMLElement;
if (timelineEle.nodeName === 'SPAN') {
tipsEle.style.display = 'none';
}
}
}
}
}

Plugin 插件,看作是机器人的每个部位,根据所需组装成不同功能类型的机器人。

最后

感谢阅读,如有不足之处,欢迎评论区评论指出。

原文链接:https://juejin.cn/post/7215991883800444984 作者:明里人

(0)
上一篇 2023年3月30日 上午10:05
下一篇 2023年3月30日 上午10:16

相关推荐

发表回复

登录后才能评论