]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/assets/player/peertube-player-options-builder.ts
Add latency setting support
[github/Chocobozzz/PeerTube.git] / client / src / assets / player / peertube-player-options-builder.ts
1 import videojs from 'video.js'
2 import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
3 import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
4 import { PluginsManager } from '@root-helpers/plugins-manager'
5 import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
6 import { isDefaultLocale } from '@shared/core-utils/i18n'
7 import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
8 import { copyToClipboard } from '../../root-helpers/utils'
9 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
10 import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
11 import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
12 import { getAverageBandwidthInStore } from './peertube-player-local-storage'
13 import {
14 NextPreviousVideoButtonOptions,
15 P2PMediaLoaderPluginOptions,
16 PeerTubeLinkButtonOptions,
17 PlaylistPluginOptions,
18 UserWatching,
19 VideoJSCaption,
20 VideoJSPluginOptions
21 } from './peertube-videojs-typings'
22 import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
23
24 export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
25
26 export type WebtorrentOptions = {
27 videoFiles: VideoFile[]
28 }
29
30 export type P2PMediaLoaderOptions = {
31 playlistUrl: string
32 segmentsSha256Url: string
33 trackerAnnounce: string[]
34 redundancyBaseUrls: string[]
35 videoFiles: VideoFile[]
36 }
37
38 export interface CustomizationOptions {
39 startTime: number | string
40 stopTime: number | string
41
42 controls?: boolean
43 muted?: boolean
44 loop?: boolean
45 subtitle?: string
46 resume?: string
47
48 peertubeLink: boolean
49 }
50
51 export interface CommonOptions extends CustomizationOptions {
52 playerElement: HTMLVideoElement
53 onPlayerElementChange: (element: HTMLVideoElement) => void
54
55 autoplay: boolean
56 p2pEnabled: boolean
57
58 nextVideo?: () => void
59 hasNextVideo?: () => boolean
60
61 previousVideo?: () => void
62 hasPreviousVideo?: () => boolean
63
64 playlist?: PlaylistPluginOptions
65
66 videoDuration: number
67 enableHotkeys: boolean
68 inactivityTimeout: number
69 poster: string
70
71 theaterButton: boolean
72 captions: boolean
73
74 videoViewUrl: string
75 embedUrl: string
76 embedTitle: string
77
78 isLive: boolean
79 liveOptions?: {
80 latencyMode: LiveVideoLatencyMode
81 }
82
83 language?: string
84
85 videoCaptions: VideoJSCaption[]
86
87 videoUUID: string
88 videoShortUUID: string
89
90 userWatching?: UserWatching
91
92 serverUrl: string
93
94 errorNotifier: (message: string) => void
95 }
96
97 export type PeertubePlayerManagerOptions = {
98 common: CommonOptions
99 webtorrent: WebtorrentOptions
100 p2pMediaLoader?: P2PMediaLoaderOptions
101
102 pluginsManager: PluginsManager
103 }
104
105 export class PeertubePlayerOptionsBuilder {
106
107 constructor (
108 private mode: PlayerMode,
109 private options: PeertubePlayerManagerOptions,
110 private p2pMediaLoaderModule?: any
111 ) {
112
113 }
114
115 getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions {
116 const commonOptions = this.options.common
117 const isHLS = this.mode === 'p2p-media-loader'
118
119 let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed)
120 const html5 = {
121 preloadTextTracks: false
122 }
123
124 const plugins: VideoJSPluginOptions = {
125 peertube: {
126 mode: this.mode,
127 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
128 videoViewUrl: commonOptions.videoViewUrl,
129 videoDuration: commonOptions.videoDuration,
130 userWatching: commonOptions.userWatching,
131 subtitle: commonOptions.subtitle,
132 videoCaptions: commonOptions.videoCaptions,
133 stopTime: commonOptions.stopTime,
134 isLive: commonOptions.isLive,
135 videoUUID: commonOptions.videoUUID
136 }
137 }
138
139 if (commonOptions.playlist) {
140 plugins.playlist = commonOptions.playlist
141 }
142
143 if (isHLS) {
144 const { hlsjs } = this.addP2PMediaLoaderOptions(plugins)
145
146 Object.assign(html5, hlsjs.html5)
147 }
148
149 if (this.mode === 'webtorrent') {
150 this.addWebTorrentOptions(plugins, alreadyPlayed)
151
152 // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
153 autoplay = false
154 }
155
156 const videojsOptions = {
157 html5,
158
159 // We don't use text track settings for now
160 textTrackSettings: false as any, // FIXME: typings
161 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
162 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
163
164 muted: commonOptions.muted !== undefined
165 ? commonOptions.muted
166 : undefined, // Undefined so the player knows it has to check the local storage
167
168 autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed),
169
170 poster: commonOptions.poster,
171 inactivityTimeout: commonOptions.inactivityTimeout,
172 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
173
174 plugins,
175
176 controlBar: {
177 children: this.getControlBarChildren(this.mode, {
178 videoShortUUID: commonOptions.videoShortUUID,
179 p2pEnabled: commonOptions.p2pEnabled,
180
181 captions: commonOptions.captions,
182 peertubeLink: commonOptions.peertubeLink,
183 theaterButton: commonOptions.theaterButton,
184
185 nextVideo: commonOptions.nextVideo,
186 hasNextVideo: commonOptions.hasNextVideo,
187
188 previousVideo: commonOptions.previousVideo,
189 hasPreviousVideo: commonOptions.hasPreviousVideo
190 }) as any // FIXME: typings
191 }
192 }
193
194 if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
195 Object.assign(videojsOptions, { language: commonOptions.language })
196 }
197
198 return videojsOptions
199 }
200
201 private addP2PMediaLoaderOptions (plugins: VideoJSPluginOptions) {
202 const p2pMediaLoaderOptions = this.options.p2pMediaLoader
203 const commonOptions = this.options.common
204
205 const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls)
206
207 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
208 redundancyUrlManager,
209 type: 'application/x-mpegURL',
210 startTime: commonOptions.startTime,
211 src: p2pMediaLoaderOptions.playlistUrl
212 }
213
214 const p2pMediaLoaderConfig: HlsJsEngineSettings = {
215 loader: this.getP2PMediaLoaderOptions(redundancyUrlManager),
216 segments: {
217 swarmId: p2pMediaLoaderOptions.playlistUrl
218 }
219 }
220
221 const hlsjs = {
222 levelLabelHandler: (level: { height: number, width: number }) => {
223 const resolution = Math.min(level.height || 0, level.width || 0)
224
225 const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution)
226 // We don't have files for live videos
227 if (!file) return level.height
228
229 let label = file.resolution.label
230 if (file.fps >= 50) label += file.fps
231
232 return label
233 },
234 html5: {
235 hlsjsConfig: this.getHLSOptions(p2pMediaLoaderConfig)
236 }
237 }
238
239 const toAssign = { p2pMediaLoader, hlsjs }
240 Object.assign(plugins, toAssign)
241
242 return toAssign
243 }
244
245 private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): Partial<HybridLoaderSettings> {
246 let consumeOnly = false
247 if ((navigator as any)?.connection?.type === 'cellular') {
248 console.log('We are on a cellular connection: disabling seeding.')
249 consumeOnly = true
250 }
251
252 const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce
253 .filter(t => t.startsWith('ws'))
254
255 const specificLiveOrVODOptions = this.options.common.isLive
256 ? this.getP2PMediaLoaderLiveOptions()
257 : this.getP2PMediaLoaderVODOptions()
258
259 return {
260 trackerAnnounce,
261 rtcConfig: getRtcConfig(),
262
263 simultaneousHttpDownloads: 1,
264 httpFailedSegmentTimeout: 1000,
265
266 segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive),
267 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1),
268
269 useP2P: this.options.common.p2pEnabled,
270 consumeOnly,
271
272 ...specificLiveOrVODOptions
273 }
274 }
275
276 private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
277 const base = {
278 requiredSegmentsPriority: 1
279 }
280
281 const latencyMode = this.options.common.liveOptions.latencyMode
282
283 switch (latencyMode) {
284 case LiveVideoLatencyMode.SMALL_LATENCY:
285 return {
286 ...base,
287
288 useP2P: false,
289 httpDownloadProbability: 1
290 }
291
292 case LiveVideoLatencyMode.HIGH_LATENCY:
293 return base
294
295 default:
296 return base
297 }
298 }
299
300 private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
301 return {
302 requiredSegmentsPriority: 3,
303
304 cachedSegmentExpiration: 86400000,
305 cachedSegmentsCount: 100,
306
307 httpDownloadMaxPriority: 9,
308 httpDownloadProbability: 0.06,
309 httpDownloadProbabilitySkipIfNoPeers: true,
310
311 p2pDownloadMaxPriority: 50
312 }
313 }
314
315 private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) {
316 const specificLiveOrVODOptions = this.options.common.isLive
317 ? this.getHLSLiveOptions()
318 : this.getHLSVODOptions()
319
320 const base = {
321 capLevelToPlayerSize: true,
322 autoStartLoad: false,
323
324 loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass(),
325
326 ...specificLiveOrVODOptions
327 }
328
329 const averageBandwidth = getAverageBandwidthInStore()
330 if (!averageBandwidth) return base
331
332 return {
333 ...base,
334
335 abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s
336 startLevel: -1,
337 testBandwidth: false,
338 debug: false
339 }
340 }
341
342 private getHLSLiveOptions () {
343 const latencyMode = this.options.common.liveOptions.latencyMode
344
345 switch (latencyMode) {
346 case LiveVideoLatencyMode.SMALL_LATENCY:
347 return {
348 liveSyncDurationCount: 2
349 }
350
351 case LiveVideoLatencyMode.HIGH_LATENCY:
352 return {
353 liveSyncDurationCount: 10
354 }
355
356 default:
357 return {
358 liveSyncDurationCount: 5
359 }
360 }
361 }
362
363 private getHLSVODOptions () {
364 return {
365 liveSyncDurationCount: 5
366 }
367 }
368
369 private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) {
370 const commonOptions = this.options.common
371 const webtorrentOptions = this.options.webtorrent
372 const p2pMediaLoaderOptions = this.options.p2pMediaLoader
373
374 const autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) === 'play'
375
376 const webtorrent = {
377 autoplay,
378
379 playerRefusedP2P: commonOptions.p2pEnabled === false,
380 videoDuration: commonOptions.videoDuration,
381 playerElement: commonOptions.playerElement,
382
383 videoFiles: webtorrentOptions.videoFiles.length !== 0
384 ? webtorrentOptions.videoFiles
385 // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
386 : p2pMediaLoaderOptions?.videoFiles || [],
387
388 startTime: commonOptions.startTime
389 }
390
391 Object.assign(plugins, { webtorrent })
392 }
393
394 private getControlBarChildren (mode: PlayerMode, options: {
395 p2pEnabled: boolean
396 videoShortUUID: string
397
398 peertubeLink: boolean
399 theaterButton: boolean
400 captions: boolean
401
402 nextVideo?: () => void
403 hasNextVideo?: () => boolean
404
405 previousVideo?: () => void
406 hasPreviousVideo?: () => boolean
407 }) {
408 const settingEntries = []
409 const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
410
411 // Keep an order
412 settingEntries.push('playbackRateMenuButton')
413 if (options.captions === true) settingEntries.push('captionsButton')
414 settingEntries.push('resolutionMenuButton')
415
416 const children = {}
417
418 if (options.previousVideo) {
419 const buttonOptions: NextPreviousVideoButtonOptions = {
420 type: 'previous',
421 handler: options.previousVideo,
422 isDisabled: () => {
423 if (!options.hasPreviousVideo) return false
424
425 return !options.hasPreviousVideo()
426 }
427 }
428
429 Object.assign(children, {
430 previousVideoButton: buttonOptions
431 })
432 }
433
434 Object.assign(children, { playToggle: {} })
435
436 if (options.nextVideo) {
437 const buttonOptions: NextPreviousVideoButtonOptions = {
438 type: 'next',
439 handler: options.nextVideo,
440 isDisabled: () => {
441 if (!options.hasNextVideo) return false
442
443 return !options.hasNextVideo()
444 }
445 }
446
447 Object.assign(children, {
448 nextVideoButton: buttonOptions
449 })
450 }
451
452 Object.assign(children, {
453 currentTimeDisplay: {},
454 timeDivider: {},
455 durationDisplay: {},
456 liveDisplay: {},
457
458 flexibleWidthSpacer: {},
459 progressControl: {
460 children: {
461 seekBar: {
462 children: {
463 [loadProgressBar]: {},
464 mouseTimeDisplay: {},
465 playProgressBar: {}
466 }
467 }
468 }
469 },
470
471 p2PInfoButton: {
472 p2pEnabled: options.p2pEnabled
473 },
474
475 muteToggle: {},
476 volumeControl: {},
477
478 settingsButton: {
479 setup: {
480 maxHeightOffset: 40
481 },
482 entries: settingEntries
483 }
484 })
485
486 if (options.peertubeLink === true) {
487 Object.assign(children, {
488 peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions
489 })
490 }
491
492 if (options.theaterButton === true) {
493 Object.assign(children, {
494 theaterButton: {}
495 })
496 }
497
498 Object.assign(children, {
499 fullscreenToggle: {}
500 })
501
502 return children
503 }
504
505 private getAutoPlayValue (autoplay: any, alreadyPlayed: boolean) {
506 if (autoplay !== true) return autoplay
507
508 // On first play, disable autoplay to avoid issues
509 // But if the player already played videos, we can safely autoplay next ones
510 if (isIOS() || isSafari()) {
511 return alreadyPlayed ? 'play' : false
512 }
513
514 return 'play'
515 }
516
517 getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) {
518 const content = () => {
519 const isLoopEnabled = player.options_['loop']
520
521 const items = [
522 {
523 icon: 'repeat',
524 label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
525 listener: function () {
526 player.options_['loop'] = !isLoopEnabled
527 }
528 },
529 {
530 label: player.localize('Copy the video URL'),
531 listener: function () {
532 copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID }))
533 }
534 },
535 {
536 label: player.localize('Copy the video URL at the current time'),
537 listener: function (this: videojs.Player) {
538 const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID })
539
540 copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() }))
541 }
542 },
543 {
544 icon: 'code',
545 label: player.localize('Copy embed code'),
546 listener: () => {
547 copyToClipboard(buildVideoOrPlaylistEmbed(commonOptions.embedUrl, commonOptions.embedTitle))
548 }
549 }
550 ]
551
552 if (this.mode === 'webtorrent') {
553 items.push({
554 label: player.localize('Copy magnet URI'),
555 listener: function (this: videojs.Player) {
556 copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
557 }
558 })
559 }
560
561 items.push({
562 icon: 'info',
563 label: player.localize('Stats for nerds'),
564 listener: () => {
565 player.stats().show()
566 }
567 })
568
569 return items.map(i => ({
570 ...i,
571 label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
572 }))
573 }
574
575 return { content }
576 }
577 }