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