西瓜视频web播放器源码

使用

提供占位元素

1
<div id="mse"></div>

初始化操作

1
2
3
4
let player = new Player({
id: 'mse',
url: 'http://s2.pstatp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4'
});

其他配置文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
new Player({
el:document.querySelector('#mse'), // 挂载点
url: 'video_url',
width: 600,
height: 337.5,
fluid: true,// 流布局
fitVideoSize: 'auto', // 自适应屏幕宽高
volume: 0.6, // 音量
autoplay: true, //自动播放
autoplayMuted: true, //静音
loop: true, //循环播放
...
});

播放器

1.1 播放器生命周期

WX20200226-095518@2x

1.2 事件机制

WX20200226-234934@2x

1.3 架构图

WX20200226-112915@2x

过程

1
2
3
4
let player = new Player({
id: 'mse',
url: 'http://s2.pstatp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4'
});
  • 初始化,入口文件加载其他插件 js
    • 引入其他 js 文件时候,通过 Player.install(‘xxx’, xxx) 安装插件,到 Player.plugins 上
  • 创建 Xgplayer 实例
    • 它继承于 Proxy,先调用 Proxy 的构造函数
      • videoConfig:将要添加到 video 标签的属性(有默认值)
      • video:创建的 video 标签
      • 通过 EventEmitter(this),为当前实例添加 on、emit、once、off 等方法用于监听事件与派发事件
      • 遍历 ev 数组,为 video 标签添加 ‘play’, ‘playing’, ‘pause’, ‘ended’, ‘error’, ‘seeking’, ‘seeked’, ‘timeupdate’, ‘waiting’, ‘canplay’, ‘canplaythrough’, ‘durationchange’, ‘volumechange’, ‘loadeddata’ 事件监听
    • 调用 Player 构造函数
      • config:传入的参数与默认参数合并(宽、高、音量、插槽id,url 等)
      • controls:自定义标签,挂载在 root(传入的 id 选择器的 dom 实例) 下,也会用于挂载 video 标签
      • pluginsCall 方法:遍历 Player.plugins 列表,执行插件自己的业务。例如:
        • 创建 DOM 元素并插入,为创建的 DOM 元素添加事件
        • 订阅 eventEmitter 主题
      • 遍历 ‘play’, ‘playing’, ‘pause’, ‘ended’, ‘error’, ‘seeking’, ‘seeked’, ‘timeupdate’, ‘waiting’, ‘canplay’, ‘canplaythrough’, ‘durationchange’, ‘volumechange’, ‘loadeddata’ 数组,添加订阅主题
    • 事件通信:当创建的 DOM 元素事件触发的时候,同时触发 video 标签的监听事件,video 标签的监听事件回调里通知插件订阅的 eventEmitter 主题,从而完成通信

插件说明

1.1 常用插件

xg-volume:音量

xg-time:进度时间

xg-progress:播放进度条

xg-play:播放按钮

xg-fullscreen:全屏按钮

xg-placeholder:占位

xg-replay:重播

xg-start:播放

xg-enter:点击播放后显示动画

xg-loading:loading显示

xg-error:错误显示(请刷新试试)

xg-texttrack:播放器外挂字幕

xg-poster:播放器贴图

xg-definition:控制条清晰度切换

xg-screenShot:截图

xg-rotate:播放器旋转控件

1.2 自定义插件开发

1
2
3
4
5
6
7
8
// pluginName.js
import Player from 'xgplayer';

let pluginName=function(player){
// 插件逻辑
}

Player.install('pluginName',pluginName);

1.3 插件生命周期

初始化(注册)

  • 添加到 Player.plugins 上

实例化

  • 自定义 UI 生成
  • 事件绑定
  • 订阅事件

销毁

  • 通过主动调用 player.destroy(isDelDom),发出销毁事件

  • 移除 DOM 元素

  • 删除 Player 实例上的属性
  • 移除 eventEmitter 订阅事件

1.4 如何实现插件可插拔

什么是插件可插拔

我认为的插件可插拔:

  • 不影响主体部分,通过插件来增加产品能力、灵活性与扩展能力。主体与插件之类松耦合关系。
  • 最重要的是支持插件的自由组合。

我认为的插件可插拔

babel:

主体内容 解析 -> 遍历 -> 生成,而插件订阅感兴趣的节点,合并插件的 visitor,在遍历时候进行处理

