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