1 import 'videojs-hotkeys/videojs.hotkeys'
3 import 'videojs-contextmenu-pt'
4 import 'videojs-contrib-quality-levels'
5 import './upnext/end-card'
6 import './upnext/upnext-plugin'
7 import './stats/stats-card'
8 import './stats/stats-plugin'
9 import './bezels/bezels-plugin'
10 import './peertube-plugin'
11 import './videojs-components/next-previous-video-button'
12 import './videojs-components/p2p-info-button'
13 import './videojs-components/peertube-link-button'
14 import './videojs-components/peertube-load-progress-bar'
15 import './videojs-components/resolution-menu-button'
16 import './videojs-components/resolution-menu-item'
17 import './videojs-components/settings-dialog'
18 import './videojs-components/settings-menu-button'
19 import './videojs-components/settings-menu-item'
20 import './videojs-components/settings-panel'
21 import './videojs-components/settings-panel-child'
22 import './videojs-components/theater-button'
23 import './playlist/playlist-plugin'
24 import videojs from 'video.js'
25 import { PluginsManager } from '@root-helpers/plugins-manager'
26 import { isDefaultLocale } from '@shared/core-utils/i18n'
27 import { VideoFile } from '@shared/models'
28 import { copyToClipboard } from '../../root-helpers/utils'
29 import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
30 import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
31 import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
32 import { getStoredP2PEnabled } from './peertube-player-local-storage'
34 NextPreviousVideoButtonOptions,
35 P2PMediaLoaderPluginOptions,
36 PlaylistPluginOptions,
40 } from './peertube-videojs-typings'
41 import { TranslationsManager } from './translations-manager'
42 import { buildVideoLink, buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
44 // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
45 (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
47 const CaptionsButton = videojs.getComponent('CaptionsButton') as any
48 // Change Captions to Subtitles/CC
49 CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
50 // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
51 CaptionsButton.prototype.label_ = ' '
53 export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
55 export type WebtorrentOptions = {
56 videoFiles: VideoFile[]
59 export type P2PMediaLoaderOptions = {
61 segmentsSha256Url: string
62 trackerAnnounce: string[]
63 redundancyBaseUrls: string[]
64 videoFiles: VideoFile[]
67 export interface CustomizationOptions {
68 startTime: number | string
69 stopTime: number | string
80 export interface CommonOptions extends CustomizationOptions {
81 playerElement: HTMLVideoElement
82 onPlayerElementChange: (element: HTMLVideoElement) => void
86 nextVideo?: () => void
87 hasNextVideo?: () => boolean
89 previousVideo?: () => void
90 hasPreviousVideo?: () => boolean
92 playlist?: PlaylistPluginOptions
95 enableHotkeys: boolean
96 inactivityTimeout: number
99 theaterButton: boolean
110 videoCaptions: VideoJSCaption[]
114 userWatching?: UserWatching
119 export type PeertubePlayerManagerOptions = {
120 common: CommonOptions
121 webtorrent: WebtorrentOptions
122 p2pMediaLoader?: P2PMediaLoaderOptions
124 pluginsManager: PluginsManager
127 export class PeertubePlayerManager {
128 private static playerElementClassName: string
129 private static onPlayerChange: (player: videojs.Player) => void
130 private static alreadyPlayed = false
131 private static pluginsManager: PluginsManager
133 static initState () {
134 PeertubePlayerManager.alreadyPlayed = false
137 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) {
138 this.pluginsManager = options.pluginsManager
140 let p2pMediaLoader: any
142 this.onPlayerChange = onPlayerChange
143 this.playerElementClassName = options.common.playerElement.className
145 if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
146 if (mode === 'p2p-media-loader') {
147 [ p2pMediaLoader ] = await Promise.all([
148 import('p2p-media-loader-hlsjs'),
149 import('./p2p-media-loader/p2p-media-loader-plugin')
153 const videojsOptions = await this.getVideojsOptions(mode, options, p2pMediaLoader)
155 await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
158 return new Promise(res => {
159 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
162 let alreadyFallback = false
164 player.tech(true).one('error', () => {
165 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
166 alreadyFallback = true
169 player.one('error', () => {
170 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
171 alreadyFallback = true
174 player.one('play', () => {
175 PeertubePlayerManager.alreadyPlayed = true
178 self.addContextMenu(mode, player, options.common.embedUrl, options.common.embedTitle)
182 videoUUID: options.common.videoUUID,
183 videoIsLive: options.common.isLive,
192 private static async maybeFallbackToWebTorrent (currentMode: PlayerMode, player: any, options: PeertubePlayerManagerOptions) {
193 if (currentMode === 'webtorrent') return
195 console.log('Fallback to webtorrent.')
197 const newVideoElement = document.createElement('video')
198 newVideoElement.className = this.playerElementClassName
200 // VideoJS wraps our video element inside a div
201 let currentParentPlayerElement = options.common.playerElement.parentNode
202 // Fix on IOS, don't ask me why
203 if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(options.common.playerElement.id).parentNode
205 currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
207 options.common.playerElement = newVideoElement
208 options.common.onPlayerElementChange(newVideoElement)
212 await import('./webtorrent/webtorrent-plugin')
214 const mode = 'webtorrent'
215 const videojsOptions = await this.getVideojsOptions(mode, options)
218 videojs(newVideoElement, videojsOptions, function (this: videojs.Player) {
221 self.addContextMenu(mode, player, options.common.embedUrl, options.common.embedTitle)
223 PeertubePlayerManager.onPlayerChange(player)
227 private static async getVideojsOptions (
229 options: PeertubePlayerManagerOptions,
230 p2pMediaLoaderModule?: any
231 ): Promise<videojs.PlayerOptions> {
232 const commonOptions = options.common
233 const isHLS = mode === 'p2p-media-loader'
235 let autoplay = this.getAutoPlayValue(commonOptions.autoplay)
237 preloadTextTracks: false
240 const plugins: VideoJSPluginOptions = {
243 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
244 videoViewUrl: commonOptions.videoViewUrl,
245 videoDuration: commonOptions.videoDuration,
246 userWatching: commonOptions.userWatching,
247 subtitle: commonOptions.subtitle,
248 videoCaptions: commonOptions.videoCaptions,
249 stopTime: commonOptions.stopTime,
250 isLive: commonOptions.isLive,
251 videoUUID: commonOptions.videoUUID
255 if (commonOptions.playlist) {
256 plugins.playlist = commonOptions.playlist
259 if (commonOptions.enableHotkeys === true) {
260 PeertubePlayerManager.addHotkeysOptions(plugins)
264 const { hlsjs } = PeertubePlayerManager.addP2PMediaLoaderOptions(plugins, options, p2pMediaLoaderModule)
266 Object.assign(html5, hlsjs.html5)
269 if (mode === 'webtorrent') {
270 PeertubePlayerManager.addWebTorrentOptions(plugins, options)
272 // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
276 const videojsOptions = {
279 // We don't use text track settings for now
280 textTrackSettings: false as any, // FIXME: typings
281 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
282 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
284 muted: commonOptions.muted !== undefined
285 ? commonOptions.muted
286 : undefined, // Undefined so the player knows it has to check the local storage
288 autoplay: this.getAutoPlayValue(autoplay),
290 poster: commonOptions.poster,
291 inactivityTimeout: commonOptions.inactivityTimeout,
292 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
297 children: this.getControlBarChildren(mode, {
298 captions: commonOptions.captions,
299 peertubeLink: commonOptions.peertubeLink,
300 theaterButton: commonOptions.theaterButton,
302 nextVideo: commonOptions.nextVideo,
303 hasNextVideo: commonOptions.hasNextVideo,
305 previousVideo: commonOptions.previousVideo,
306 hasPreviousVideo: commonOptions.hasPreviousVideo
307 }) as any // FIXME: typings
311 if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
312 Object.assign(videojsOptions, { language: commonOptions.language })
315 return this.pluginsManager.runHook('filter:internal.player.videojs.options.result', videojsOptions)
318 private static addP2PMediaLoaderOptions (
319 plugins: VideoJSPluginOptions,
320 options: PeertubePlayerManagerOptions,
321 p2pMediaLoaderModule: any
323 const p2pMediaLoaderOptions = options.p2pMediaLoader
324 const commonOptions = options.common
326 const trackerAnnounce = p2pMediaLoaderOptions.trackerAnnounce
327 .filter(t => t.startsWith('ws'))
329 const redundancyUrlManager = new RedundancyUrlManager(options.p2pMediaLoader.redundancyBaseUrls)
331 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
332 redundancyUrlManager,
333 type: 'application/x-mpegURL',
334 startTime: commonOptions.startTime,
335 src: p2pMediaLoaderOptions.playlistUrl
338 let consumeOnly = false
340 if (navigator && (navigator as any).connection && (navigator as any).connection.type === 'cellular') {
341 console.log('We are on a cellular connection: disabling seeding.')
345 const p2pMediaLoaderConfig = {
348 segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url, options.common.isLive),
349 rtcConfig: getRtcConfig(),
350 requiredSegmentsPriority: 1,
351 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
352 useP2P: getStoredP2PEnabled(),
356 swarmId: p2pMediaLoaderOptions.playlistUrl
360 levelLabelHandler: (level: { height: number, width: number }) => {
361 const resolution = Math.min(level.height || 0, level.width || 0)
363 const file = p2pMediaLoaderOptions.videoFiles.find(f => f.resolution.id === resolution)
364 // We don't have files for live videos
365 if (!file) return level.height
367 let label = file.resolution.label
368 if (file.fps >= 50) label += file.fps
374 capLevelToPlayerSize: true,
375 autoStartLoad: false,
376 liveSyncDurationCount: 5,
377 loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
382 const toAssign = { p2pMediaLoader, hlsjs }
383 Object.assign(plugins, toAssign)
388 private static addWebTorrentOptions (plugins: VideoJSPluginOptions, options: PeertubePlayerManagerOptions) {
389 const commonOptions = options.common
390 const webtorrentOptions = options.webtorrent
391 const p2pMediaLoaderOptions = options.p2pMediaLoader
393 const autoplay = this.getAutoPlayValue(commonOptions.autoplay) === 'play'
399 videoDuration: commonOptions.videoDuration,
400 playerElement: commonOptions.playerElement,
401 videoFiles: webtorrentOptions.videoFiles.length !== 0
402 ? webtorrentOptions.videoFiles
403 // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
404 : p2pMediaLoaderOptions?.videoFiles || [],
405 startTime: commonOptions.startTime
408 Object.assign(plugins, { webtorrent })
411 private static getControlBarChildren (mode: PlayerMode, options: {
412 peertubeLink: boolean
413 theaterButton: boolean
417 hasNextVideo?: () => boolean
419 previousVideo?: Function
420 hasPreviousVideo?: () => boolean
422 const settingEntries = []
423 const loadProgressBar = mode === 'webtorrent' ? 'peerTubeLoadProgressBar' : 'loadProgressBar'
426 settingEntries.push('playbackRateMenuButton')
427 if (options.captions === true) settingEntries.push('captionsButton')
428 settingEntries.push('resolutionMenuButton')
432 if (options.previousVideo) {
433 const buttonOptions: NextPreviousVideoButtonOptions = {
435 handler: options.previousVideo,
437 if (!options.hasPreviousVideo) return false
439 return !options.hasPreviousVideo()
443 Object.assign(children, {
444 'previousVideoButton': buttonOptions
448 Object.assign(children, { playToggle: {} })
450 if (options.nextVideo) {
451 const buttonOptions: NextPreviousVideoButtonOptions = {
453 handler: options.nextVideo,
455 if (!options.hasNextVideo) return false
457 return !options.hasNextVideo()
461 Object.assign(children, {
462 'nextVideoButton': buttonOptions
466 Object.assign(children, {
467 'currentTimeDisplay': {},
469 'durationDisplay': {},
472 'flexibleWidthSpacer': {},
477 [loadProgressBar]: {},
478 'mouseTimeDisplay': {},
479 'playProgressBar': {}
494 entries: settingEntries
498 if (options.peertubeLink === true) {
499 Object.assign(children, {
500 'peerTubeLinkButton': {}
504 if (options.theaterButton === true) {
505 Object.assign(children, {
510 Object.assign(children, {
511 'fullscreenToggle': {}
517 private static addContextMenu (mode: PlayerMode, player: videojs.Player, videoEmbedUrl: string, videoEmbedTitle: string) {
518 const content = () => {
519 const isLoopEnabled = player.options_['loop']
523 label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
524 listener: function () {
525 player.options_['loop'] = !isLoopEnabled
529 label: player.localize('Copy the video URL'),
530 listener: function () {
531 copyToClipboard(buildVideoLink())
535 label: player.localize('Copy the video URL at the current time'),
536 listener: function (this: videojs.Player) {
537 copyToClipboard(buildVideoLink({ startTime: this.currentTime() }))
542 label: player.localize('Copy embed code'),
544 copyToClipboard(buildVideoOrPlaylistEmbed(videoEmbedUrl, videoEmbedTitle))
549 if (mode === 'webtorrent') {
551 label: player.localize('Copy magnet URI'),
552 listener: function (this: videojs.Player) {
553 copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
560 label: player.localize('Stats for nerds'),
562 player.stats().show()
566 return items.map(i => ({
568 label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
573 player.contextmenuUI({ content })
576 private static addHotkeysOptions (plugins: VideoJSPluginOptions) {
577 const isNaked = (event: KeyboardEvent, key: string) =>
578 (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key)
580 Object.assign(plugins, {
582 skipInitialFocus: true,
583 enableInactiveFocus: false,
584 captureDocumentHotkeys: true,
585 documentHotkeysFocusElementFilter: (e: HTMLElement) => {
586 const tagName = e.tagName.toLowerCase()
587 return e.id === 'content' || tagName === 'body' || tagName === 'video'
590 enableVolumeScroll: false,
591 enableModifiersForNumbers: false,
593 rewindKey: function (event: KeyboardEvent) {
594 return isNaked(event, 'ArrowLeft')
597 forwardKey: function (event: KeyboardEvent) {
598 return isNaked(event, 'ArrowRight')
601 fullscreenKey: function (event: KeyboardEvent) {
602 // fullscreen with the f key or Ctrl+Enter
603 return isNaked(event, 'f') || (!event.altKey && event.ctrlKey && event.key === 'Enter')
607 increasePlaybackRateKey: {
608 key: function (event: KeyboardEvent) {
609 return isNaked(event, '>')
611 handler: function (player: videojs.Player) {
612 const newValue = Math.min(player.playbackRate() + 0.1, 5)
613 player.playbackRate(parseFloat(newValue.toFixed(2)))
616 decreasePlaybackRateKey: {
617 key: function (event: KeyboardEvent) {
618 return isNaked(event, '<')
620 handler: function (player: videojs.Player) {
621 const newValue = Math.max(player.playbackRate() - 0.1, 0.10)
622 player.playbackRate(parseFloat(newValue.toFixed(2)))
626 key: function (event: KeyboardEvent) {
627 return isNaked(event, '.')
629 handler: function (player: videojs.Player) {
631 // Calculate movement distance (assuming 30 fps)
633 player.currentTime(player.currentTime() + dist)
641 private static getAutoPlayValue (autoplay: any) {
642 if (autoplay !== true) return autoplay
644 // On first play, disable autoplay to avoid issues
645 // But if the player already played videos, we can safely autoplay next ones
646 if (isIOS() || isSafari()) {
647 return PeertubePlayerManager.alreadyPlayed ? 'play' : false
654 // ############################################################################