WX20200226-215448@2x

koa:

主体内容:只做了请求的监听,依赖中间件来完成其他业务。

WX20200226-221038@2x

真实的可拔插:可以根据需求引入或移除插件,前提是在不改变源码的情况下实现。有热拔插和冷拔插,分别是运行时与编译时

例如:西瓜播放器使用 ignore 数组来禁用功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pluginsCall () {
let self = this
if (Player.plugins) {
let ignores = this.config.ignores // 过滤数组
Object.keys(Player.plugins).forEach(name => {
let descriptor = Player.plugins[name]
if (!ignores.some(item => name === item)) {
if (['pc', 'tablet', 'mobile'].some(type => type === name)) {
if (name === sniffer.device) {
setTimeout(() => {
descriptor.call(self, self)
}, 0)
}
} else {
descriptor.call(this, this)
}
}
})
}
}

还可以考虑编译时就不打进包内,减少体积。构建时候根据配置插件,来指定打包的文件。

编译时可以解决的几种方式:

  1. 配置 ignore 数组,使用 webpack 的 plugins 插件,在 webpack buildModule(buildModule 是准备从入口文件开始,先使用 loader 对文件处理的第一步)之前,发现是不使用的插件,直接替换 resource 到一个空文件

WX20200227-215011@2x

  1. 根据命令行配置,打包的时候,选择不打进哪些插件

WechatIMG3415

两种实现方式比较

其实两种实现的方式都是类似,都是通过替换文件路径来实现加载空的 js 文件。

第一种方式:考虑的只是让配置尽量简单一些,更多的关注点只是 ignore 数组,可以单独抽离一份配置文件,但是需要很多其他的东西来帮助实现,比如 webpack 插件,路径转换之类的函数。如果多用户使用不同插件的话,还必须得配多份配置。

第二种方式:通过配置多个不同的带参命令行实现,需要多个判断命令行参数,来忽略不同的插件,但它不需要像第一种方式一样,添加多份配置文件。

其他问题

1.1 PC/移动端为什么要自动切换,如何自动切换

首先判断环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
get os () {
let ua = navigator.userAgent
let isWindowsPhone = /(?:Windows Phone)/.test(ua)
let isSymbian = /(?:SymbianOS)/.test(ua) || isWindowsPhone
let isAndroid = /(?:Android)/.test(ua)
let isFireFox = /(?:Firefox)/.test(ua)
let isTablet = /(?:iPad|PlayBook)/.test(ua) || (isAndroid && !/(?:Mobile)/.test(ua)) || (isFireFox && /(?:Tablet)/.test(ua))
let isPhone = /(?:iPhone)/.test(ua) && !isTablet
let isPc = !isPhone && !isAndroid && !isSymbian && !isTablet
return {
isTablet,
isPhone,
isAndroid,
isPc,
isSymbian,
isWindowsPhone,
isFireFox
}
}

1.2 安全白名单的作用是什么,如何实现

在 mobile 模式下,根据配置使用浏览器自带控件,或者插件,主要为了兼容性

  • 如果没有配置白名单

    ​ 显示 video 标签浏览器默认控件,并且禁止下载

  • 设置了白名单

    ​ 支持点击 video 背景,播放与暂停

1.3 如何有效的节省带宽、点播如何无缝切换

1.4 完整的产品机制包括哪些方面

1.5 错误监控如何上报

1.6 为什么要自动降级,如何降级

具体业务实现

1.1 screenshot

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import Player from '../player'

let saveScreenShot = function (data, filename) {
let saveLink = document.createElement('a')
saveLink.href = data
saveLink.download = filename

// 创建事件
// https://developer.mozilla.org/en-US/docs/Web/API/Document/createEvent
let event = document.createEvent('MouseEvents')

// 以鼠标事件创建
// event.initMouseEvent(type, canBubble, cancelable, view, detail, screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey, button, relatedTarget);
// https://developer.mozilla.org/zh-CN/docs/Web/API/MouseEvent/initMouseEvent
event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null)

// 派发事件
saveLink.dispatchEvent(event)
// https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/dispatchEvent
}

