├── .gitignore ├── README.md ├── docs ├── CNAME ├── danmaku.js ├── eplayer.js ├── game.html ├── icons │ ├── danmu-ok.svg │ ├── danmu-op.svg │ ├── danmu-x.svg │ ├── emoj.svg │ ├── fullscreen.svg │ ├── later.svg │ ├── pause.svg │ ├── play.svg │ ├── volume-ok.svg │ ├── volume-x.svg │ └── web-fullscreen.svg ├── index.html └── mug.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | .idea/ 4 | npm-debug.log 5 | yarn-error.log 6 | package-lock.json 7 | yarn.lock 8 | .DS_Store 9 | .cache/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

eplayer logo

2 | 3 | # eplayer [![NPM version](https://img.shields.io/npm/v/eplayer.svg?style=flat-square)](https://npmjs.com/package/eplayer) [![NPM downloads](https://img.shields.io/npm/dt/eplayer.svg?style=flat-square)](https://npmjs.com/package/eplayer) 4 | 5 | :dart: A web-components html5 video player facing future. 6 | 7 | #### who use eplayer? 8 | 9 | [eplayer.js.org - demo](https://eplayer.js.org/) 10 | 11 | [clicli - C 站](https://www.clicli.me/) 12 | 13 | #### Contributors 14 | 15 | 感谢大家的 pr,阿里嘎多! 16 | 17 | 18 | 19 | ### Use 20 | 21 | 0. ep 基于 web-component,为了兼容,需要事先引入 polyfill 22 | 23 | ```html 24 | 25 | ``` 26 | 27 | 1. 插入 `e-player` 标签,没错,只需要将平时用的 `video` 换成 `e-player` 即可 28 | 29 | ```html 30 | 31 | ``` 32 | 33 | type 属性可选,默认为 mp4,支持 hls 和 flv 34 | 35 | 2. 注册 `customElement`,注意 `type=module`,使用 es6 的 import 36 | 37 | ```html 38 | 43 | ``` 44 | 45 | 3. 可选定制 css,用于穿透 shadow-dom 预留的默认样式 46 | 47 | ```css 48 | e-player { 49 | /* 主题色 默认为 clicli 同款基佬紫*/ 50 | --theme: #00fff6; 51 | /* 进度条底色 一般不用设置 */ 52 | --progress: rgba(255, 255, 255, 0.3); 53 | /* 进度条偏移颜色 一般不用设置 */ 54 | --buffer: rgba(255, 255, 255, 0.6); 55 | /* 图标颜色 一般不用设置 */ 56 | --icons: rgba(255, 255, 255, 0.6); 57 | } 58 | ``` 59 | 60 | 4. 可选定制插件,会在右击菜单中出现一个选项,触发点击事件 61 | 62 | ```js 63 | Eplayer.use('github源码', ep => { 64 | // ep 为 shdow-root 元素 65 | window.location.href = 'https://github.com/132yse/eplayer' 66 | }) 67 | ``` 68 | 69 | #### hls 70 | 71 | 原生支持 `mp4` 和 `mkv` ,如果需要支持 `m3u8`,需要先引入 `hls.js` 72 | 73 | ```html 74 | 75 | ``` 76 | 77 | ### Npm 78 | 79 | ```shell 80 | yarn add eplayer -S 81 | ``` 82 | 83 | 同样的注册 customElement,但是注意,customElement 只能注册一次,然后还没完,往下看: 84 | 85 | #### omim 86 | 87 | omim 是腾讯前端框架 [omi](https://github.com/Tencent/omi) 的组件库分支,eplayer 已经集成进去 88 | 89 | [戳我戳我](https://github.com/Tencent/omi/tree/master/packages/omim/demos/player) 90 | 91 | #### Vue 92 | 93 | vue 默认是不支持 web-components 的,它无法主动判断含有`-`的是 vue 组件还是 web 组件 94 | 95 | 所以需要手动配置,忽略掉`e-player` 96 | 97 | ```JavaScript 98 | Vue.config.ignoredElements = [ 99 | 'e-player' 100 | ] 101 | ``` 102 | 103 | 然后,同样需要引入上面的几个文件,然后 bind 一下 src 和 type 104 | 105 | ```html 106 | 107 | ``` 108 | 109 | 可以封装成 vue 组件来使用:[vue-web-components-wrapper](https://github.com/vuejs/vue-web-component-wrapper) 110 | 111 | #### React / Fre 112 | 113 | react 直接支持 customElement,直接在 render 函数中`e-player`标签 114 | 115 | 比如,下面这个 fre 的粒子 116 | 117 | ```js 118 | function EPlayer({ src, type }) { 119 | const [url, setUrl] = useState(0) 120 | useEffect(() => { 121 | fetch(`https://jx.clicli.us/jx?url=${src}@dogecloud`) 122 | .then(res => res.json()) 123 | .then(data => { 124 | setUrl(data.url) 125 | }) 126 | }, []) 127 | return 128 | } 129 | ``` 130 | 131 | 完整代码在这里:[fre-eplayer](https://github.com/cliclitv/fre-eplayer) 132 | 133 | #### Angular 134 | 135 | 在 `angular.json` 中添加 `webcomponentsjs` 和 `hls.js` 136 | 137 | ```json 138 | ... 139 | "scripts": [ 140 | "node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js", 141 | "node_modules/hls.js/dist/hls.min.js" 142 | ] 143 | ... 144 | ``` 145 | 146 | 在 `app.component.ts` 中引入 `eplayer` 147 | 148 | ```ts 149 | import { Component, OnInit } from "@angular/core"; 150 | import Eplayer from "eplayer"; 151 | 152 | @Component({ 153 | selector: "app-root", 154 | templateUrl: "./app.component.html", 155 | styleUrls: ["./app.component.scss"], 156 | }) 157 | export class AppComponent implements OnInit { 158 | ngOnInit(): void { 159 | customElements.define("e-player", Eplayer); 160 | } 161 | } 162 | ``` 163 | 164 | 在需要使用 `eplayer` 的模块中启用自定义元素的支持 165 | 166 | ```ts 167 | import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; 168 | import { CommonModule } from "@angular/common"; 169 | import { VideoComponent } from "./video.component"; 170 | 171 | @NgModule({ 172 | declarations: [VideoComponent], 173 | imports: [CommonModule], 174 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 175 | }) 176 | export class VideoModule {} 177 | ``` 178 | 179 | 在相应的 `html` 文件中对 `src` 和 `type` 绑定 180 | 181 | ```html 182 | 183 | ``` 184 | 185 | 完整项目示例: [@cliclitv/pwa](https://github.com/cliclitv/pwa) 186 | 187 | #### ssr 188 | 189 | ssr 服务端没有 web-components 的 API,通常 web-components 的 ssr 都会通过一些库去模拟这些 API 190 | 191 | eplayer 不推荐这么做,请直接和正常的 html 引入方式一样,引入 cdn 192 | 193 | #### WAP 194 | 195 | WAP 端建议使用原生播放器,国产浏览器太乱了,没得兼容,同时建议往 APP 上引流 196 | 197 | #### Screenshot 198 | 199 | ![](https://ww1.sinaimg.cn/mw690/0065Zy9ely1g9srnm3ezpj31jg0v3kjl.jpg) 200 | 201 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | eplayer.js.org -------------------------------------------------------------------------------- /docs/danmaku.js: -------------------------------------------------------------------------------- 1 | const SPEED_ARG = 0.009 2 | 3 | const defaultDanmakuData = { 4 | msg: '', 5 | fontSize: 24, 6 | fontColor: '#ffffff', 7 | rollTime: 0, 8 | } 9 | 10 | class Danmaku { 11 | constructor(options) { 12 | this._container = options.container 13 | this._totalWidth = null 14 | this._totalHeight = null 15 | this._trackSize = 12 16 | this._renderTimer = null 17 | this._queue = [] 18 | this._tracks = null 19 | this._autoId = 0 20 | this._paused = true 21 | this.resize() 22 | this._resetTracks() 23 | console.log(this._container) 24 | } 25 | 26 | resize() { 27 | this._totalWidth = this._container.offsetWidth 28 | this._totalHeight = this._container.offsetHeight 29 | this.clearScreen() 30 | } 31 | 32 | clearScreen() { 33 | this._clearDanmakuNodes() 34 | this._resetTracks() 35 | } 36 | 37 | _resetTracks() { 38 | const count = Math.floor(this._totalHeight / this._trackSize / 3) 39 | this._tracks = new Array(count) 40 | for (let i = 0; i < count; i++) { 41 | this._tracks[i] = [] 42 | } 43 | } 44 | 45 | // 循环弹幕节点 46 | _eachDanmakuNode(fn) { 47 | let child = this._container.firstChild 48 | let id, y 49 | while (child) { 50 | if (child.nodeType === 1) { 51 | y = child.getAttribute('data-y') 52 | id = child.getAttribute('data-id') 53 | if (y && id) { fn(child, Number(y), Number(id)) } 54 | } 55 | child = child.nextSibling 56 | } 57 | } 58 | 59 | _clearDanmakuNodes() { 60 | const nodes = [] 61 | this._eachDanmakuNode((node) => { 62 | nodes.push(node) 63 | }) 64 | nodes.forEach((node) => { 65 | this._container.removeChild(node) 66 | }) 67 | } 68 | 69 | _parseData(data) { 70 | return Object.assign({ 71 | autoId: ++this._autoId, 72 | fontSize: Math.floor(Math.random() * 20) + 20 73 | }, defaultDanmakuData, data) 74 | } 75 | 76 | add(data) { 77 | this._queue.push(this._parseData(data)) 78 | // 如果队列轮询已经停止,则启动 79 | if (!this._renderTimer) { this._render() } 80 | } 81 | 82 | // 把弹幕数据加到合适的轨道 83 | _addToTrack(data) { 84 | // 单条轨道 85 | let track 86 | // 轨道的最后一项弹幕数据 87 | let lastItem 88 | // 弹幕已经走的路程 89 | let distance 90 | // 弹幕数据最终坐落的轨道索引 91 | // 有些弹幕会占多条轨道,所以 y 是个数组 92 | let y = [] 93 | 94 | const now = Date.now() 95 | 96 | for (let i = 0; i < this._tracks.length; i++) { 97 | track = this._tracks[i] || [] 98 | 99 | if (track.length) { 100 | // 轨道被占用,要计算是否会重叠 101 | // 只需要跟轨道最后一条弹幕比较即可 102 | lastItem = track[track.length - 1] 103 | 104 | // 计算已滚动距离 105 | distance = lastItem.rollSpeed * (now - lastItem.startTime) / 1000 106 | 107 | // 通过速度差,计算最后一条弹幕全部消失前,是否会与新增弹幕重叠 108 | // 如果不会重叠,则可以使用当前轨道 109 | if ( 110 | (distance > lastItem.width) && 111 | ( 112 | (data.rollSpeed <= lastItem.rollSpeed) || 113 | ((distance - lastItem.width) / (data.rollSpeed - lastItem.rollSpeed) > 114 | (this._totalWidth + lastItem.width - distance) / lastItem.rollSpeed) 115 | ) 116 | ) { 117 | y.push(i) 118 | } else { 119 | y = [] 120 | } 121 | 122 | } else { 123 | // 轨道未被占用 124 | y.push(i) 125 | } 126 | 127 | // 有足够的轨道可以用时,就可以新增弹幕了,否则等下一次轮询 128 | if (y.length >= data.useTracks) { 129 | data.y = y 130 | y.forEach((i) => { 131 | this._tracks[i].push(data) 132 | }) 133 | break 134 | } 135 | } 136 | } 137 | 138 | // (弹幕飘到尽头后)从轨道中移除对应数据 139 | _removeFromTrack(y, id) { 140 | y.forEach((i) => { 141 | const track = this._tracks[i] 142 | for (let j = 0; j < track.length; j++) { 143 | if (track[j].autoId === id) { 144 | track.splice(j, 1) 145 | break 146 | } 147 | } 148 | }) 149 | } 150 | 151 | // 通过 y 和 id 获取弹幕数据 152 | _findData(y, id) { 153 | const track = this._tracks[y] 154 | for (let j = 0; j < track.length; j++) { 155 | if (track[j].autoId === id) { 156 | return track[j] 157 | } 158 | } 159 | } 160 | 161 | // 轮询渲染 162 | _render() { 163 | if (this._paused) { return } 164 | try { 165 | this._renderToDOM() 166 | } finally { 167 | this._renderEnd() 168 | } 169 | } 170 | 171 | _renderToDOM() { 172 | let count = Math.floor(this._tracks.length / 3), i = 0 173 | 174 | while (count && i < this._queue.length) { 175 | const data = this._queue[i] 176 | let node = data.node 177 | 178 | if (!node) { 179 | // 弹幕节点基本样式 180 | data.node = node = document.createElement('div') 181 | node.innerText = data.msg 182 | node.style.position = 'absolute' 183 | node.style.left = '100%' 184 | node.style.whiteSpace = 'nowrap' 185 | node.style.color = data.fontColor 186 | node.style.fontSize = data.fontSize + 'px' 187 | node.style.willChange = 'transform' 188 | this._container.appendChild(node) 189 | 190 | data.useTracks = Math.ceil(node.offsetHeight / this._trackSize) 191 | // 占用的轨道数多于轨道总数,则忽略此数据 192 | if (data.useTracks > this._tracks.length) { 193 | this._queue.splice(i, 1) 194 | this._container.removeChild(node) 195 | continue 196 | } 197 | 198 | data.width = node.offsetWidth 199 | data.totalDistance = data.width + this._totalWidth 200 | data.rollTime = data.rollTime || 201 | Math.floor(data.totalDistance * SPEED_ARG * (Math.random() * 0.3 + 0.7)) 202 | data.rollSpeed = data.totalDistance / data.rollTime 203 | } 204 | 205 | this._addToTrack(data) 206 | 207 | if (data.y) { 208 | this._queue.splice(i, 1) 209 | 210 | node.setAttribute('data-id', data.autoId) 211 | node.setAttribute('data-y', data.y[0]) 212 | node.style.top = data.y[0] * this._trackSize + 'px' 213 | node.style.transition = `transform ${data.rollTime}s linear` 214 | node.style.transform = `translateX(-${data.totalDistance}px)` 215 | node.addEventListener('transitionstart', () => { 216 | data.startTime = Date.now() 217 | }, false) 218 | node.addEventListener('transitionend', () => { 219 | this._removeFromTrack(data.y, data.autoId) 220 | this._container.removeChild(node) 221 | this._queue = this._queue.filter(item => item.autoId != data.autoId) 222 | }, false) 223 | 224 | data.startTime = Date.now() + 80 225 | 226 | } else { 227 | // 当前弹幕要排队,继续处理下一条 228 | i++ 229 | } 230 | 231 | count-- 232 | } 233 | } 234 | // 轮询结束后,根据队列长度继续执行或停止执行 235 | _renderEnd() { 236 | if (this._queue.length > 0) { 237 | this._renderTimer = requestAnimationFrame(() => { 238 | this._render() 239 | }) 240 | } else { 241 | this._renderTimer = null 242 | } 243 | } 244 | 245 | pause() { 246 | if (this._paused) { return } 247 | this._paused = true 248 | 249 | this._eachDanmakuNode((node, y, id) => { 250 | const data = this._findData(y, id) 251 | if (data) { 252 | // 获取已滚动距离 253 | const transform = getComputedStyle(node, null).getPropertyValue('transform') 254 | data.rolledDistance = -Number(new DOMMatrix(transform).m41) 255 | 256 | // 移除动画,计算出弹幕所在的位置,固定样式 257 | node.style.transition = '' 258 | node.style.transform = `translateX(-${data.rolledDistance}px)` 259 | } 260 | }) 261 | } 262 | 263 | // 继续滚动弹幕 264 | resume() { 265 | if (!this._paused) { return } 266 | 267 | this._eachDanmakuNode((node, y, id) => { 268 | const data = this._findData(y, id) 269 | if (data) { 270 | data.startTime = Date.now() 271 | // 重新计算滚完剩余距离需要多少时间 272 | data.rollTime = (data.totalDistance - data.rolledDistance) / data.rollSpeed 273 | node.style.transition = `transform ${data.rollTime}s linear` 274 | node.style.transform = `translateX(-${data.totalDistance}px)` 275 | } 276 | }) 277 | 278 | this._paused = false 279 | 280 | if (!this._renderTimer) { this._render() } 281 | } 282 | } -------------------------------------------------------------------------------- /docs/eplayer.js: -------------------------------------------------------------------------------- 1 | class Eplayer extends HTMLElement { 2 | constructor() { 3 | super() 4 | this.doms = {} 5 | this.src = this.getAttribute('src') 6 | this.type = this.getAttribute('type') 7 | this.live = JSON.parse(this.getAttribute('live')) 8 | this.danmaku = null 9 | this.subs = [] 10 | 11 | this.init() 12 | this.stream() 13 | } 14 | 15 | static get observedAttributes() { 16 | return ['src', 'type', 'danma', 'live'] 17 | } 18 | 19 | attributeChangedCallback(name, oldVal, newVal) { 20 | if (name === 'src') { 21 | this.src = this.$('.video').src = newVal 22 | this.stream() 23 | this.$('.video').load() 24 | } 25 | if (name === 'type') { 26 | this.type = newVal 27 | this.stream() 28 | this.$('.video').load() 29 | } 30 | if (name === 'live') { 31 | this.live = JSON.parse(newVal) 32 | if (this.live) { 33 | this.$('.progress').style.display = 'none' 34 | this.$('.time').style.display = 'none' 35 | } else { 36 | this.$('.progress').style.display = 'block' 37 | this.$('.time').style.display = 'inline-block' 38 | } 39 | } 40 | if (name === 'danma') { 41 | this.danmaku.add({ 42 | msg: newVal 43 | }) 44 | } 45 | } 46 | 47 | $(key) { 48 | return this.doms[key] 49 | } 50 | 51 | waiting() { 52 | this.$('.mark').removeEventListener('click', this.mark.bind(this)) 53 | this.$('.mark').classList.remove('playing') 54 | this.$('.mark').classList.add('loading') 55 | } 56 | 57 | stream() { 58 | switch (this.type) { 59 | case 'hls': 60 | if (Hls.isSupported()) { 61 | let hls = new Hls() 62 | hls.loadSource(this.src) 63 | hls.attachMedia(this.video) 64 | } 65 | break 66 | } 67 | } 68 | 69 | mark() { 70 | clearTimeout(this.timer) 71 | this.timer = setTimeout(() => this.play(), 200) 72 | } 73 | 74 | canplay() { 75 | this.$('.mark').classList.remove('loading') 76 | this.$('.mark').classList.add('playing') 77 | this.$('.mark').addEventListener('click', this.mark.bind(this)) 78 | this.$('.total').innerHTML = getTimeStr(this.video.duration) 79 | } 80 | 81 | play() { 82 | if (this.video.paused) { 83 | this.video.play() 84 | this.danmaku.resume() 85 | this.$('.is-play').setAttribute('icon-id', 'pause') 86 | this.emit('play') 87 | } else { 88 | this.video.pause() 89 | this.danmaku.pause() 90 | this.$('.is-play').setAttribute('icon-id', 'play') 91 | this.emit('pause') 92 | } 93 | } 94 | 95 | volume() { 96 | if (this.video.muted) { 97 | this.video.muted = false 98 | this.$('.is-volume').setAttribute('icon-id', 'volume-ok') 99 | } else { 100 | this.video.muted = true 101 | this.$('.is-volume').setAttribute('icon-id', 'volume-x') 102 | } 103 | } 104 | 105 | update() { 106 | if (this.moving) return 107 | let cTime = getTimeStr(this.video.currentTime) 108 | if (this.video.buffered.length) { 109 | let bufferEnd = this.video.buffered.end(this.video.buffered.length - 1) 110 | this.$('.buffer').style.width = (bufferEnd / this.video.duration) * 100 + '%' 111 | } 112 | let offset = (this.video.currentTime / this.video.duration) * 100 113 | this.$('.now').innerHTML = cTime 114 | this.$('.current').style.width = offset + '%' 115 | } 116 | 117 | progress(e) { 118 | const progressBarRect = this.$('.progress').getBoundingClientRect() 119 | let clickX = e.clientX - progressBarRect.left 120 | clickX = Math.max(0, Math.min(clickX, progressBarRect.width)) 121 | const offsetRatio = clickX / progressBarRect.width 122 | 123 | this.video.currentTime = this.video.duration * offsetRatio 124 | this.$('.now').innerHTML = getTimeStr(this.video.currentTime) 125 | this.$('.current').style.width = offsetRatio * 100 + '%' 126 | } 127 | 128 | down(e) { 129 | e.preventDefault() 130 | this.moving = true 131 | const progressBarRect = this.$('.progress').getBoundingClientRect() 132 | this.progressBarWidth = progressBarRect.width 133 | this.initialClientX = e.clientX 134 | const initialOffsetRatio = this.video.currentTime / this.video.duration 135 | this.initialOffsetPixels = initialOffsetRatio * this.progressBarWidth 136 | 137 | document.addEventListener('pointermove', this.handleMove) 138 | document.addEventListener('pointerup', this.handleUp, { once: true }) 139 | } 140 | 141 | handleMove = (e) => { 142 | if (!this.moving) return 143 | 144 | const deltaX = e.clientX - this.initialClientX 145 | let newOffsetPixels = this.initialOffsetPixels + deltaX 146 | newOffsetPixels = Math.max(0, Math.min(newOffsetPixels, this.progressBarWidth)) 147 | 148 | this.$('.current').style.width = (newOffsetPixels / this.progressBarWidth) * 100 + '%' 149 | 150 | const newTime = (newOffsetPixels / this.progressBarWidth) * this.video.duration 151 | this.video.currentTime = Math.max(0, Math.min(newTime, this.video.duration)) 152 | 153 | this.$('.now').innerHTML = getTimeStr(this.video.currentTime) 154 | } 155 | 156 | handleUp = (e) => { 157 | this.moving = false 158 | document.removeEventListener('pointermove', this.handleMove) 159 | delete this.progressBarWidth 160 | delete this.initialClientX 161 | delete this.initialOffsetPixels 162 | } 163 | 164 | move(e) { 165 | let offset = e.clientX - this.disX + 12 166 | if (offset < 0) offset = 0 167 | if (offset > this.$('.progress').clientWidth) { 168 | offset = this.$('.progress').clientWidth 169 | } 170 | this.$('.current').style.width = offset + 'px' 171 | this.video.currentTime = (offset / this.$('.progress').clientWidth) * this.video.duration 172 | this.$('.now').innerHTML = getTimeStr(this.video.currentTime) 173 | } 174 | 175 | alow() { 176 | clearTimeout(this.timer) 177 | this.$('.mark').style.cursor = 'default' 178 | this.$('.eplayer').classList.add('hover') 179 | this.timer = setTimeout(() => { 180 | this.$('.eplayer').classList.remove('hover') 181 | }, 5000) 182 | } 183 | 184 | keydown(e) { 185 | switch (e.keyCode) { 186 | case 37: 187 | this.video.currentTime -= 10 188 | break 189 | case 39: 190 | this.video.currentTime += 10 191 | break 192 | case 38: 193 | try { 194 | this.video.volume = parseInt(this.video.volume * 100) / 100 + 0.05 195 | } catch (e) { } 196 | break 197 | case 40: 198 | try { 199 | this.video.volume = parseInt(this.video.volume * 100) / 100 - 0.05 200 | } catch (e) { } 201 | break 202 | case 32: 203 | this.play() 204 | break 205 | default: 206 | } 207 | } 208 | 209 | ended() { 210 | // this.$('.is-play').classList.replace('ep-pause', 'ep-play') 211 | } 212 | 213 | full() { 214 | if (isFullScreen()) { 215 | if (document.exitFullscreen) { 216 | document.exitFullscreen() 217 | } else if (document.mozCancelFullScreen) { 218 | document.mozCancelFullScreen() 219 | } else if (document.webkitCancelFullScreen) { 220 | document.webkitCancelFullScreen() 221 | } else if (document.msExitFullscreen) { 222 | document.msExitFullscreen() 223 | } 224 | 225 | screen.orientation.lock("portrait-primary") 226 | } else { 227 | let el = this.$('.eplayer') 228 | let rfs = el.requestFullScreen || el.webkitRequestFullScreen || el.mozRequestFullScreen || el.msRequestFullscreen 229 | rfs.call(el) 230 | screen.orientation.lock("landscape-primary") 231 | } 232 | } 233 | 234 | panel(e) { 235 | e.preventDefault() 236 | const panel = this.$('.panel') 237 | const eplayer = this.$('.eplayer') 238 | if (e.button !== 2) { 239 | panel.style.display = 'none' 240 | } else { 241 | panel.style.display = 'block' 242 | panel.style.height = panel.childElementCount * 24 + 'px' 243 | if (panel.offsetHeight + e.offsetY + 40 > eplayer.offsetHeight) { 244 | panel.style.top = '' 245 | panel.style.bottom = ((eplayer.offsetHeight - e.offsetY) / eplayer.offsetHeight) * 100 + '%' 246 | } else { 247 | panel.style.bottom = '' 248 | panel.style.top = (e.offsetY / eplayer.offsetHeight) * 100 + '%' 249 | } 250 | if (panel.offsetWidth + e.offsetX + 10 > eplayer.offsetWidth) { 251 | panel.style.left = '' 252 | panel.style.right = ((eplayer.offsetWidth - e.offsetX) / eplayer.offsetWidth) * 100 + '%' 253 | } else { 254 | panel.style.right = '' 255 | panel.style.left = (e.offsetX / eplayer.offsetWidth) * 100 + '%' 256 | } 257 | } 258 | } 259 | 260 | speed(e) { 261 | this.video.playbackRate === 3 ? (this.video.playbackRate = 1) : (this.video.playbackRate = this.video.playbackRate + 0.25) 262 | this.$('.speed').innerText = this.video.playbackRate + 'x' 263 | } 264 | 265 | init() { 266 | let html = ` 267 | 450 | 451 |
452 |
453 | 454 |
455 |
456 |
457 | 458 | 459 |
460 |
461 |
462 |
463 |
464 |
465 | 466 | 467 | 00:00/00:00 468 | 469 |
470 |
471 | 倍速 472 | 画中画 473 | 474 | 475 | 476 |
477 |
478 |
479 |
480 |
481 | ` 482 | let template = document.createElement('template') 483 | template.innerHTML = html 484 | this.attachShadow({ 485 | mode: 'open', 486 | }).appendChild(template.content.cloneNode(true)) 487 | 488 | const doms = [ 489 | '.video', 490 | '.mark', 491 | '.playing', 492 | '.loading', 493 | '.total', 494 | '.now', 495 | '.time', 496 | '.current', 497 | '.buffer', 498 | '.is-play', 499 | '.is-volume', 500 | '.dot', 501 | '.progress', 502 | '.controls', 503 | '.line', 504 | '.bg', 505 | '.eplayer', 506 | '.fullscreen', 507 | '.panel', 508 | '.speed', 509 | '.pip', 510 | '.danmaku' 511 | ] 512 | 513 | for (const key of doms) { 514 | let dom = this.shadowRoot.querySelectorAll(key) 515 | this.doms[key] = dom.length > 1 ? [...dom] : dom[0] 516 | } 517 | } 518 | 519 | connectedCallback() { 520 | this.video = this.$('.video') 521 | this.video.volume = 0.5 522 | this.danmaku = new Danmaku({ 523 | container: this.$('.danmaku') 524 | }) 525 | // setVolume(this.video.volume * 10, this.$('.line')) 526 | this.video.onwaiting = this.waiting.bind(this) 527 | this.video.oncanplay = this.canplay.bind(this) 528 | this.video.ontimeupdate = this.update.bind(this) 529 | this.video.onended = this.ended.bind(this) 530 | this.delegate('click', { 531 | '.is-volume': this.volume, 532 | '.fullscreen': this.full, 533 | '.is-play': this.play, 534 | '.ep-speed': this.speed, 535 | '.speed': this.speed, 536 | '.bg': this.progress, 537 | '.buffer': this.progress, 538 | '.current': this.progress, 539 | '.pip': this.pip, 540 | }) 541 | this.delegate('pointerdown', { 542 | '.dot': this.down, 543 | '.mark': this.panel, 544 | }) 545 | this.delegate('dblclick', { 546 | '.mark': (e) => { 547 | clearTimeout(this.timer) 548 | this.full() 549 | }, 550 | }) 551 | this.delegate('keydown', this.keydown) 552 | this.delegate('mousemove', this.alow) 553 | } 554 | 555 | delegate(type, map) { 556 | const that = this 557 | if (typeof map === 'function') { 558 | this.shadowRoot.addEventListener(type, map.bind(that)) 559 | } else { 560 | this.shadowRoot.addEventListener(type, (e) => { 561 | for (const key in map) e.target.matches(key) && map[key].call(that, e) 562 | }) 563 | } 564 | } 565 | 566 | pip(e) { 567 | if (!document.pictureInPictureElement) { 568 | this.video.requestPictureInPicture() 569 | } else { 570 | document.exitPictureInPicture() 571 | } 572 | } 573 | 574 | emit (name) { 575 | const fn = Eplayer.subs[name] 576 | fn && fn.call(this, this.shadowRoot) 577 | } 578 | 579 | } 580 | 581 | Eplayer.subs = {} 582 | 583 | Eplayer.use = function (name, cb) { 584 | this.subs[name] = cb 585 | } 586 | 587 | 588 | function getTimeStr(time) { 589 | let h = Math.floor(time / 3600) 590 | let m = Math.floor((time % 3600) / 60) 591 | let s = Math.floor(time % 60) 592 | h = h >= 10 ? h : '0' + h 593 | m = m >= 10 ? m : '0' + m 594 | s = s >= 10 ? s : '0' + s 595 | return h === '00' ? m + ':' + s : h + ':' + m + ':' + s 596 | } 597 | 598 | function isFullScreen() { 599 | return document.isFullScreen || document.webkitIsFullScreen || document.mozIsFullScreen 600 | } 601 | 602 | ; (function () { 603 | let link = document.createElement('script') 604 | link.setAttribute('src', 'https://lf1-cdn-tos.bytegoofy.com/obj/iconpark/icons_34101_11.6161dfd06f46009a9dea0fcffc6234bf.js') 605 | document.head.appendChild(link) 606 | })() 607 | 608 | customElements.define('e-player', Eplayer) 609 | -------------------------------------------------------------------------------- /docs/game.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/icons/danmu-ok.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/danmu-op.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/danmu-x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/emoj.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/fullscreen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/later.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/volume-ok.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/volume-x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/icons/web-fullscreen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | eplayer 9 | 10 | 11 | 12 | 13 | 14 | 61 | 62 |
63 | 65 | 66 |
67 | 68 | 69 | 70 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /docs/mug.js: -------------------------------------------------------------------------------- 1 | const assetUrl = 'https://registry.npmmirror.com/eplayer/1.5.4/files/docs/res' 2 | 3 | class Mug { 4 | constructor(beatMap, container, video) { 5 | this.container = container 6 | this.beatMap = beatMap 7 | this.gameStatus = 0 8 | this.buttonX = 85 9 | 10 | this.buttonArray = [] 11 | this.imgNumber = 1 12 | this.fps = 0 13 | this.score = 0 14 | this.str = "0" 15 | this.app = new PIXI.Application(500, 800) 16 | this.container.appendChild(this.app.view) 17 | console.log(container) 18 | this.app.view.style.width = container.offsetWidth + 'px' 19 | this.video = video 20 | 21 | this.drawUI() 22 | const buttonArray = this.buttonArray 23 | document.onkeydown = function (e) { 24 | if (e && e.keyCode == 68) { 25 | buttonArray[0].keyDown() 26 | } else if (e && e.keyCode == 70) { 27 | buttonArray[1].keyDown() 28 | } else if (e && e.keyCode == 74) { 29 | buttonArray[2].keyDown() 30 | } else if (e && e.keyCode == 75) { 31 | buttonArray[3].keyDown() 32 | } 33 | } 34 | document.onkeyup = function (e) { 35 | if (e && e.keyCode == 68) { 36 | buttonArray[0].keyUp() 37 | } else if (e && e.keyCode == 70) { 38 | buttonArray[1].keyUp() 39 | } else if (e && e.keyCode == 74) { 40 | buttonArray[2].keyUp() 41 | } else if (e && e.keyCode == 75) { 42 | buttonArray[3].keyUp() 43 | } 44 | } 45 | 46 | this.app.ticker.add(this.animate.bind(this)) 47 | } 48 | 49 | animate() { 50 | if (this.gameStatus == 0) { 51 | return 52 | } 53 | 54 | this.fps = this.video.currentTime 55 | //创建动物 56 | const head = this.beatMap[0] 57 | 58 | 59 | if (head && this.fps >= head.fps) { 60 | console.log(this.fps) 61 | this.beatMap.shift() 62 | let button = this.buttonArray[head.button - 1] 63 | button.createAnimal() 64 | } 65 | 66 | //图片移动 67 | for (let i = 0; i < this.buttonArray.length; i++) { 68 | let button = this.buttonArray[i] 69 | button.animalMove() 70 | button.deleteAnimal() 71 | button.scoreMove() 72 | } 73 | 74 | if (!head) { 75 | this.over() 76 | } 77 | } 78 | 79 | drawUI() { 80 | //游戏元素图层 81 | let gameCeng = new PIXI.Container() 82 | this.app.stage.addChild(gameCeng) 83 | //游戏背景 84 | let bg = new PIXI.Sprite.fromImage(`${assetUrl}/beijing.png`) 85 | gameCeng.addChild(bg) 86 | //ui图层 87 | let uiCeng = new PIXI.Container() 88 | this.app.stage.addChild(uiCeng) 89 | this.uiCeng = uiCeng 90 | 91 | //游戏对象层 92 | let gameObjectCeng = new PIXI.Container() 93 | gameCeng.addChild(gameObjectCeng) 94 | 95 | //line 图层 96 | let lineCeng = new PIXI.Container() 97 | gameObjectCeng.addChild(lineCeng) 98 | 99 | //兔子图片图层 100 | let animalCeng = new PIXI.Container() 101 | gameObjectCeng.addChild(animalCeng) 102 | 103 | //score图层 104 | let scoreCeng = new PIXI.Container() 105 | gameObjectCeng.addChild(scoreCeng) 106 | 107 | //点击位置 108 | let touming = new PIXI.Sprite.fromImage(`${assetUrl}/touming.png`) 109 | lineCeng.addChild(touming) 110 | touming.y = 600 111 | touming.x = 250 112 | touming.anchor.set(0.5, 0.5) 113 | 114 | 115 | for (let i = 0; i < 4; i++) { 116 | let button = new Button(this.imgNumber, gameObjectCeng, uiCeng, lineCeng, animalCeng, this.buttonX, this) 117 | this.buttonX = button.bjt.x + 110 118 | this.imgNumber++ 119 | this.buttonArray.push(button) 120 | } 121 | 122 | 123 | let style = { 124 | font: 'bold 40px 微软雅黑',//加粗 倾斜 字号 字体名称 125 | fill: '#F7EDCA',//颜色 126 | stroke: '#4a1850',//描边颜色 127 | strokeThickness: 5,//描边宽度 128 | dropShadow: true,//开启阴影 129 | dropShadowColor: '#000000',//阴影颜色 130 | dropShadowAngle: Math.PI / 6,//阴影角度 131 | dropShadowDistance: 6,//投影距离 132 | wordWrap: true,//开启自动换行(注:开启后在文本中空格处换行,如文本中没有空格则不换行) 133 | wordWrapWidth: 150,//自动换行宽度 134 | } 135 | 136 | let text = new PIXI.Text("得分 ", style) 137 | uiCeng.addChild(text) 138 | text.x = 30 139 | text.y = 30 140 | 141 | //创建文本 142 | let scoreTxt = new PIXI.Text(this.str, style) 143 | this.scoreTxt = scoreTxt 144 | uiCeng.addChild(scoreTxt) 145 | scoreTxt.x = 130 146 | scoreTxt.y = 30 147 | 148 | let startBtn = new PIXI.Sprite.fromImage(`${assetUrl}/kaishianniu.png`) 149 | uiCeng.addChild(startBtn) 150 | startBtn.interactive = true 151 | startBtn.on("pointerdown", () => { 152 | startBtn.visible = false 153 | this.gameStatus = 1 154 | this.video.play() 155 | }) 156 | 157 | startBtn.x = 200 158 | startBtn.y = 345 159 | 160 | } 161 | 162 | over() { 163 | this.video.pause() 164 | let gameoverPanel = new PIXI.Sprite.fromImage(`${assetUrl}/beiban.png`) 165 | this.uiCeng.addChild(gameoverPanel) 166 | gameoverPanel.x = 20 167 | gameoverPanel.y = 100 168 | gameoverPanel.alpha = 0.9 169 | 170 | let style = { 171 | font: 'bold 40px 微软雅黑',//加粗 倾斜 字号 字体名称 172 | fill: '#F7EDCA',//颜色 173 | } 174 | let scoreTxt = new PIXI.Text(this.score, style) 175 | gameoverPanel.addChild(scoreTxt) 176 | scoreTxt.x = 210 177 | scoreTxt.y = 110 178 | 179 | let restartBtn = new PIXI.Sprite.fromImage(`${assetUrl}/fanhuianniu.png`) 180 | gameoverPanel.addChild(restartBtn) 181 | restartBtn.x = 185 182 | restartBtn.y = 330 183 | restartBtn.interactive = true 184 | restartBtn.on("click", function () { 185 | window.location.reload() 186 | }) 187 | restartBtn.on("touchstart", function () { 188 | window.location.reload() 189 | }) 190 | this.gameStatus = 0 191 | 192 | } 193 | 194 | } 195 | 196 | //获取显示对象的世界坐标 197 | function getWorldPosition(displayObject) { 198 | let x = displayObject.transform.worldTransform.tx 199 | let y = displayObject.transform.worldTransform.ty 200 | return { "x": x, "y": y } 201 | } 202 | 203 | class Button { 204 | constructor(imgNumber, gameObjectCeng, uiCeng, lineCeng, animalCeng, buttonX, that) { 205 | this.gameObjectCeng = gameObjectCeng 206 | this.bjt = new PIXI.Sprite.fromImage(`${assetUrl}/bjt${imgNumber}.png`) 207 | gameObjectCeng.addChild(this.bjt) 208 | this.bjt.anchor.set(0.5, 1) 209 | this.bjt.x = buttonX 210 | this.bjt.y = 800 211 | this.bjt.visible = false 212 | this.animalCeng = animalCeng 213 | 214 | this.button = new PIXI.Sprite.fromImage(`${assetUrl}/anniu${imgNumber}.png`) 215 | uiCeng.addChild(this.button) 216 | this.button.anchor.set(0.5, 0.5) 217 | this.button.y = 754 218 | this.button.x = this.bjt.x 219 | this.type = imgNumber 220 | 221 | this.kong = new PIXI.Sprite.fromImage(`${assetUrl}/kong.png`) 222 | lineCeng.addChild(this.kong) 223 | this.kong.anchor.set(0.5, 0.5) 224 | this.kong.x = this.bjt.x 225 | this.kong.y = 600 226 | 227 | this.button.interactive = true 228 | this.animalArray = [] 229 | 230 | this.show = true 231 | 232 | this.scoreArray = [] 233 | 234 | this.soft = that 235 | 236 | // Handlers for button 237 | this.button.on("pointerdown", this.buttonMouseHandler.bind(this)) 238 | this.button.on("pointerup", this.buttonMouseupHandler.bind(this)) 239 | this.button.on("click", this.buttonClick.bind(this)) 240 | } 241 | 242 | createAnimal() { 243 | let animal = new Animal(this.type, this.button.x) 244 | this.animalCeng.addChild(animal.animal) 245 | this.animalArray.push(animal) 246 | } 247 | 248 | animalMove() { 249 | for (let i = 0; i < this.animalArray.length; i++) { 250 | let animal = this.animalArray[i] 251 | animal.move() 252 | } 253 | } 254 | 255 | deleteAnimal() { 256 | for (let i = this.animalArray.length - 1; i >= 0; i--) { 257 | let animal = this.animalArray[i] 258 | if (this.kong.y + 46 < animal.animal.y && this.show == true) { 259 | this.scoreAction("miss") 260 | this.show = false 261 | } 262 | if (animal.animal.y > 800) { 263 | this.show = true 264 | this.animalCeng.removeChild(animal.animal) 265 | this.animalArray.splice(i, 1) 266 | } 267 | } 268 | } 269 | 270 | buttonMouseHandler() { 271 | this.buttonClick() 272 | } 273 | 274 | buttonMouseupHandler() { 275 | this.bjt.visible = false 276 | } 277 | 278 | buttonClick() { 279 | const soft = this 280 | this.bjt.visible = true 281 | 282 | for (let i = 0; i < soft.animalArray.length; i++) { 283 | if (soft.kong.y - 10 < soft.animalArray[i].animal.y && soft.kong.y + 10 > soft.animalArray[i].animal.y) { 284 | this.soft.score += 10 285 | this.soft.scoreTxt.text = this.soft.score 286 | this.animalCeng.removeChild(soft.animalArray[i].animal) 287 | soft.animalArray.splice(i, 1) 288 | this.scoreAction("perfect") 289 | 290 | } else if (soft.kong.y - 20 < soft.animalArray[i].animal.y && soft.kong.y + 20 > soft.animalArray[i].animal.y) { 291 | this.soft.score += 5 292 | this.soft.scoreTxt.text = this.soft.score 293 | this.animalCeng.removeChild(soft.animalArray[i].animal) 294 | soft.animalArray.splice(i, 1) 295 | this.scoreAction("good") 296 | } else if (soft.kong.y - 30 < soft.animalArray[i].animal.y && soft.kong.y + 30 > soft.animalArray[i].animal.y) { 297 | this.soft.score += 1 298 | this.soft.scoreTxt.text = this.soft.score 299 | this.animalCeng.removeChild(soft.animalArray[i].animal) 300 | soft.animalArray.splice(i, 1) 301 | this.scoreAction("bad") 302 | } 303 | } 304 | } 305 | 306 | keyDown() { 307 | this.buttonClick() 308 | } 309 | 310 | keyUp() { 311 | this.bjt.visible = false 312 | } 313 | 314 | scoreAction(name) { 315 | let score = new PIXI.Sprite.fromImage(`${assetUrl}/` + name + ".png") 316 | this.gameObjectCeng.addChild(score) 317 | score.y = 540 318 | score.x = this.bjt.x 319 | score.anchor.set(0.5, 0.5) 320 | score.alpha = 1 321 | this.scoreArray.push(score) 322 | } 323 | 324 | scoreMove() { 325 | for (let i = 0; i < this.scoreArray.length; i++) { 326 | let score = this.scoreArray[i] 327 | score.alpha -= 0.01 328 | score.y -= 2 329 | } 330 | for (let i = this.scoreArray.length - 1; i >= 0; i--) { 331 | let score = this.scoreArray[i] 332 | if (score.y <= 400) { 333 | this.gameObjectCeng.removeChild(score) 334 | this.scoreArray.splice(i, 1) 335 | } 336 | } 337 | } 338 | } 339 | 340 | class Animal { 341 | constructor(type, animalX) { 342 | let number = 3 343 | let typeMap = { 344 | 1: "lan", 345 | 2: "lv", 346 | 3: "hong", 347 | 4: "huang" 348 | } 349 | this.animal = new PIXI.Sprite.fromImage(`${assetUrl}/${typeMap[type]}${number}.png`) 350 | this.animal.anchor.set(0.5, 0.5) 351 | this.animal.x = animalX 352 | this.animal.y = 0 353 | } 354 | 355 | move() { 356 | this.animal.y += 3.33 357 | } 358 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eplayer", 3 | "version": "1.6.6", 4 | "description": "A web-components html5 video player facing future", 5 | "main": "./docs/eplayer.js", 6 | "module": "./docs/eplayer.js", 7 | "unpkg": "./docs/eplayer.js", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "build": "rollup -c", 11 | "start": "serve docs" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/132yse/eplayer.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "bugs": { 21 | "url": "https://github.com/132yse/eplayer/issues" 22 | }, 23 | "homepage": "https://github.com/132yse/eplayer#readme", 24 | "dependencies": { 25 | "pixi-filters": "^6.0.4", 26 | "pixi.js": "^8.1.8", 27 | "serve": "^14.2.3" 28 | } 29 | } 30 | --------------------------------------------------------------------------------