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