let screenShot = function () {
let player = this
let util = Player.util
if (!player.config.screenShot) {
return
}
let btn = util.createDom('xg-screenShot', '<p class="name"><span>截图</span></p>', {tabindex: 11}, 'xgplayer-screenShot')

// 创建 canvas
let canvas = document.createElement('canvas')
let canvasCtx = canvas.getContext('2d')
let img = new Image()
canvas.width = this.config.width || 600
canvas.height = this.config.height || 337.5
let root = player.controls
root.appendChild(btn)
let array = ['click', 'touchstart']
array.forEach(item => {
btn.addEventListener(item, function (e) {
e.preventDefault()
e.stopPropagation()
img.onload = (function () {
canvasCtx.drawImage(player.video, 0, 0, canvas.width, canvas.height)

// 需要设置,否则会报错
img.setAttribute('crossOrigin', 'anonymous')
img.src = canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream')
let screenShotImg = img.src.replace(/^data:image\/[^;]+/, 'data:application/octet-stream')
saveScreenShot(screenShotImg, '截图.png')
})()
})
})
}

Player.install('screenShot', screenShot)

1.2 download

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import Player from '../player'
import {getAbsoluteURL} from '../utils/url'
import downloadUtil from 'downloadjs'

const download = function () {
const player = this
if (!this.config.download) { return }
let container = player.root
let util = Player.util
let downloadEl = util.createDom('xgplayer-download', `<xg-icon class="xgplayer-download-img"></xg-icon>`, {}, 'xgplayer-download')

let root = player.controls
root.appendChild(downloadEl)

let tipsDownload = player.config.lang && player.config.lang === 'zh-cn' ? '下载' : 'Download'
let tips = util.createDom('xg-tips', tipsDownload, {}, 'xgplayer-tips')
downloadEl.appendChild(tips)

player.download = function() {
const url = getAbsoluteURL(player.config.url)
downloadUtil(url)
}
downloadEl.addEventListener('click', (e) => {
e.stopPropagation()
player.download();
})

downloadEl.addEventListener('mouseenter', (e) => {
e.preventDefault()
e.stopPropagation()
tips.style.left = '50%'
let rect = tips.getBoundingClientRect()
let rootRect = container.getBoundingClientRect()
if (rect.right > rootRect.right) {
tips.style.left = `${-rect.right + rootRect.right + 16}px`
}
})
}

Player.install('download', download)

我认为需要优化的地方

1.1 错误处理未定义 onError 函数

1.2 destroy 的时候,是否需要主动解绑 video 绑定事件,还是移除对 video 实例的引用的时候,会被垃圾回收。但是子元素引用仍然保存,就不会被回收

1.3 关于设计可以优化的地方

包括可插拔设计

1.4 为什么 destroy 的时候,先将 video src 设置为空,再调用 load

补充

1.1 Object.defineProperties:直接在一个对象上定义新的属性或修改现有属性,并返回该对象。

语法:

1
Object.defineProperties(obj, props) // obj 在其上定义或修改属性的对象 // props

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {};
Object.defineProperties(obj, {
'on': {
value: function(type,listener){ xxx }
configurable: true
enumerable: false
writable: true
},
'property2': {
value: 'Hello',
writable: false
}
});

1.2 IndexedDB

window.indexedDB || window.webkitindexedDB

window.IDBKeyRange || window.webkitIDBKeyRange

1.3 eventEmitter

1
2
3
4
on
once
emit
off

1.4 选择器

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>event</title>
</head>
<body>
<div id="12">123</div>
</body>
<script>
console.log(document.querySelector('#12'));
</script>
</html>

WX20200218-164651@2x

不支持数值开头的

1
2
3
4
5
6
7
8
9
10
11
12
13
util.findDom = function (el = document, sel) {
let dom
// fix querySelector IDs that start with a digit
// https://stackoverflow.com/questions/37270787/uncaught-syntaxerror-failed-to-execute-queryselector-on-document
try {
dom = el.querySelector(sel)
} catch (e) {
if (sel.startsWith('#')) {
dom = el.getElementById(sel.slice(1))
}
}
return dom
}

1.5 图片不存在,加载其他图片

https://stackoverflow.com/questions/37588017/fallback-background-image-if-default-doesnt-exist

1
background-image: url('http://placehol/1000x1000'), url('http://placehold.it/500x500');

1.6 babel-plugin-bulk-import

Babel插件,用于批量导入的插件。

require.context

