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