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