]>
Commit | Line | Data |
---|---|---|
1 | // Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher | |
2 | ||
3 | import * as videojs from 'video.js' | |
4 | import * as WebTorrent from 'webtorrent' | |
5 | import { VideoConstant, VideoResolution } from '../../../../shared/models/videos' | |
6 | import { VideoFile } from '../../../../shared/models/videos/video.model' | |
7 | import { renderVideo } from './video-renderer' | |
8 | ||
9 | declare module 'video.js' { | |
10 | interface Player { | |
11 | peertube (): PeerTubePlugin | |
12 | } | |
13 | } | |
14 | ||
15 | interface VideoJSComponentInterface { | |
16 | _player: videojs.Player | |
17 | ||
18 | new (player: videojs.Player, options?: any) | |
19 | ||
20 | registerComponent (name: string, obj: any) | |
21 | } | |
22 | ||
23 | type PeertubePluginOptions = { | |
24 | videoFiles: VideoFile[] | |
25 | playerElement: HTMLVideoElement | |
26 | videoViewUrl: string | |
27 | videoDuration: number | |
28 | } | |
29 | ||
30 | // https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts | |
31 | // Don't import all Angular stuff, just copy the code with shame | |
32 | const dictionaryBytes: Array<{max: number, type: string}> = [ | |
33 | { max: 1024, type: 'B' }, | |
34 | { max: 1048576, type: 'KB' }, | |
35 | { max: 1073741824, type: 'MB' }, | |
36 | { max: 1.0995116e12, type: 'GB' } | |
37 | ] | |
38 | function bytes (value) { | |
39 | const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] | |
40 | const calc = Math.floor(value / (format.max / 1024)).toString() | |
41 | ||
42 | return [ calc, format.type ] | |
43 | } | |
44 | ||
45 | // videojs typings don't have some method we need | |
46 | const videojsUntyped = videojs as any | |
47 | const webtorrent = new WebTorrent({ | |
48 | tracker: { | |
49 | rtcConfig: { | |
50 | iceServers: [ | |
51 | { | |
52 | urls: 'stun:stun.stunprotocol.org' | |
53 | }, | |
54 | { | |
55 | urls: 'stun:stun.framasoft.org' | |
56 | } | |
57 | ] | |
58 | } | |
59 | }, | |
60 | dht: false | |
61 | }) | |
62 | ||
63 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | |
64 | class ResolutionMenuItem extends MenuItem { | |
65 | ||
66 | constructor (player: videojs.Player, options) { | |
67 | options.selectable = true | |
68 | super(player, options) | |
69 | ||
70 | const currentResolutionId = this.player_.peertube().getCurrentResolutionId() | |
71 | this.selected(this.options_.id === currentResolutionId) | |
72 | } | |
73 | ||
74 | handleClick (event) { | |
75 | super.handleClick(event) | |
76 | ||
77 | this.player_.peertube().updateResolution(this.options_.id) | |
78 | } | |
79 | } | |
80 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | |
81 | ||
82 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | |
83 | class ResolutionMenuButton extends MenuButton { | |
84 | label: HTMLElement | |
85 | ||
86 | constructor (player: videojs.Player, options) { | |
87 | options.label = 'Quality' | |
88 | super(player, options) | |
89 | ||
90 | this.label = document.createElement('span') | |
91 | ||
92 | this.el().setAttribute('aria-label', 'Quality') | |
93 | this.controlText('Quality') | |
94 | ||
95 | videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') | |
96 | this.el().appendChild(this.label) | |
97 | ||
98 | player.peertube().on('videoFileUpdate', () => this.update()) | |
99 | } | |
100 | ||
101 | createItems () { | |
102 | const menuItems = [] | |
103 | for (const videoFile of this.player_.peertube().videoFiles) { | |
104 | menuItems.push(new ResolutionMenuItem( | |
105 | this.player_, | |
106 | { | |
107 | id: videoFile.resolution.id, | |
108 | label: videoFile.resolution.label, | |
109 | src: videoFile.magnetUri, | |
110 | selected: videoFile.resolution.id === this.currentSelectionId | |
111 | }) | |
112 | ) | |
113 | } | |
114 | ||
115 | return menuItems | |
116 | } | |
117 | ||
118 | update () { | |
119 | if (!this.label) return | |
120 | ||
121 | this.label.innerHTML = this.player_.peertube().getCurrentResolutionLabel() | |
122 | this.hide() | |
123 | return super.update() | |
124 | } | |
125 | ||
126 | buildCSSClass () { | |
127 | return super.buildCSSClass() + ' vjs-resolution-button' | |
128 | } | |
129 | } | |
130 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | |
131 | ||
132 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | |
133 | class PeerTubeLinkButton extends Button { | |
134 | ||
135 | createEl () { | |
136 | const link = document.createElement('a') | |
137 | link.href = window.location.href.replace('embed', 'watch') | |
138 | link.innerHTML = 'PeerTube' | |
139 | link.title = 'Go to the video page' | |
140 | link.className = 'vjs-peertube-link' | |
141 | link.target = '_blank' | |
142 | ||
143 | return link | |
144 | } | |
145 | ||
146 | handleClick () { | |
147 | this.player_.pause() | |
148 | } | |
149 | } | |
150 | Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) | |
151 | ||
152 | class WebTorrentButton extends Button { | |
153 | createEl () { | |
154 | const div = document.createElement('div') | |
155 | const subDivWebtorrent = document.createElement('div') | |
156 | div.appendChild(subDivWebtorrent) | |
157 | ||
158 | const downloadIcon = document.createElement('span') | |
159 | downloadIcon.classList.add('icon', 'icon-download') | |
160 | subDivWebtorrent.appendChild(downloadIcon) | |
161 | ||
162 | const downloadSpeedText = document.createElement('span') | |
163 | downloadSpeedText.classList.add('download-speed-text') | |
164 | const downloadSpeedNumber = document.createElement('span') | |
165 | downloadSpeedNumber.classList.add('download-speed-number') | |
166 | const downloadSpeedUnit = document.createElement('span') | |
167 | downloadSpeedText.appendChild(downloadSpeedNumber) | |
168 | downloadSpeedText.appendChild(downloadSpeedUnit) | |
169 | subDivWebtorrent.appendChild(downloadSpeedText) | |
170 | ||
171 | const uploadIcon = document.createElement('span') | |
172 | uploadIcon.classList.add('icon', 'icon-upload') | |
173 | subDivWebtorrent.appendChild(uploadIcon) | |
174 | ||
175 | const uploadSpeedText = document.createElement('span') | |
176 | uploadSpeedText.classList.add('upload-speed-text') | |
177 | const uploadSpeedNumber = document.createElement('span') | |
178 | uploadSpeedNumber.classList.add('upload-speed-number') | |
179 | const uploadSpeedUnit = document.createElement('span') | |
180 | uploadSpeedText.appendChild(uploadSpeedNumber) | |
181 | uploadSpeedText.appendChild(uploadSpeedUnit) | |
182 | subDivWebtorrent.appendChild(uploadSpeedText) | |
183 | ||
184 | const peersText = document.createElement('span') | |
185 | peersText.classList.add('peers-text') | |
186 | const peersNumber = document.createElement('span') | |
187 | peersNumber.classList.add('peers-number') | |
188 | subDivWebtorrent.appendChild(peersNumber) | |
189 | subDivWebtorrent.appendChild(peersText) | |
190 | ||
191 | div.className = 'vjs-peertube' | |
192 | // Hide the stats before we get the info | |
193 | subDivWebtorrent.className = 'vjs-peertube-hidden' | |
194 | ||
195 | const subDivHttp = document.createElement('div') | |
196 | subDivHttp.className = 'vjs-peertube-hidden' | |
197 | const subDivHttpText = document.createElement('span') | |
198 | subDivHttpText.classList.add('peers-number') | |
199 | subDivHttpText.textContent = 'HTTP' | |
200 | const subDivFallbackText = document.createElement('span') | |
201 | subDivFallbackText.classList.add('peers-text') | |
202 | subDivFallbackText.textContent = ' fallback' | |
203 | ||
204 | subDivHttp.appendChild(subDivHttpText) | |
205 | subDivHttp.appendChild(subDivFallbackText) | |
206 | div.appendChild(subDivHttp) | |
207 | ||
208 | this.player_.peertube().on('torrentInfo', (event, data) => { | |
209 | // We are in HTTP fallback | |
210 | if (!data) { | |
211 | subDivHttp.className = 'vjs-peertube-displayed' | |
212 | subDivWebtorrent.className = 'vjs-peertube-hidden' | |
213 | ||
214 | return | |
215 | } | |
216 | ||
217 | const downloadSpeed = bytes(data.downloadSpeed) | |
218 | const uploadSpeed = bytes(data.uploadSpeed) | |
219 | const numPeers = data.numPeers | |
220 | ||
221 | downloadSpeedNumber.textContent = downloadSpeed[ 0 ] | |
222 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] | |
223 | ||
224 | uploadSpeedNumber.textContent = uploadSpeed[ 0 ] | |
225 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | |
226 | ||
227 | peersNumber.textContent = numPeers | |
228 | peersText.textContent = ' peers' | |
229 | ||
230 | subDivHttp.className = 'vjs-peertube-hidden' | |
231 | subDivWebtorrent.className = 'vjs-peertube-displayed' | |
232 | }) | |
233 | ||
234 | return div | |
235 | } | |
236 | } | |
237 | Button.registerComponent('WebTorrentButton', WebTorrentButton) | |
238 | ||
239 | const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin') | |
240 | class PeerTubePlugin extends Plugin { | |
241 | private player: any | |
242 | private currentVideoFile: VideoFile | |
243 | private playerElement: HTMLVideoElement | |
244 | private videoFiles: VideoFile[] | |
245 | private torrent: WebTorrent.Torrent | |
246 | private autoplay = false | |
247 | private videoViewUrl: string | |
248 | private videoDuration: number | |
249 | private videoViewInterval | |
250 | private torrentInfoInterval | |
251 | private savePlayerSrcFunction: Function | |
252 | ||
253 | constructor (player: videojs.Player, options: PeertubePluginOptions) { | |
254 | super(player, options) | |
255 | ||
256 | // Fix canplay event on google chrome by disabling default videojs autoplay | |
257 | this.autoplay = this.player.options_.autoplay | |
258 | this.player.options_.autoplay = false | |
259 | ||
260 | this.videoFiles = options.videoFiles | |
261 | this.videoViewUrl = options.videoViewUrl | |
262 | this.videoDuration = options.videoDuration | |
263 | ||
264 | this.savePlayerSrcFunction = this.player.src | |
265 | // Hack to "simulate" src link in video.js >= 6 | |
266 | // Without this, we can't play the video after pausing it | |
267 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | |
268 | this.player.src = () => true | |
269 | ||
270 | this.playerElement = options.playerElement | |
271 | ||
272 | this.player.ready(() => { | |
273 | this.initializePlayer() | |
274 | this.runTorrentInfoScheduler() | |
275 | this.runViewAdd() | |
276 | }) | |
277 | } | |
278 | ||
279 | dispose () { | |
280 | clearInterval(this.videoViewInterval) | |
281 | clearInterval(this.torrentInfoInterval) | |
282 | ||
283 | // Don't need to destroy renderer, video player will be destroyed | |
284 | this.flushVideoFile(this.currentVideoFile, false) | |
285 | } | |
286 | ||
287 | getCurrentResolutionId () { | |
288 | return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 | |
289 | } | |
290 | ||
291 | getCurrentResolutionLabel () { | |
292 | return this.currentVideoFile ? this.currentVideoFile.resolution.label : '' | |
293 | } | |
294 | ||
295 | updateVideoFile (videoFile?: VideoFile, done?: () => void) { | |
296 | if (done === undefined) { | |
297 | done = () => { /* empty */ } | |
298 | } | |
299 | ||
300 | // Pick the first one | |
301 | if (videoFile === undefined) { | |
302 | videoFile = this.videoFiles[0] | |
303 | } | |
304 | ||
305 | // Don't add the same video file once again | |
306 | if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { | |
307 | return | |
308 | } | |
309 | ||
310 | // Do not display error to user because we will have multiple fallbacks | |
311 | this.disableErrorDisplay() | |
312 | ||
313 | this.player.src = () => true | |
314 | this.player.playbackRate(1) | |
315 | ||
316 | const previousVideoFile = this.currentVideoFile | |
317 | this.currentVideoFile = videoFile | |
318 | ||
319 | this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, done) | |
320 | ||
321 | this.trigger('videoFileUpdate') | |
322 | } | |
323 | ||
324 | addTorrent (magnetOrTorrentUrl: string, previousVideoFile: VideoFile, done: Function) { | |
325 | console.log('Adding ' + magnetOrTorrentUrl + '.') | |
326 | ||
327 | this.torrent = webtorrent.add(magnetOrTorrentUrl, torrent => { | |
328 | console.log('Added ' + magnetOrTorrentUrl + '.') | |
329 | ||
330 | this.flushVideoFile(previousVideoFile) | |
331 | ||
332 | const options = { autoplay: true, controls: true } | |
333 | renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => { | |
334 | this.renderer = renderer | |
335 | ||
336 | if (err) return this.fallbackToHttp() | |
337 | ||
338 | if (!this.player.paused()) { | |
339 | const playPromise = this.player.play() | |
340 | if (playPromise !== undefined) return playPromise.then(done) | |
341 | ||
342 | return done() | |
343 | } | |
344 | ||
345 | return done() | |
346 | }) | |
347 | }) | |
348 | ||
349 | this.torrent.on('error', err => this.handleError(err)) | |
350 | ||
351 | this.torrent.on('warning', (err: any) => { | |
352 | // We don't support HTTP tracker but we don't care -> we use the web socket tracker | |
353 | if (err.message.indexOf('Unsupported tracker protocol') !== -1) return | |
354 | ||
355 | // Users don't care about issues with WebRTC, but developers do so log it in the console | |
356 | if (err.message.indexOf('Ice connection failed') !== -1) { | |
357 | console.error(err) | |
358 | return | |
359 | } | |
360 | ||
361 | // Magnet hash is not up to date with the torrent file, add directly the torrent file | |
362 | if (err.message.indexOf('incorrect info hash') !== -1) { | |
363 | console.error('Incorrect info hash detected, falling back to torrent file.') | |
364 | return this.addTorrent(this.torrent['xs'], previousVideoFile, done) | |
365 | } | |
366 | ||
367 | return this.handleError(err) | |
368 | }) | |
369 | } | |
370 | ||
371 | updateResolution (resolutionId: number) { | |
372 | // Remember player state | |
373 | const currentTime = this.player.currentTime() | |
374 | const isPaused = this.player.paused() | |
375 | ||
376 | // Remove poster to have black background | |
377 | this.playerElement.poster = '' | |
378 | ||
379 | // Hide bigPlayButton | |
380 | if (!isPaused) { | |
381 | this.player.bigPlayButton.hide() | |
382 | } | |
383 | ||
384 | const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) | |
385 | this.updateVideoFile(newVideoFile, () => { | |
386 | this.player.currentTime(currentTime) | |
387 | this.player.handleTechSeeked_() | |
388 | }) | |
389 | } | |
390 | ||
391 | flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { | |
392 | if (videoFile !== undefined && webtorrent.get(videoFile.magnetUri)) { | |
393 | if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() | |
394 | ||
395 | webtorrent.remove(videoFile.magnetUri) | |
396 | console.log('Removed ' + videoFile.magnetUri) | |
397 | } | |
398 | } | |
399 | ||
400 | setVideoFiles (files: VideoFile[], videoViewUrl: string, videoDuration: number) { | |
401 | this.videoViewUrl = videoViewUrl | |
402 | this.videoDuration = videoDuration | |
403 | this.videoFiles = files | |
404 | ||
405 | // Re run view add for the new video | |
406 | this.runViewAdd() | |
407 | this.updateVideoFile(undefined, () => this.player.play()) | |
408 | } | |
409 | ||
410 | private initializePlayer () { | |
411 | this.initSmoothProgressBar() | |
412 | ||
413 | if (this.autoplay === true) { | |
414 | this.updateVideoFile(undefined, () => this.player.play()) | |
415 | } else { | |
416 | this.player.one('play', () => { | |
417 | this.player.pause() | |
418 | this.updateVideoFile(undefined, () => this.player.play()) | |
419 | }) | |
420 | } | |
421 | } | |
422 | ||
423 | private runTorrentInfoScheduler () { | |
424 | this.torrentInfoInterval = setInterval(() => { | |
425 | // Not initialized yet | |
426 | if (this.torrent === undefined) return | |
427 | ||
428 | // Http fallback | |
429 | if (this.torrent === null) return this.trigger('torrentInfo', false) | |
430 | ||
431 | return this.trigger('torrentInfo', { | |
432 | downloadSpeed: this.torrent.downloadSpeed, | |
433 | numPeers: this.torrent.numPeers, | |
434 | uploadSpeed: this.torrent.uploadSpeed | |
435 | }) | |
436 | }, 1000) | |
437 | } | |
438 | ||
439 | private runViewAdd () { | |
440 | this.clearVideoViewInterval() | |
441 | ||
442 | // After 30 seconds (or 3/4 of the video), add a view to the video | |
443 | let minSecondsToView = 30 | |
444 | ||
445 | if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 | |
446 | ||
447 | let secondsViewed = 0 | |
448 | this.videoViewInterval = setInterval(() => { | |
449 | if (this.player && !this.player.paused()) { | |
450 | secondsViewed += 1 | |
451 | ||
452 | if (secondsViewed > minSecondsToView) { | |
453 | this.clearVideoViewInterval() | |
454 | ||
455 | this.addViewToVideo().catch(err => console.error(err)) | |
456 | } | |
457 | } | |
458 | }, 1000) | |
459 | } | |
460 | ||
461 | private clearVideoViewInterval () { | |
462 | if (this.videoViewInterval !== undefined) { | |
463 | clearInterval(this.videoViewInterval) | |
464 | this.videoViewInterval = undefined | |
465 | } | |
466 | } | |
467 | ||
468 | private addViewToVideo () { | |
469 | return fetch(this.videoViewUrl, { method: 'POST' }) | |
470 | } | |
471 | ||
472 | private fallbackToHttp () { | |
473 | this.flushVideoFile(this.currentVideoFile, true) | |
474 | this.torrent = null | |
475 | ||
476 | // Enable error display now this is our last fallback | |
477 | this.player.one('error', () => this.enableErrorDisplay()) | |
478 | ||
479 | const httpUrl = this.currentVideoFile.fileUrl | |
480 | this.player.src = this.savePlayerSrcFunction | |
481 | this.player.src(httpUrl) | |
482 | this.player.play() | |
483 | } | |
484 | ||
485 | private handleError (err: Error | string) { | |
486 | return this.player.trigger('customError', { err }) | |
487 | } | |
488 | ||
489 | private enableErrorDisplay () { | |
490 | this.player.addClass('vjs-error-display-enabled') | |
491 | } | |
492 | ||
493 | private disableErrorDisplay () { | |
494 | this.player.removeClass('vjs-error-display-enabled') | |
495 | } | |
496 | ||
497 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | |
498 | private initSmoothProgressBar () { | |
499 | const SeekBar = videojsUntyped.getComponent('SeekBar') | |
500 | SeekBar.prototype.getPercent = function getPercent () { | |
501 | // Allows for smooth scrubbing, when player can't keep up. | |
502 | // const time = (this.player_.scrubbing()) ? | |
503 | // this.player_.getCache().currentTime : | |
504 | // this.player_.currentTime() | |
505 | const time = this.player_.currentTime() | |
506 | const percent = time / this.player_.duration() | |
507 | return percent >= 1 ? 1 : percent | |
508 | } | |
509 | SeekBar.prototype.handleMouseMove = function handleMouseMove (event) { | |
510 | let newTime = this.calculateDistance(event) * this.player_.duration() | |
511 | if (newTime === this.player_.duration()) { | |
512 | newTime = newTime - 0.1 | |
513 | } | |
514 | this.player_.currentTime(newTime) | |
515 | this.update() | |
516 | } | |
517 | } | |
518 | } | |
519 | videojsUntyped.registerPlugin('peertube', PeerTubePlugin) |