diff options
author | Chocobozzz <me@florianbigard.com> | 2022-02-02 11:16:23 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-02-02 11:26:18 +0100 |
commit | c4207f978e23c77f09c4646b940dfd532281300f (patch) | |
tree | b11f459839dced708a80f6cff5d57e49ecf45917 /client/src/assets/player | |
parent | b25fdc73fdf22896093e12c51bb64160c0410879 (diff) | |
download | PeerTube-c4207f978e23c77f09c4646b940dfd532281300f.tar.gz PeerTube-c4207f978e23c77f09c4646b940dfd532281300f.tar.zst PeerTube-c4207f978e23c77f09c4646b940dfd532281300f.zip |
Fast forward on HLS decode error
Diffstat (limited to 'client/src/assets/player')
-rw-r--r-- | client/src/assets/player/index.ts | 2 | ||||
-rw-r--r-- | client/src/assets/player/p2p-media-loader/hls-plugin.ts | 6 | ||||
-rw-r--r-- | client/src/assets/player/peertube-player-manager.ts | 602 | ||||
-rw-r--r-- | client/src/assets/player/peertube-player-options-builder.ts | 489 | ||||
-rw-r--r-- | client/src/assets/player/peertube-plugin.ts | 8 | ||||
-rw-r--r-- | client/src/assets/player/peertube-videojs-typings.ts | 2 | ||||
-rw-r--r-- | client/src/assets/player/webtorrent/webtorrent-plugin.ts | 12 |
7 files changed, 588 insertions, 533 deletions
diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts new file mode 100644 index 000000000..e2a6ccf24 --- /dev/null +++ b/client/src/assets/player/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './peertube-player-manager' | ||
2 | export * from './peertube-player-options-builder' | ||
diff --git a/client/src/assets/player/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/p2p-media-loader/hls-plugin.ts index 640858025..ae31bcfe1 100644 --- a/client/src/assets/player/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/p2p-media-loader/hls-plugin.ts | |||
@@ -174,6 +174,12 @@ class Html5Hlsjs { | |||
174 | dispose () { | 174 | dispose () { |
175 | this.videoElement.removeEventListener('play', this.handlers.play) | 175 | this.videoElement.removeEventListener('play', this.handlers.play) |
176 | 176 | ||
177 | // FIXME: https://github.com/video-dev/hls.js/issues/4092 | ||
178 | const untypedHLS = this.hls as any | ||
179 | untypedHLS.log = untypedHLS.warn = () => { | ||
180 | // empty | ||
181 | } | ||
182 | |||
177 | this.hls.destroy() | 183 | this.hls.destroy() |
178 | } | 184 | } |
179 | 185 | ||
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index b9a289aa0..2ef42a961 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts | |||
@@ -24,28 +24,12 @@ import './mobile/peertube-mobile-plugin' | |||
24 | import './mobile/peertube-mobile-buttons' | 24 | import './mobile/peertube-mobile-buttons' |
25 | import './hotkeys/peertube-hotkeys-plugin' | 25 | import './hotkeys/peertube-hotkeys-plugin' |
26 | import videojs from 'video.js' | 26 | import videojs from 'video.js' |
27 | import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' | ||
28 | import { PluginsManager } from '@root-helpers/plugins-manager' | 27 | import { PluginsManager } from '@root-helpers/plugins-manager' |
29 | import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' | 28 | import { saveAverageBandwidth } from './peertube-player-local-storage' |
30 | import { isDefaultLocale } from '@shared/core-utils/i18n' | 29 | import { CommonOptions, PeertubePlayerManagerOptions, PeertubePlayerOptionsBuilder, PlayerMode } from './peertube-player-options-builder' |
31 | import { VideoFile } from '@shared/models' | 30 | import { PlayerNetworkInfo } from './peertube-videojs-typings' |
32 | import { copyToClipboard } from '../../root-helpers/utils' | ||
33 | import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' | ||
34 | import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' | ||
35 | import { segmentValidatorFactory } from './p2p-media-loader/segment-validator' | ||
36 | import { getAverageBandwidthInStore, saveAverageBandwidth } from './peertube-player-local-storage' | ||
37 | import { | ||
38 | NextPreviousVideoButtonOptions, | ||
39 | P2PMediaLoaderPluginOptions, | ||
40 | PeerTubeLinkButtonOptions, | ||
41 | PlayerNetworkInfo, | ||
42 | PlaylistPluginOptions, | ||
43 | UserWatching, | ||
44 | VideoJSCaption, | ||
45 | VideoJSPluginOptions | ||
46 | } from './peertube-videojs-typings' | ||
47 | import { TranslationsManager } from './translations-manager' | 31 | import { TranslationsManager } from './translations-manager' |
48 | import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isMobile, isSafari } from './utils' | 32 | import { isMobile } from './utils' |
49 | 33 | ||
50 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | 34 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) |
51 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' | 35 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' |
@@ -56,112 +40,49 @@ CaptionsButton.prototype.controlText_ = 'Subtitles/CC' | |||
56 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | 40 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) |
57 | CaptionsButton.prototype.label_ = ' ' | 41 | CaptionsButton.prototype.label_ = ' ' |
58 | 42 | ||
59 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | ||
60 | |||
61 | export type WebtorrentOptions = { | ||
62 | videoFiles: VideoFile[] | ||
63 | } | ||
64 | |||
65 | export type P2PMediaLoaderOptions = { | ||
66 | playlistUrl: string | ||
67 | segmentsSha256Url: string | ||
68 | trackerAnnounce: string[] | ||
69 | redundancyBaseUrls: string[] | ||
70 | videoFiles: VideoFile[] | ||
71 | } | ||
72 | |||
73 | export interface CustomizationOptions { | ||
74 | startTime: number | string | ||
75 | stopTime: number | string | ||
76 | |||
77 | controls?: boolean | ||
78 | muted?: boolean | ||
79 | loop?: boolean | ||
80 | subtitle?: string | ||
81 | resume?: string | ||
82 | |||
83 | peertubeLink: boolean | ||
84 | } | ||
85 | |||
86 | export interface CommonOptions extends CustomizationOptions { | ||
87 | playerElement: HTMLVideoElement | ||
88 | onPlayerElementChange: (element: HTMLVideoElement) => void | ||
89 | |||
90 | autoplay: boolean | ||
91 | p2pEnabled: boolean | ||
92 | |||
93 | nextVideo?: () => void | ||
94 | hasNextVideo?: () => boolean | ||
95 | |||
96 | previousVideo?: () => void | ||
97 | hasPreviousVideo?: () => boolean | ||
98 | |||
99 | playlist?: PlaylistPluginOptions | ||
100 | |||
101 | videoDuration: number | ||
102 | enableHotkeys: boolean | ||
103 | inactivityTimeout: number | ||
104 | poster: string | ||
105 | |||
106 | theaterButton: boolean | ||
107 | captions: boolean | ||
108 | |||
109 | videoViewUrl: string | ||
110 | embedUrl: string | ||
111 | embedTitle: string | ||
112 | |||
113 | isLive: boolean | ||
114 | |||
115 | language?: string | ||
116 | |||
117 | videoCaptions: VideoJSCaption[] | ||
118 | |||
119 | videoUUID: string | ||
120 | videoShortUUID: string | ||
121 | |||
122 | userWatching?: UserWatching | ||
123 | |||
124 | serverUrl: string | ||
125 | } | ||
126 | |||
127 | export type PeertubePlayerManagerOptions = { | ||
128 | common: CommonOptions | ||
129 | webtorrent: WebtorrentOptions | ||
130 | p2pMediaLoader?: P2PMediaLoaderOptions | ||
131 | |||
132 | pluginsManager: PluginsManager | ||
133 | } | ||
134 | |||
135 | export class PeertubePlayerManager { | 43 | export class PeertubePlayerManager { |
136 | private static playerElementClassName: string | 44 | private static playerElementClassName: string |
137 | private static onPlayerChange: (player: videojs.Player) => void | 45 | private static onPlayerChange: (player: videojs.Player) => void |
138 | private static alreadyPlayed = false | 46 | private static alreadyPlayed = false |
139 | private static pluginsManager: PluginsManager | 47 | private static pluginsManager: PluginsManager |
140 | 48 | ||
49 | private static videojsDecodeErrors = 0 | ||
50 | |||
51 | private static p2pMediaLoaderModule: any | ||
52 | |||
141 | static initState () { | 53 | static initState () { |
142 | PeertubePlayerManager.alreadyPlayed = false | 54 | this.alreadyPlayed = false |
143 | } | 55 | } |
144 | 56 | ||
145 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { | 57 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { |
146 | this.pluginsManager = options.pluginsManager | 58 | this.pluginsManager = options.pluginsManager |
147 | 59 | ||
148 | let p2pMediaLoader: any | ||
149 | |||
150 | this.onPlayerChange = onPlayerChange | 60 | this.onPlayerChange = onPlayerChange |
151 | this.playerElementClassName = options.common.playerElement.className | 61 | this.playerElementClassName = options.common.playerElement.className |
152 | 62 | ||
153 | if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') | 63 | if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') |
154 | if (mode === 'p2p-media-loader') { | 64 | if (mode === 'p2p-media-loader') { |
155 | [ p2pMediaLoader ] = await Promise.all([ | 65 | const [ p2pMediaLoaderModule ] = await Promise.all([ |
156 | import('@peertube/p2p-media-loader-hlsjs'), | 66 | import('@peertube/p2p-media-loader-hlsjs'), |
157 | import('./p2p-media-loader/p2p-media-loader-plugin') | 67 | import('./p2p-media-loader/p2p-media-loader-plugin') |
158 | ]) | 68 | ]) |
159 | } | ||
160 | 69 | ||
161 | const videojsOptions = await this.getVideojsOptions(mode, options, p2pMediaLoader) | 70 | this.p2pMediaLoaderModule = p2pMediaLoaderModule |
71 | } | ||
162 | 72 | ||
163 | await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) | 73 | await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) |
164 | 74 | ||
75 | return this.buildPlayer(mode, options) | ||
76 | } | ||
77 | |||
78 | private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> { | ||
79 | const videojsOptionsBuilder = new PeertubePlayerOptionsBuilder(mode, options, this.p2pMediaLoaderModule) | ||
80 | |||
81 | const videojsOptions = await this.pluginsManager.runHook( | ||
82 | 'filter:internal.player.videojs.options.result', | ||
83 | videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed) | ||
84 | ) | ||
85 | |||
165 | const self = this | 86 | const self = this |
166 | return new Promise(res => { | 87 | return new Promise(res => { |
167 | videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { | 88 | videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { |
@@ -169,27 +90,24 @@ export class PeertubePlayerManager { | |||
169 | 90 | ||
170 | let alreadyFallback = false | 91 | let alreadyFallback = false |
171 | 92 | ||
172 | player.tech(true).one('error', () => { | 93 | const handleError = () => { |
173 | if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) | 94 | if (alreadyFallback) return |
174 | alreadyFallback = true | 95 | alreadyFallback = true |
175 | }) | ||
176 | 96 | ||
177 | player.one('error', () => { | 97 | if (mode === 'p2p-media-loader') { |
178 | if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options) | 98 | self.tryToRecoverHLSError(player.error(), player, options) |
179 | alreadyFallback = true | 99 | } else { |
180 | }) | 100 | self.maybeFallbackToWebTorrent(mode, player, options) |
101 | } | ||
102 | } | ||
103 | |||
104 | player.one('error', () => handleError()) | ||
181 | 105 | ||
182 | player.one('play', () => { | 106 | player.one('play', () => { |
183 | PeertubePlayerManager.alreadyPlayed = true | 107 | self.alreadyPlayed = true |
184 | }) | 108 | }) |
185 | 109 | ||
186 | self.addContextMenu({ | 110 | self.addContextMenu(videojsOptionsBuilder, player, options.common) |
187 | mode, | ||
188 | player, | ||
189 | videoShortUUID: options.common.videoShortUUID, | ||
190 | videoEmbedUrl: options.common.embedUrl, | ||
191 | videoEmbedTitle: options.common.embedTitle | ||
192 | }) | ||
193 | 111 | ||
194 | if (isMobile()) player.peertubeMobile() | 112 | if (isMobile()) player.peertubeMobile() |
195 | if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() | 113 | if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() |
@@ -214,437 +132,77 @@ export class PeertubePlayerManager { | |||
214 | }) | 132 | }) |
215 | } | 133 | } |
216 | 134 | ||
217 | private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) { | 135 | private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) { |
218 | if (currentMode === 'webtorrent') return | 136 | if (err.code === 3) { // Decode error |
219 | |||
220 | console.log('Fallback to webtorrent.') | ||
221 | |||
222 | const newVideoElement = document.createElement('video') | ||
223 | newVideoElement.className = this.playerElementClassName | ||
224 | |||
225 | // VideoJS wraps our video element inside a div | ||
226 | let currentParentPlayerElement = options.common.playerElement.parentNode | ||
227 | // Fix on IOS, don't ask me why | ||
228 | if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode | ||
229 | |||
230 | currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) | ||
231 | |||
232 | options.common.playerElement = newVideoElement | ||
233 | options.common.onPlayerElementChange(newVideoElement) | ||
234 | |||
235 | player.dispose() | ||
236 | |||
237 | await import('./webtorrent/webtorrent-plugin') | ||
238 | |||
239 | const mode = 'webtorrent' | ||
240 | const videojsOptions = await this.getVideojsOptions(mode, options) | ||
241 | 137 | ||
242 | const self = this | 138 | // Display a notification to user |
243 | videojs(newVideoElement, videojsOptions, function (this: videojs.Player) { | 139 | if (this.videojsDecodeErrors === 0) { |
244 | const player = this | 140 | options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.')) |
245 | |||
246 | self.addContextMenu({ | ||
247 | mode, | ||
248 | player, | ||
249 | videoShortUUID: options.common.videoShortUUID, | ||
250 | videoEmbedUrl: options.common.embedUrl, | ||
251 | videoEmbedTitle: options.common.embedTitle | ||
252 | }) | ||
253 | |||
254 | PeertubePlayerManager.onPlayerChange(player) | ||
255 | }) | ||
256 | } | ||
257 | |||
258 | private static async getVideojsOptions ( | ||
259 | mode: PlayerMode, | ||
260 | options: PeertubePlayerManagerOptions, | ||
261 | p2pMediaLoaderModule?: any | ||
262 | ): Promise<videojs.PlayerOptions> { | ||
263 | const commonOptions = options.common | ||
264 | const isHLS = mode === 'p2p-media-loader' | ||
265 | |||
266 | let autoplay = this.getAutoPlayValue(commonOptions.autoplay) | ||
267 | const html5 = { | ||
268 | preloadTextTracks: false | ||
269 | } | ||
270 | |||
271 | const plugins: VideoJSPluginOptions = { | ||
272 | peertube: { | ||
273 | mode, | ||
274 | autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent | ||
275 | videoViewUrl: commonOptions.videoViewUrl, | ||
276 | videoDuration: commonOptions.videoDuration, | ||
277 | userWatching: commonOptions.userWatching, | ||
278 | subtitle: commonOptions.subtitle, | ||
279 | videoCaptions: commonOptions.videoCaptions, | ||
280 | stopTime: commonOptions.stopTime, | ||
281 | isLive: commonOptions.isLive, | ||
282 | videoUUID: commonOptions.videoUUID | ||
283 | } | 141 | } |
284 | } | ||
285 | |||
286 | if (commonOptions.playlist) { | ||
287 | plugins.playlist = commonOptions.playlist | ||
288 | } | ||
289 | |||
290 | if (isHLS) { | ||
291 | const { hlsjs } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule) | ||
292 | |||
293 | Object.assign(html5, hlsjs.html5) | ||
294 | } | ||
295 | |||
296 | if (mode === 'webtorrent') { | ||
297 | PeertubePlayerManager.addWebTorrentOptions(plugins, options) | ||
298 | |||
299 | // WebTorrent plugin handles autoplay, because we do some hackish stuff in there | ||
300 | autoplay = false | ||
301 | } | ||
302 | |||
303 | const videojsOptions = { | ||
304 | html5, | ||
305 | |||
306 | // We don't use text track settings for now | ||
307 | textTrackSettings: false as any, // FIXME: typings | ||
308 | controls: commonOptions.controls !== undefined ? commonOptions.controls : true, | ||
309 | loop: commonOptions.loop !== undefined ? commonOptions.loop : false, | ||
310 | |||
311 | muted: commonOptions.muted !== undefined | ||
312 | ? commonOptions.muted | ||
313 | : undefined, // Undefined so the player knows it has to check the local storage | ||
314 | |||
315 | autoplay: this.getAutoPlayValue(autoplay), | ||
316 | |||
317 | poster: commonOptions.poster, | ||
318 | inactivityTimeout: commonOptions.inactivityTimeout, | ||
319 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], | ||
320 | |||
321 | plugins, | ||
322 | |||
323 | controlBar: { | ||
324 | children: this.getControlBarChildren(mode, { | ||
325 | videoShortUUID: commonOptions.videoShortUUID, | ||
326 | p2pEnabled: commonOptions.p2pEnabled, | ||
327 | |||
328 | captions: commonOptions.captions, | ||
329 | peertubeLink: commonOptions.peertubeLink, | ||
330 | theaterButton: commonOptions.theaterButton, | ||
331 | |||
332 | nextVideo: commonOptions.nextVideo, | ||
333 | hasNextVideo: commonOptions.hasNextVideo, | ||
334 | 142 | ||
335 | previousVideo: commonOptions.previousVideo, | 143 | if (this.videojsDecodeErrors === 20) { |
336 | hasPreviousVideo: commonOptions.hasPreviousVideo | 144 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) |
337 | }) as any // FIXME: typings | 145 | return |
338 | } | 146 | } |
339 | } | ||
340 | |||
341 | if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { | ||
342 | Object.assign(videojsOptions, { language: commonOptions.language }) | ||
343 | } | ||
344 | |||
345 | return this.pluginsManager.runHook('filter:internal.player.videojs.options.result', videojsOptions) | ||
346 | } | ||
347 | |||
348 | private static addP2PMediaLoaderOptions ( | ||
349 | plugins: VideoJSPluginOptions, | ||
350 | options: PeertubePlayerManagerOptions, | ||
351 | p2pMediaLoaderModule: any | ||
352 | ) { | ||
353 | const p2pMediaLoaderOptions = options.p2pMediaLoader | ||
354 | const commonOptions = options.common | ||
355 | |||
356 | const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce | ||
357 | .filter(t => t.startsWith('ws')) | ||
358 | |||
359 | const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls) | ||
360 | |||
361 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | ||
362 | redundancyUrlManager, | ||
363 | type: 'application/x-mpegURL', | ||
364 | startTime: commonOptions.startTime, | ||
365 | src: p2pMediaLoaderOptions.playlistUrl | ||
366 | } | ||
367 | 147 | ||
368 | let consumeOnly = false | 148 | console.log('Fast forwarding HLS to recover from an error.') |
369 | if ((navigator as any)?.connection?.type === 'cellular') { | ||
370 | console.log('We are on a cellular connection: disabling seeding.') | ||
371 | consumeOnly = true | ||
372 | } | ||
373 | |||
374 | const p2pMediaLoaderConfig: HlsJsEngineSettings = { | ||
375 | loader: { | ||
376 | trackerAnnounce, | ||
377 | segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url, options.common.isLive), | ||
378 | rtcConfig: getRtcConfig(), | ||
379 | requiredSegmentsPriority: 1, | ||
380 | simultaneousHttpDownloads: 1, | ||
381 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1), | ||
382 | useP2P: commonOptions.p2pEnabled, | ||
383 | consumeOnly | ||
384 | }, | ||
385 | segments: { | ||
386 | swarmId: p2pMediaLoaderOptions.playlistUrl | ||
387 | } | ||
388 | } | ||
389 | |||
390 | const hlsjs = { | ||
391 | levelLabelHandler: (level: { height: number, width: number }) => { | ||
392 | const resolution = Math.min(level.height || 0, level.width || 0) | ||
393 | 149 | ||
394 | const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution) | 150 | this.videojsDecodeErrors++ |
395 | // We don't have files for live videos | ||
396 | if (!file) return level.height | ||
397 | 151 | ||
398 | let label = file.resolution.label | 152 | options.common.startTime = currentPlayer.currentTime() + 2 |
399 | if (file.fps >= 50) label += file.fps | 153 | options.common.autoplay = true |
154 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) | ||
400 | 155 | ||
401 | return label | 156 | const newPlayer = await this.buildPlayer('p2p-media-loader', options) |
402 | }, | 157 | this.onPlayerChange(newPlayer) |
403 | html5: { | 158 | } else { |
404 | hlsjsConfig: this.getHLSOptions(p2pMediaLoaderModule, p2pMediaLoaderConfig) | 159 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) |
405 | } | ||
406 | } | 160 | } |
407 | |||
408 | const toAssign = { p2pMediaLoader, hlsjs } | ||
409 | Object.assign(plugins, toAssign) | ||
410 | |||
411 | return toAssign | ||
412 | } | 161 | } |
413 | 162 | ||
414 | private static getHLSOptions (p2pMediaLoaderModule: any, p2pMediaLoaderConfig: HlsJsEngineSettings) { | 163 | private static async maybeFallbackToWebTorrent ( |
415 | const base = { | 164 | currentMode: PlayerMode, |
416 | capLevelToPlayerSize: true, | 165 | currentPlayer: videojs.Player, |
417 | autoStartLoad: false, | 166 | options: PeertubePlayerManagerOptions |
418 | liveSyncDurationCount: 5, | 167 | ) { |
419 | 168 | if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') { | |
420 | loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() | 169 | currentPlayer.peertube().displayFatalError() |
421 | } | 170 | return |
422 | |||
423 | const averageBandwidth = getAverageBandwidthInStore() | ||
424 | if (!averageBandwidth) return base | ||
425 | |||
426 | return { | ||
427 | ...base, | ||
428 | |||
429 | abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s | ||
430 | startLevel: -1, | ||
431 | testBandwidth: false, | ||
432 | debug: false | ||
433 | } | ||
434 | } | ||
435 | |||
436 | private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) { | ||
437 | const commonOptions = options.common | ||
438 | const webtorrentOptions = options.webtorrent | ||
439 | const p2pMediaLoaderOptions = options.p2pMediaLoader | ||
440 | |||
441 | const autoplay = this.getAutoPlayValue(commonOptions.autoplay) === 'play' | ||
442 | |||
443 | const webtorrent = { | ||
444 | autoplay, | ||
445 | playerRefusedP2P: commonOptions.p2pEnabled === false, | ||
446 | videoDuration: commonOptions.videoDuration, | ||
447 | playerElement: commonOptions.playerElement, | ||
448 | videoFiles: webtorrentOptions.videoFiles.length !== 0 | ||
449 | ? webtorrentOptions.videoFiles | ||
450 | // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode | ||
451 | : p2pMediaLoaderOptions?.videoFiles || [], | ||
452 | startTime: commonOptions.startTime | ||
453 | } | ||
454 | |||
455 | Object.assign(plugins, { webtorrent }) | ||
456 | } | ||
457 | |||
458 | private static getControlBarChildren (mode: PlayerMode, options: { | ||
459 | p2pEnabled: boolean | ||
460 | videoShortUUID: string | ||
461 | |||
462 | peertubeLink: boolean | ||
463 | theaterButton: boolean | ||
464 | captions: boolean | ||
465 | |||
466 | nextVideo?: () => void | ||
467 | hasNextVideo?: () => boolean | ||
468 | |||
469 | previousVideo?: () => void | ||
470 | hasPreviousVideo?: () => boolean | ||
471 | }) { | ||
472 | const settingEntries = [] | ||
473 | const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar' | ||
474 | |||
475 | // Keep an order | ||
476 | settingEntries.push('playbackRateMenuButton') | ||
477 | if (options.captions === true) settingEntries.push('captionsButton') | ||
478 | settingEntries.push('resolutionMenuButton') | ||
479 | |||
480 | const children = {} | ||
481 | |||
482 | if (options.previousVideo) { | ||
483 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
484 | type: 'previous', | ||
485 | handler: options.previousVideo, | ||
486 | isDisabled: () => { | ||
487 | if (!options.hasPreviousVideo) return false | ||
488 | |||
489 | return !options.hasPreviousVideo() | ||
490 | } | ||
491 | } | ||
492 | |||
493 | Object.assign(children, { | ||
494 | previousVideoButton: buttonOptions | ||
495 | }) | ||
496 | } | ||
497 | |||
498 | Object.assign(children, { playToggle: {} }) | ||
499 | |||
500 | if (options.nextVideo) { | ||
501 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
502 | type: 'next', | ||
503 | handler: options.nextVideo, | ||
504 | isDisabled: () => { | ||
505 | if (!options.hasNextVideo) return false | ||
506 | |||
507 | return !options.hasNextVideo() | ||
508 | } | ||
509 | } | ||
510 | |||
511 | Object.assign(children, { | ||
512 | nextVideoButton: buttonOptions | ||
513 | }) | ||
514 | } | 171 | } |
515 | 172 | ||
516 | Object.assign(children, { | 173 | console.log('Fallback to webtorrent.') |
517 | currentTimeDisplay: {}, | ||
518 | timeDivider: {}, | ||
519 | durationDisplay: {}, | ||
520 | liveDisplay: {}, | ||
521 | |||
522 | flexibleWidthSpacer: {}, | ||
523 | progressControl: { | ||
524 | children: { | ||
525 | seekBar: { | ||
526 | children: { | ||
527 | [loadProgressBar]: {}, | ||
528 | mouseTimeDisplay: {}, | ||
529 | playProgressBar: {} | ||
530 | } | ||
531 | } | ||
532 | } | ||
533 | }, | ||
534 | |||
535 | p2PInfoButton: { | ||
536 | p2pEnabled: options.p2pEnabled | ||
537 | }, | ||
538 | |||
539 | muteToggle: {}, | ||
540 | volumeControl: {}, | ||
541 | |||
542 | settingsButton: { | ||
543 | setup: { | ||
544 | maxHeightOffset: 40 | ||
545 | }, | ||
546 | entries: settingEntries | ||
547 | } | ||
548 | }) | ||
549 | |||
550 | if (options.peertubeLink === true) { | ||
551 | Object.assign(children, { | ||
552 | peerTubeLinkButton: { shortUUID: options.videoShortUUID } as PeerTubeLinkButtonOptions | ||
553 | }) | ||
554 | } | ||
555 | 174 | ||
556 | if (options.theaterButton === true) { | 175 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) |
557 | Object.assign(children, { | ||
558 | theaterButton: {} | ||
559 | }) | ||
560 | } | ||
561 | 176 | ||
562 | Object.assign(children, { | 177 | await import('./webtorrent/webtorrent-plugin') |
563 | fullscreenToggle: {} | ||
564 | }) | ||
565 | 178 | ||
566 | return children | 179 | const newPlayer = await this.buildPlayer('webtorrent', options) |
180 | this.onPlayerChange(newPlayer) | ||
567 | } | 181 | } |
568 | 182 | ||
569 | private static addContextMenu (options: { | 183 | private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) { |
570 | mode: PlayerMode | 184 | const newVideoElement = document.createElement('video') |
571 | player: videojs.Player | 185 | newVideoElement.className = this.playerElementClassName |
572 | videoShortUUID: string | ||
573 | videoEmbedUrl: string | ||
574 | videoEmbedTitle: string | ||
575 | }) { | ||
576 | const { mode, player, videoEmbedTitle, videoEmbedUrl, videoShortUUID } = options | ||
577 | |||
578 | const content = () => { | ||
579 | const isLoopEnabled = player.options_['loop'] | ||
580 | const items = [ | ||
581 | { | ||
582 | icon: 'repeat', | ||
583 | label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''), | ||
584 | listener: function () { | ||
585 | player.options_['loop'] = !isLoopEnabled | ||
586 | } | ||
587 | }, | ||
588 | { | ||
589 | label: player.localize('Copy the video URL'), | ||
590 | listener: function () { | ||
591 | copyToClipboard(buildVideoLink({ shortUUID: videoShortUUID })) | ||
592 | } | ||
593 | }, | ||
594 | { | ||
595 | label: player.localize('Copy the video URL at the current time'), | ||
596 | listener: function (this: videojs.Player) { | ||
597 | const url = buildVideoLink({ shortUUID: videoShortUUID }) | ||
598 | 186 | ||
599 | copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) | 187 | // VideoJS wraps our video element inside a div |
600 | } | 188 | let currentParentPlayerElement = commonOptions.playerElement.parentNode |
601 | }, | 189 | // Fix on IOS, don't ask me why |
602 | { | 190 | if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode |
603 | icon: 'code', | ||
604 | label: player.localize('Copy embed code'), | ||
605 | listener: () => { | ||
606 | copyToClipboard(buildVideoOrPlaylistEmbed(videoEmbedUrl, videoEmbedTitle)) | ||
607 | } | ||
608 | } | ||
609 | ] | ||
610 | 191 | ||
611 | if (mode === 'webtorrent') { | 192 | currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) |
612 | items.push({ | ||
613 | label: player.localize('Copy magnet URI'), | ||
614 | listener: function (this: videojs.Player) { | ||
615 | copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) | ||
616 | } | ||
617 | }) | ||
618 | } | ||
619 | 193 | ||
620 | items.push({ | 194 | commonOptions.playerElement = newVideoElement |
621 | icon: 'info', | 195 | commonOptions.onPlayerElementChange(newVideoElement) |
622 | label: player.localize('Stats for nerds'), | ||
623 | listener: () => { | ||
624 | player.stats().show() | ||
625 | } | ||
626 | }) | ||
627 | 196 | ||
628 | return items.map(i => ({ | 197 | player.dispose() |
629 | ...i, | ||
630 | label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label | ||
631 | })) | ||
632 | } | ||
633 | 198 | ||
634 | // adding the menu | 199 | return newVideoElement |
635 | player.contextmenuUI({ content }) | ||
636 | } | 200 | } |
637 | 201 | ||
638 | private static getAutoPlayValue (autoplay: any) { | 202 | private static addContextMenu (optionsBuilder: PeertubePlayerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) { |
639 | if (autoplay !== true) return autoplay | 203 | const options = optionsBuilder.getContextMenuOptions(player, commonOptions) |
640 | |||
641 | // On first play, disable autoplay to avoid issues | ||
642 | // But if the player already played videos, we can safely autoplay next ones | ||
643 | if (isIOS() || isSafari()) { | ||
644 | return PeertubePlayerManager.alreadyPlayed ? 'play' : false | ||
645 | } | ||
646 | 204 | ||
647 | return 'play' | 205 | player.contextmenuUI(options) |
648 | } | 206 | } |
649 | } | 207 | } |
650 | 208 | ||
diff --git a/client/src/assets/player/peertube-player-options-builder.ts b/client/src/assets/player/peertube-player-options-builder.ts new file mode 100644 index 000000000..901f6cd3b --- /dev/null +++ b/client/src/assets/player/peertube-player-options-builder.ts | |||
@@ -0,0 +1,489 @@ | |||
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 | } | ||
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index fd612dd4f..b5c42d1c5 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts | |||
@@ -122,6 +122,14 @@ class PeerTubePlugin extends Plugin { | |||
122 | this.alterInactivity() | 122 | this.alterInactivity() |
123 | } | 123 | } |
124 | 124 | ||
125 | displayFatalError () { | ||
126 | this.player.addClass('vjs-error-display-enabled') | ||
127 | } | ||
128 | |||
129 | hideFatalError () { | ||
130 | this.player.removeClass('vjs-error-display-enabled') | ||
131 | } | ||
132 | |||
125 | private initializePlayer () { | 133 | private initializePlayer () { |
126 | if (isMobile()) this.player.addClass('vjs-is-mobile') | 134 | if (isMobile()) this.player.addClass('vjs-is-mobile') |
127 | 135 | ||
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index b20ef7a3b..246f0d390 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts | |||
@@ -4,7 +4,7 @@ import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' | |||
4 | import { Html5Hlsjs } from './p2p-media-loader/hls-plugin' | 4 | import { Html5Hlsjs } from './p2p-media-loader/hls-plugin' |
5 | import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' | 5 | import { P2pMediaLoaderPlugin } from './p2p-media-loader/p2p-media-loader-plugin' |
6 | import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' | 6 | import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' |
7 | import { PlayerMode } from './peertube-player-manager' | 7 | import { PlayerMode } from './peertube-player-options-builder' |
8 | import { PeerTubePlugin } from './peertube-plugin' | 8 | import { PeerTubePlugin } from './peertube-plugin' |
9 | import { PeerTubeResolutionsPlugin } from './peertube-resolutions-plugin' | 9 | import { PeerTubeResolutionsPlugin } from './peertube-resolutions-plugin' |
10 | import { PlaylistPlugin } from './playlist/playlist-plugin' | 10 | import { PlaylistPlugin } from './playlist/playlist-plugin' |
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index 16dc7a244..4bcb2766a 100644 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts | |||
@@ -145,7 +145,7 @@ class WebTorrentPlugin extends Plugin { | |||
145 | } | 145 | } |
146 | 146 | ||
147 | // Do not display error to user because we will have multiple fallback | 147 | // Do not display error to user because we will have multiple fallback |
148 | this.disableErrorDisplay(); | 148 | this.player.peertube().hideFatalError(); |
149 | 149 | ||
150 | // Hack to "simulate" src link in video.js >= 6 | 150 | // Hack to "simulate" src link in video.js >= 6 |
151 | // Without this, we can't play the video after pausing it | 151 | // Without this, we can't play the video after pausing it |
@@ -524,7 +524,7 @@ class WebTorrentPlugin extends Plugin { | |||
524 | this.torrent = null | 524 | this.torrent = null |
525 | 525 | ||
526 | // Enable error display now this is our last fallback | 526 | // Enable error display now this is our last fallback |
527 | this.player.one('error', () => this.enableErrorDisplay()) | 527 | this.player.one('error', () => this.player.peertube().displayFatalError()) |
528 | 528 | ||
529 | const httpUrl = this.currentVideoFile.fileUrl | 529 | const httpUrl = this.currentVideoFile.fileUrl |
530 | this.player.src = this.savePlayerSrcFunction | 530 | this.player.src = this.savePlayerSrcFunction |
@@ -549,14 +549,6 @@ class WebTorrentPlugin extends Plugin { | |||
549 | return this.player.trigger('customError', { err }) | 549 | return this.player.trigger('customError', { err }) |
550 | } | 550 | } |
551 | 551 | ||
552 | private enableErrorDisplay () { | ||
553 | this.player.addClass('vjs-error-display-enabled') | ||
554 | } | ||
555 | |||
556 | private disableErrorDisplay () { | ||
557 | this.player.removeClass('vjs-error-display-enabled') | ||
558 | } | ||
559 | |||
560 | private pickAverageVideoFile () { | 552 | private pickAverageVideoFile () { |
561 | if (this.videoFiles.length === 1) return this.videoFiles[0] | 553 | if (this.videoFiles.length === 1) return this.videoFiles[0] |
562 | 554 | ||