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