Correctly implement p2p-media-loader
[oweals/peertube.git] / client / src / assets / player / webtorrent-plugin.ts
1 // FIXME: something weird with our path definition in tsconfig and typings
2 // @ts-ignore
3 import * as videojs from 'video.js'
4
5 import * as WebTorrent from 'webtorrent'
6 import { VideoFile } from '../../../../shared/models/videos/video.model'
7 import { renderVideo } from './webtorrent/video-renderer'
8 import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings'
9 import { videoFileMaxByResolution, videoFileMinByResolution } from './utils'
10 import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store'
11 import {
12   getAverageBandwidthInStore,
13   getStoredMute,
14   getStoredVolume,
15   getStoredWebTorrentEnabled,
16   saveAverageBandwidth
17 } from './peertube-player-local-storage'
18
19 const CacheChunkStore = require('cache-chunk-store')
20
21 type PlayOptions = {
22   forcePlay?: boolean,
23   seek?: number,
24   delay?: number
25 }
26
27 const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
28 class WebTorrentPlugin extends Plugin {
29   private readonly playerElement: HTMLVideoElement
30
31   private readonly autoplay: boolean = false
32   private readonly startTime: number = 0
33   private readonly savePlayerSrcFunction: Function
34   private readonly videoFiles: VideoFile[]
35   private readonly videoDuration: number
36   private readonly CONSTANTS = {
37     INFO_SCHEDULER: 1000, // Don't change this
38     AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
39     AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
40     AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
41     AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
42     BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
43   }
44
45   private readonly webtorrent = new WebTorrent({
46     tracker: {
47       rtcConfig: {
48         iceServers: [
49           {
50             urls: 'stun:stun.stunprotocol.org'
51           },
52           {
53             urls: 'stun:stun.framasoft.org'
54           }
55         ]
56       }
57     },
58     dht: false
59   })
60
61   private player: any
62   private currentVideoFile: VideoFile
63   private torrent: WebTorrent.Torrent
64
65   private renderer: any
66   private fakeRenderer: any
67   private destroyingFakeRenderer = false
68
69   private autoResolution = true
70   private autoResolutionPossible = true
71   private isAutoResolutionObservation = false
72   private playerRefusedP2P = false
73
74   private torrentInfoInterval: any
75   private autoQualityInterval: any
76   private addTorrentDelay: any
77   private qualityObservationTimer: any
78   private runAutoQualitySchedulerTimer: any
79
80   private downloadSpeeds: number[] = []
81
82   constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
83     super(player, options)
84
85     // Disable auto play on iOS
86     this.autoplay = options.autoplay && this.isIOS() === false
87     this.playerRefusedP2P = !getStoredWebTorrentEnabled()
88
89     this.videoFiles = options.videoFiles
90     this.videoDuration = options.videoDuration
91
92     this.savePlayerSrcFunction = this.player.src
93     this.playerElement = options.playerElement
94
95     this.player.ready(() => {
96       const playerOptions = this.player.options_
97
98       const volume = getStoredVolume()
99       if (volume !== undefined) this.player.volume(volume)
100
101       const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
102       if (muted !== undefined) this.player.muted(muted)
103
104       this.player.duration(options.videoDuration)
105
106       this.initializePlayer()
107       this.runTorrentInfoScheduler()
108
109       this.player.one('play', () => {
110         // Don't run immediately scheduler, wait some seconds the TCP connections are made
111         this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
112       })
113     })
114   }
115
116   dispose () {
117     clearTimeout(this.addTorrentDelay)
118     clearTimeout(this.qualityObservationTimer)
119     clearTimeout(this.runAutoQualitySchedulerTimer)
120
121     clearInterval(this.torrentInfoInterval)
122     clearInterval(this.autoQualityInterval)
123
124     // Don't need to destroy renderer, video player will be destroyed
125     this.flushVideoFile(this.currentVideoFile, false)
126
127     this.destroyFakeRenderer()
128   }
129
130   getCurrentResolutionId () {
131     return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
132   }
133
134   updateVideoFile (
135     videoFile?: VideoFile,
136     options: {
137       forcePlay?: boolean,
138       seek?: number,
139       delay?: number
140     } = {},
141     done: () => void = () => { /* empty */ }
142   ) {
143     // Automatically choose the adapted video file
144     if (videoFile === undefined) {
145       const savedAverageBandwidth = getAverageBandwidthInStore()
146       videoFile = savedAverageBandwidth
147         ? this.getAppropriateFile(savedAverageBandwidth)
148         : this.pickAverageVideoFile()
149     }
150
151     // Don't add the same video file once again
152     if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
153       return
154     }
155
156     // Do not display error to user because we will have multiple fallback
157     this.disableErrorDisplay()
158
159     // Hack to "simulate" src link in video.js >= 6
160     // Without this, we can't play the video after pausing it
161     // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
162     this.player.src = () => true
163     const oldPlaybackRate = this.player.playbackRate()
164
165     const previousVideoFile = this.currentVideoFile
166     this.currentVideoFile = videoFile
167
168     // Don't try on iOS that does not support MediaSource
169     // Or don't use P2P if webtorrent is disabled
170     if (this.isIOS() || this.playerRefusedP2P) {
171       return this.fallbackToHttp(options, () => {
172         this.player.playbackRate(oldPlaybackRate)
173         return done()
174       })
175     }
176
177     this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
178       this.player.playbackRate(oldPlaybackRate)
179       return done()
180     })
181
182     this.changeQuality()
183     this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
184   }
185
186   updateResolution (resolutionId: number, delay = 0) {
187     // Remember player state
188     const currentTime = this.player.currentTime()
189     const isPaused = this.player.paused()
190
191     // Remove poster to have black background
192     this.playerElement.poster = ''
193
194     // Hide bigPlayButton
195     if (!isPaused) {
196       this.player.bigPlayButton.hide()
197     }
198
199     const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
200     const options = {
201       forcePlay: false,
202       delay,
203       seek: currentTime + (delay / 1000)
204     }
205     this.updateVideoFile(newVideoFile, options)
206   }
207
208   flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
209     if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
210       if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
211
212       this.webtorrent.remove(videoFile.magnetUri)
213       console.log('Removed ' + videoFile.magnetUri)
214     }
215   }
216
217   enableAutoResolution () {
218     this.autoResolution = true
219     this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
220   }
221
222   disableAutoResolution (forbid = false) {
223     if (forbid === true) this.autoResolutionPossible = false
224
225     this.autoResolution = false
226     this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
227     this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
228   }
229
230   getTorrent () {
231     return this.torrent
232   }
233
234   private addTorrent (
235     magnetOrTorrentUrl: string,
236     previousVideoFile: VideoFile,
237     options: PlayOptions,
238     done: Function
239   ) {
240     console.log('Adding ' + magnetOrTorrentUrl + '.')
241
242     const oldTorrent = this.torrent
243     const torrentOptions = {
244       store: (chunkLength: number, storeOpts: any) => new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
245         max: 100
246       })
247     }
248
249     this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
250       console.log('Added ' + magnetOrTorrentUrl + '.')
251
252       if (oldTorrent) {
253         // Pause the old torrent
254         this.stopTorrent(oldTorrent)
255
256         // We use a fake renderer so we download correct pieces of the next file
257         if (options.delay) this.renderFileInFakeElement(torrent.files[ 0 ], options.delay)
258       }
259
260       // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
261       this.addTorrentDelay = setTimeout(() => {
262         // We don't need the fake renderer anymore
263         this.destroyFakeRenderer()
264
265         const paused = this.player.paused()
266
267         this.flushVideoFile(previousVideoFile)
268
269         // Update progress bar (just for the UI), do not wait rendering
270         if (options.seek) this.player.currentTime(options.seek)
271
272         const renderVideoOptions = { autoplay: false, controls: true }
273         renderVideo(torrent.files[ 0 ], this.playerElement, renderVideoOptions, (err, renderer) => {
274           this.renderer = renderer
275
276           if (err) return this.fallbackToHttp(options, done)
277
278           return this.tryToPlay(err => {
279             if (err) return done(err)
280
281             if (options.seek) this.seek(options.seek)
282             if (options.forcePlay === false && paused === true) this.player.pause()
283
284             return done()
285           })
286         })
287       }, options.delay || 0)
288     })
289
290     this.torrent.on('error', (err: any) => console.error(err))
291
292     this.torrent.on('warning', (err: any) => {
293       // We don't support HTTP tracker but we don't care -> we use the web socket tracker
294       if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
295
296       // Users don't care about issues with WebRTC, but developers do so log it in the console
297       if (err.message.indexOf('Ice connection failed') !== -1) {
298         console.log(err)
299         return
300       }
301
302       // Magnet hash is not up to date with the torrent file, add directly the torrent file
303       if (err.message.indexOf('incorrect info hash') !== -1) {
304         console.error('Incorrect info hash detected, falling back to torrent file.')
305         const newOptions = { forcePlay: true, seek: options.seek }
306         return this.addTorrent(this.torrent[ 'xs' ], previousVideoFile, newOptions, done)
307       }
308
309       // Remote instance is down
310       if (err.message.indexOf('from xs param') !== -1) {
311         this.handleError(err)
312       }
313
314       console.warn(err)
315     })
316   }
317
318   private tryToPlay (done?: (err?: Error) => void) {
319     if (!done) done = function () { /* empty */ }
320
321     const playPromise = this.player.play()
322     if (playPromise !== undefined) {
323       return playPromise.then(done)
324                         .catch((err: Error) => {
325                           if (err.message.indexOf('The play() request was interrupted by a call to pause()') !== -1) {
326                             return
327                           }
328
329                           console.error(err)
330                           this.player.pause()
331                           this.player.posterImage.show()
332                           this.player.removeClass('vjs-has-autoplay')
333                           this.player.removeClass('vjs-has-big-play-button-clicked')
334
335                           return done()
336                         })
337     }
338
339     return done()
340   }
341
342   private seek (time: number) {
343     this.player.currentTime(time)
344     this.player.handleTechSeeked_()
345   }
346
347   private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
348     if (this.videoFiles === undefined || this.videoFiles.length === 0) return undefined
349     if (this.videoFiles.length === 1) return this.videoFiles[0]
350
351     // Don't change the torrent is the play was ended
352     if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
353
354     if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
355
356     // Limit resolution according to player height
357     const playerHeight = this.playerElement.offsetHeight as number
358
359     // We take the first resolution just above the player height
360     // Example: player height is 530px, we want the 720p file instead of 480p
361     let maxResolution = this.videoFiles[0].resolution.id
362     for (let i = this.videoFiles.length - 1; i >= 0; i--) {
363       const resolutionId = this.videoFiles[i].resolution.id
364       if (resolutionId >= playerHeight) {
365         maxResolution = resolutionId
366         break
367       }
368     }
369
370     // Filter videos we can play according to our screen resolution and bandwidth
371     const filteredFiles = this.videoFiles
372                               .filter(f => f.resolution.id <= maxResolution)
373                               .filter(f => {
374                                 const fileBitrate = (f.size / this.videoDuration)
375                                 let threshold = fileBitrate
376
377                                 // If this is for a higher resolution or an initial load: add a margin
378                                 if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
379                                   threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
380                                 }
381
382                                 return averageDownloadSpeed > threshold
383                               })
384
385     // If the download speed is too bad, return the lowest resolution we have
386     if (filteredFiles.length === 0) return videoFileMinByResolution(this.videoFiles)
387
388     return videoFileMaxByResolution(filteredFiles)
389   }
390
391   private getAndSaveActualDownloadSpeed () {
392     const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
393     const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
394     if (lastDownloadSpeeds.length === 0) return -1
395
396     const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
397     const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
398
399     // Save the average bandwidth for future use
400     saveAverageBandwidth(averageBandwidth)
401
402     return averageBandwidth
403   }
404
405   private initializePlayer () {
406     this.buildQualities()
407
408     if (this.autoplay === true) {
409       this.player.posterImage.hide()
410
411       return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
412     }
413
414     // Proxy first play
415     const oldPlay = this.player.play.bind(this.player)
416     this.player.play = () => {
417       this.player.addClass('vjs-has-big-play-button-clicked')
418       this.player.play = oldPlay
419
420       this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
421     }
422   }
423
424   private runAutoQualityScheduler () {
425     this.autoQualityInterval = setInterval(() => {
426
427       // Not initialized or in HTTP fallback
428       if (this.torrent === undefined || this.torrent === null) return
429       if (this.autoResolution === false) return
430       if (this.isAutoResolutionObservation === true) return
431
432       const file = this.getAppropriateFile()
433       let changeResolution = false
434       let changeResolutionDelay = 0
435
436       // Lower resolution
437       if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
438         console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
439         changeResolution = true
440       } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
441         console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
442         changeResolution = true
443         changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
444       }
445
446       if (changeResolution === true) {
447         this.updateResolution(file.resolution.id, changeResolutionDelay)
448
449         // Wait some seconds in observation of our new resolution
450         this.isAutoResolutionObservation = true
451
452         this.qualityObservationTimer = setTimeout(() => {
453           this.isAutoResolutionObservation = false
454         }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
455       }
456     }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
457   }
458
459   private isPlayerWaiting () {
460     return this.player && this.player.hasClass('vjs-waiting')
461   }
462
463   private runTorrentInfoScheduler () {
464     this.torrentInfoInterval = setInterval(() => {
465       // Not initialized yet
466       if (this.torrent === undefined) return
467
468       // Http fallback
469       if (this.torrent === null) return this.player.trigger('p2pInfo', false)
470
471       // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
472       if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
473
474       return this.player.trigger('p2pInfo', {
475         p2p: {
476           downloadSpeed: this.torrent.downloadSpeed,
477           numPeers: this.torrent.numPeers,
478           uploadSpeed: this.torrent.uploadSpeed,
479           downloaded: this.torrent.downloaded,
480           uploaded: this.torrent.uploaded
481         }
482       } as PlayerNetworkInfo)
483     }, this.CONSTANTS.INFO_SCHEDULER)
484   }
485
486   private fallbackToHttp (options: PlayOptions, done?: Function) {
487     const paused = this.player.paused()
488
489     this.disableAutoResolution(true)
490
491     this.flushVideoFile(this.currentVideoFile, true)
492     this.torrent = null
493
494     // Enable error display now this is our last fallback
495     this.player.one('error', () => this.enableErrorDisplay())
496
497     const httpUrl = this.currentVideoFile.fileUrl
498     this.player.src = this.savePlayerSrcFunction
499     this.player.src(httpUrl)
500
501     this.changeQuality()
502
503     // We changed the source, so reinit captions
504     this.player.trigger('sourcechange')
505
506     return this.tryToPlay(err => {
507       if (err && done) return done(err)
508
509       if (options.seek) this.seek(options.seek)
510       if (options.forcePlay === false && paused === true) this.player.pause()
511
512       if (done) return done()
513     })
514   }
515
516   private handleError (err: Error | string) {
517     return this.player.trigger('customError', { err })
518   }
519
520   private enableErrorDisplay () {
521     this.player.addClass('vjs-error-display-enabled')
522   }
523
524   private disableErrorDisplay () {
525     this.player.removeClass('vjs-error-display-enabled')
526   }
527
528   private isIOS () {
529     return !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
530   }
531
532   private pickAverageVideoFile () {
533     if (this.videoFiles.length === 1) return this.videoFiles[0]
534
535     return this.videoFiles[Math.floor(this.videoFiles.length / 2)]
536   }
537
538   private stopTorrent (torrent: WebTorrent.Torrent) {
539     torrent.pause()
540     // Pause does not remove actual peers (in particular the webseed peer)
541     torrent.removePeer(torrent[ 'ws' ])
542   }
543
544   private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
545     this.destroyingFakeRenderer = false
546
547     const fakeVideoElem = document.createElement('video')
548     renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
549       this.fakeRenderer = renderer
550
551       // The renderer returns an error when we destroy it, so skip them
552       if (this.destroyingFakeRenderer === false && err) {
553         console.error('Cannot render new torrent in fake video element.', err)
554       }
555
556       // Load the future file at the correct time (in delay MS - 2 seconds)
557       fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
558     })
559   }
560
561   private destroyFakeRenderer () {
562     if (this.fakeRenderer) {
563       this.destroyingFakeRenderer = true
564
565       if (this.fakeRenderer.destroy) {
566         try {
567           this.fakeRenderer.destroy()
568         } catch (err) {
569           console.log('Cannot destroy correctly fake renderer.', err)
570         }
571       }
572       this.fakeRenderer = undefined
573     }
574   }
575
576   private buildQualities () {
577     const qualityLevelsPayload = []
578
579     for (const file of this.videoFiles) {
580       const representation = {
581         id: file.resolution.id,
582         label: this.buildQualityLabel(file),
583         height: file.resolution.id,
584         _enabled: true
585       }
586
587       this.player.qualityLevels().addQualityLevel(representation)
588
589       qualityLevelsPayload.push({
590         id: representation.id,
591         label: representation.label,
592         selected: false
593       })
594     }
595
596     const payload: LoadedQualityData = {
597       qualitySwitchCallback: (d: any) => this.qualitySwitchCallback(d),
598       qualityData: {
599         video: qualityLevelsPayload
600       }
601     }
602     this.player.tech_.trigger('loadedqualitydata', payload)
603   }
604
605   private buildQualityLabel (file: VideoFile) {
606     let label = file.resolution.label
607
608     if (file.fps && file.fps >= 50) {
609       label += file.fps
610     }
611
612     return label
613   }
614
615   private qualitySwitchCallback (id: number) {
616     if (id === -1) {
617       if (this.autoResolutionPossible === true) this.enableAutoResolution()
618       return
619     }
620
621     this.disableAutoResolution()
622     this.updateResolution(id)
623   }
624
625   private changeQuality () {
626     const resolutionId = this.currentVideoFile.resolution.id
627     const qualityLevels = this.player.qualityLevels()
628
629     if (resolutionId === -1) {
630       qualityLevels.selectedIndex = -1
631       return
632     }
633
634     for (let i = 0; i < qualityLevels; i++) {
635       const q = this.player.qualityLevels[i]
636       if (q.height === resolutionId) qualityLevels.selectedIndex = i
637     }
638   }
639 }
640
641 videojs.registerPlugin('webtorrent', WebTorrentPlugin)
642 export { WebTorrentPlugin }