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