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