require.context函数接受三个参数

  1. directory {String} -读取文件的路径
  2. useSubdirectories {Boolean} -是否遍历文件的子目录
  3. regExp {RegExp} -匹配文件的正则

示例:

1
2
require.context('./test', false, /.test.js$/);
// 遍历当前目录下的test文件夹的所有.test.js结尾的文件,不遍历子目录

1.7 判断是否是 dom 节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Returns true if it is a DOM node
function isNode(o){
return (
typeof Node === "object" ? o instanceof Node :
o && typeof o === "object" && typeof o.nodeType === "number" && typeof o.nodeName==="string"
);
}

//Returns true if it is a DOM element
function isElement(o){
return (
typeof HTMLElement === "object" ? o instanceof HTMLElement : //DOM2
o && typeof o === "object" && o !== null && o.nodeType === 1 && typeof o.nodeName==="string"
);
}

1.8 DefinePlugin

DefinePlugin 允许创建一个在编译时可以配置的全局常量。这可能会对开发模式和发布模式的构建允许不同的行为非常有用。如果在开发构建中,而不在发布构建中执行日志记录,则可以使用全局常量来决定是否记录日志。这就是 DefinePlugin 的用处,设置它,就可以忘记开发和发布构建的规则。

1.9 video 标签

属性:

  • src :视频的属性
  • poster:视频封面,没有播放时显示的图片
  • preload:预加载
  • autoplay:自动播放
  • loop:循环播放
  • controls:浏览器自带的控制条
  • width:视频宽度
  • height:视频高度

事件:

事件 描述
loadstart 浏览器开始在网上寻找媒体数据
progress 浏览器正在获取媒体数据
suspend 浏览器暂停获取媒体数据,但是下载过程并滑正常结束
abort 浏览器在下载完全部媒体数据之前中止获取媒体数据,但是并不是由错误引起的
error 获取媒体数据过程中出错
emptied video元素或audio元素所在网络突然变为未初始化状态可能原因有两个:1.载入媒体过程中突然发生一个致命错误 2.在浏览器正在选择支持的播放格式时,又调用 了load方法重新载入媒体
stalled 浏览器尝试获取媒体数据失败
play 即将开始播放,当执行了play方法时触发,或数据下载后元素被设为autoplay属性
pause 播放暂停,当执行了pause方式时触发
loadedmetadata 浏览器获取完毕媒体的时间长和字节数
waiting 播放过程由于得不到下一帧而暂停播放(例如下一帧尚未加载完毕),但很快就能够得到下一帧
canplay 浏览器能够播放媒体,但估计以当前的播放速率不能直接播放完毕,播放期间需要缓冲
canplaythrough 浏览器能够播放媒体,而且以当前播放速率能够将媒体播放完毕,不再需要进行缓冲
seeking seeking属性变为true,浏览器正在请求数据
seeked seeking属性变为false,浏览器停止请求数据
timeupdate 由于播放位置被改变,可能是播放过程中的自然改变,也可能是被人为的改变,或由于播放不能连续而发生的跳变
ended 播放结束后停止播放
ratechange defaultplaybackRate属性(默认播放速率)或playbackRate属性(当前播放速率)被改变
druationchange 播放时长被改变
volumechange volume属性(音量)被改变或muted属性(静音状态)被改变

1.10 canvas.toDataUrl

HTMLCanvasElement.toDataURL() 方法返回一个包含图片展示的 data URI 。可以使用 type 参数其类型,默认为 PNG 格式。图片的分辨率为96dpi。

  • 如果画布的高度或宽度是0,那么会返回字符串“data:,”。
  • 如果传入的类型非“image/png”,但是返回的值以“data:image/png”开头,那么该传入的类型是不支持的。
  • Chrome支持“image/webp”类型。

语法:

1
canvas.toDataURL(type, encoderOptions);

参数:

type 可选

​ 图片格式,默认为 image/png

encoderOptions 可选

​ 在指定图片格式为 image/jpeg 或 image/webp的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。其他参数会被忽略。

返回值:

​ 包含 data URIDOMString

需要注意的是如果画布中有跨域图片,并且服务器不允许跨域,是无法导出的。

1.11 事件

  • mouseenter
  • mousedown
  • mouseup
  • mouseleave
  • touchstart

  • touchend

  • animationend
  • keydown

文档

https://h5player.bytedance.com/gettingStarted/