Speedup embed first paint
[oweals/peertube.git] / client / src / assets / player / peertube-player-manager.ts
1 import { VideoFile } from '../../../../shared/models/videos'
2 // @ts-ignore
3 import * as videojs from 'video.js'
4 import 'videojs-hotkeys'
5 import 'videojs-dock'
6 import 'videojs-contextmenu-ui'
7 import 'videojs-contrib-quality-levels'
8 import './upnext/upnext-plugin'
9 import './peertube-plugin'
10 import './videojs-components/peertube-link-button'
11 import './videojs-components/resolution-menu-button'
12 import './videojs-components/settings-menu-button'
13 import './videojs-components/p2p-info-button'
14 import './videojs-components/peertube-load-progress-bar'
15 import './videojs-components/theater-button'
16 import { P2PMediaLoaderPluginOptions, UserWatching, VideoJSCaption, VideoJSPluginOptions, videojsUntyped } from './peertube-videojs-typings'
17 import { buildVideoEmbed, buildVideoLink, copyToClipboard, getRtcConfig } from './utils'
18 import { isDefaultLocale } from '../../../../shared/models/i18n/i18n'
19 import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
20 import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
21 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
22 import { getStoredP2PEnabled } from './peertube-player-local-storage'
23 import { TranslationsManager } from './translations-manager'
24
25 // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
26 videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
27 // Change Captions to Subtitles/CC
28 videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
29 // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
30 videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
31
32 export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
33
34 export type WebtorrentOptions = {
35   videoFiles: VideoFile[]
36 }
37
38 export type P2PMediaLoaderOptions = {
39   playlistUrl: string
40   segmentsSha256Url: string
41   trackerAnnounce: string[]
42   redundancyBaseUrls: string[]
43   videoFiles: VideoFile[]
44 }
45
46 export interface CustomizationOptions {
47   startTime: number | string
48   stopTime: number | string
49
50   controls?: boolean
51   muted?: boolean
52   loop?: boolean
53   subtitle?: string
54
55   peertubeLink: boolean
56 }
57
58 export interface CommonOptions extends CustomizationOptions {
59   playerElement: HTMLVideoElement
60   onPlayerElementChange: (element: HTMLVideoElement) => void
61
62   autoplay: boolean
63   videoDuration: number
64   enableHotkeys: boolean
65   inactivityTimeout: number
66   poster: string
67
68   theaterButton: boolean
69   captions: boolean
70
71   videoViewUrl: string
72   embedUrl: string
73
74   language?: string
75
76   videoCaptions: VideoJSCaption[]
77
78   userWatching?: UserWatching
79
80   serverUrl: string
81 }
82
83 export type PeertubePlayerManagerOptions = {
84   common: CommonOptions,
85   webtorrent: WebtorrentOptions,
86   p2pMediaLoader?: P2PMediaLoaderOptions
87 }
88
89 export class PeertubePlayerManager {
90   private static playerElementClassName: string
91   private static onPlayerChange: (player: any) => void
92
93   static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: any) => void) {
94     let p2pMediaLoader: any
95
96     this.onPlayerChange = onPlayerChange
97     this.playerElementClassName = options.common.playerElement.className
98
99     if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
100     if (mode === 'p2p-media-loader') {
101       [ p2pMediaLoader ] = await Promise.all([
102         import('p2p-media-loader-hlsjs'),
103         import('./p2p-media-loader/p2p-media-loader-plugin')
104       ])
105     }
106
107     const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader)
108
109     await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
110
111     const self = this
112     return new Promise(res => {
113       videojs(options.common.playerElement, videojsOptions, function (this: any) {
114         const player = this
115
116         let alreadyFallback = false
117
118         player.tech_.one('error', () => {
119           if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
120           alreadyFallback = true
121         })
122
123         player.one('error', () => {
124           if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
125           alreadyFallback = true
126         })
127
128         self.addContextMenu(mode, player, options.common.embedUrl)
129
130         return res(player)
131       })
132     })
133   }
134
135   private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) {
136     if (currentMode === 'webtorrent') return
137
138     console.log('Fallback to webtorrent.')
139
140     const newVideoElement = document.createElement('video')
141     newVideoElement.className = this.playerElementClassName
142
143     // VideoJS wraps our video element inside a div
144     let currentParentPlayerElement = options.common.playerElement.parentNode
145     // Fix on IOS, don't ask me why
146     if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode
147
148     currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
149
150     options.common.playerElement = newVideoElement
151     options.common.onPlayerElementChange(newVideoElement)
152
153     player.dispose()
154
155     await import('./webtorrent/webtorrent-plugin')
156
157     const mode = 'webtorrent'
158     const videojsOptions = this.getVideojsOptions(mode, options)
159
160     const self = this
161     videojs(newVideoElement, videojsOptions, function (this: any) {
162       const player = this
163
164       self.addContextMenu(mode, player, options.common.embedUrl)
165
166       PeertubePlayerManager.onPlayerChange(player)
167     })
168   }
169
170   private static getVideojsOptions (mode: PlayerMode, options: PeertubePlayerManagerOptions, p2pMediaLoaderModule?: any) {
171     const commonOptions = options.common
172
173     let autoplay = commonOptions.autoplay
174     let html5 = {}
175
176     const plugins: VideoJSPluginOptions = {
177       peertube: {
178         mode,
179         autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
180         videoViewUrl: commonOptions.videoViewUrl,
181         videoDuration: commonOptions.videoDuration,
182         userWatching: commonOptions.userWatching,
183         subtitle: commonOptions.subtitle,
184         videoCaptions: commonOptions.videoCaptions,
185         stopTime: commonOptions.stopTime
186       }
187     }
188
189     if (commonOptions.enableHotkeys === true) {
190       PeertubePlayerManager.addHotkeysOptions(plugins)
191     }
192
193     if (mode === 'p2p-media-loader') {
194       const { streamrootHls } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule)
195
196       html5 = streamrootHls.html5
197     }
198
199     if (mode === 'webtorrent') {
200       PeertubePlayerManager.addWebTorrentOptions(plugins, options)
201
202       // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
203       autoplay = false
204     }
205
206     const videojsOptions = {
207       html5,
208
209       // We don't use text track settings for now
210       textTrackSettings: false,
211       controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
212       loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
213
214       muted: commonOptions.muted !== undefined
215         ? commonOptions.muted
216         : undefined, // Undefined so the player knows it has to check the local storage
217
218       autoplay: autoplay === true
219         ? 'any' // Use 'any' instead of true to get notifier by videojs if autoplay fails
220         : autoplay,
221
222       poster: commonOptions.poster,
223       inactivityTimeout: commonOptions.inactivityTimeout,
224       playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 2 ],
225
226       plugins,
227
228       controlBar: {
229         children: this.getControlBarChildren(mode, {
230           captions: commonOptions.captions,
231           peertubeLink: commonOptions.peertubeLink,
232           theaterButton: commonOptions.theaterButton
233         })
234       }
235     }
236
237     if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
238       Object.assign(videojsOptions, { language: commonOptions.language })
239     }
240
241     return videojsOptions
242   }
243
244   private static addP2PMediaLoaderOptions (
245     plugins: VideoJSPluginOptions,
246     options: PeertubePlayerManagerOptions,
247     p2pMediaLoaderModule: any
248   ) {
249     const p2pMediaLoaderOptions = options.p2pMediaLoader
250     const commonOptions = options.common
251
252     const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
253                                                  .filter(t => t.startsWith('ws'))
254
255     const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls)
256
257     const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
258       redundancyUrlManager,
259       type: 'application/x-mpegURL',
260       startTime: commonOptions.startTime,
261       src: p2pMediaLoaderOptions.playlistUrl
262     }
263
264     let consumeOnly = false
265     // FIXME: typings
266     if (navigator && (navigator as any).connection && (navigator as any).connection.type === 'cellular') {
267       console.log('We are on a cellular connection: disabling seeding.')
268       consumeOnly = true
269     }
270
271     const p2pMediaLoaderConfig = {
272       loader: {
273         trackerAnnounce,
274         segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
275         rtcConfig: getRtcConfig(),
276         requiredSegmentsPriority: 5,
277         segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
278         useP2P: getStoredP2PEnabled(),
279         consumeOnly
280       },
281       segments: {
282         swarmId: p2pMediaLoaderOptions.playlistUrl
283       }
284     }
285     const streamrootHls = {
286       levelLabelHandler: (level: { height: number, width: number }) => {
287         const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === level.height)
288
289         let label = file.resolution.label
290         if (file.fps >= 50) label += file.fps
291
292         return label
293       },
294       html5: {
295         hlsjsConfig: {
296           capLevelToPlayerSize: true,
297           autoStartLoad: false,
298           liveSyncDurationCount: 7,
299           loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
300         }
301       }
302     }
303
304     const toAssign = { p2pMediaLoader, streamrootHls }
305     Object.assign(plugins, toAssign)
306
307     return toAssign
308   }
309
310   private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) {
311     const commonOptions = options.common
312     const webtorrentOptions = options.webtorrent
313
314     const webtorrent = {
315       autoplay: commonOptions.autoplay,
316       videoDuration: commonOptions.videoDuration,
317       playerElement: commonOptions.playerElement,
318       videoFiles: webtorrentOptions.videoFiles,
319       startTime: commonOptions.startTime
320     }
321
322     Object.assign(plugins, { webtorrent })
323   }
324
325   private static getControlBarChildren (mode: PlayerMode, options: {
326     peertubeLink: boolean
327     theaterButton: boolean,
328     captions: boolean
329   }) {
330     const settingEntries = []
331     const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
332
333     // Keep an order
334     settingEntries.push('playbackRateMenuButton')
335     if (options.captions === true) settingEntries.push('captionsButton')
336     settingEntries.push('resolutionMenuButton')
337
338     const children = {
339       'playToggle': {},
340       'currentTimeDisplay': {},
341       'timeDivider': {},
342       'durationDisplay': {},
343       'liveDisplay': {},
344
345       'flexibleWidthSpacer': {},
346       'progressControl': {
347         children: {
348           'seekBar': {
349             children: {
350               [loadProgressBar]: {},
351               'mouseTimeDisplay': {},
352               'playProgressBar': {}
353             }
354           }
355         }
356       },
357
358       'p2PInfoButton': {},
359
360       'muteToggle': {},
361       'volumeControl': {},
362
363       'settingsButton': {
364         setup: {
365           maxHeightOffset: 40
366         },
367         entries: settingEntries
368       }
369     }
370
371     if (options.peertubeLink === true) {
372       Object.assign(children, {
373         'peerTubeLinkButton': {}
374       })
375     }
376
377     if (options.theaterButton === true) {
378       Object.assign(children, {
379         'theaterButton': {}
380       })
381     }
382
383     Object.assign(children, {
384       'fullscreenToggle': {}
385     })
386
387     return children
388   }
389
390   private static addContextMenu (mode: PlayerMode, player: any, videoEmbedUrl: string) {
391     const content = [
392       {
393         label: player.localize('Copy the video URL'),
394         listener: function () {
395           copyToClipboard(buildVideoLink())
396         }
397       },
398       {
399         label: player.localize('Copy the video URL at the current time'),
400         listener: function () {
401           const player = this as videojs.Player
402           copyToClipboard(buildVideoLink({ startTime: player.currentTime() }))
403         }
404       },
405       {
406         label: player.localize('Copy embed code'),
407         listener: () => {
408           copyToClipboard(buildVideoEmbed(videoEmbedUrl))
409         }
410       }
411     ]
412
413     if (mode === 'webtorrent') {
414       content.push({
415         label: player.localize('Copy magnet URI'),
416         listener: function () {
417           const player = this as videojs.Player
418           copyToClipboard(player.webtorrent().getCurrentVideoFile().magnetUri)
419         }
420       })
421     }
422
423     player.contextmenuUI({ content })
424   }
425
426   private static addHotkeysOptions (plugins: VideoJSPluginOptions) {
427     Object.assign(plugins, {
428       hotkeys: {
429         enableVolumeScroll: false,
430         enableModifiersForNumbers: false,
431
432         fullscreenKey: function (event: KeyboardEvent) {
433           // fullscreen with the f key or Ctrl+Enter
434           return event.key === 'f' || (event.ctrlKey && event.key === 'Enter')
435         },
436
437         seekStep: function (event: KeyboardEvent) {
438           // mimic VLC seek behavior, and default to 5 (original value is 5).
439           if (event.ctrlKey && event.altKey) {
440             return 5 * 60
441           } else if (event.ctrlKey) {
442             return 60
443           } else if (event.altKey) {
444             return 10
445           } else {
446             return 5
447           }
448         },
449
450         customKeys: {
451           increasePlaybackRateKey: {
452             key: function (event: KeyboardEvent) {
453               return event.key === '>'
454             },
455             handler: function (player: videojs.Player) {
456               player.playbackRate((player.playbackRate() + 0.1).toFixed(2))
457             }
458           },
459           decreasePlaybackRateKey: {
460             key: function (event: KeyboardEvent) {
461               return event.key === '<'
462             },
463             handler: function (player: videojs.Player) {
464               player.playbackRate((player.playbackRate() - 0.1).toFixed(2))
465             }
466           },
467           frameByFrame: {
468             key: function (event: KeyboardEvent) {
469               return event.key === '.'
470             },
471             handler: function (player: videojs.Player) {
472               player.pause()
473               // Calculate movement distance (assuming 30 fps)
474               const dist = 1 / 30
475               player.currentTime(player.currentTime() + dist)
476             }
477           }
478         }
479       }
480     })
481   }
482 }
483
484 // ############################################################################
485
486 export {
487   videojs
488 }