aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/assets/player/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/assets/player/shared')
-rw-r--r--client/src/assets/player/shared/bezels/bezels-plugin.ts21
-rw-r--r--client/src/assets/player/shared/bezels/index.ts2
-rw-r--r--client/src/assets/player/shared/bezels/pause-bezel.ts76
-rw-r--r--client/src/assets/player/shared/common/index.ts1
-rw-r--r--client/src/assets/player/shared/common/utils.ts66
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts5
-rw-r--r--client/src/assets/player/shared/control-bar/next-previous-video-button.ts50
-rw-r--r--client/src/assets/player/shared/control-bar/p2p-info-button.ts124
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-link-button.ts45
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts33
-rw-r--r--client/src/assets/player/shared/control-bar/theater-button.ts53
-rw-r--r--client/src/assets/player/shared/dock/index.ts2
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-component.ts65
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-plugin.ts25
-rw-r--r--client/src/assets/player/shared/hotkeys/index.ts1
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts196
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts137
-rw-r--r--client/src/assets/player/shared/manager-options/hls-options-builder.ts192
-rw-r--r--client/src/assets/player/shared/manager-options/index.ts1
-rw-r--r--client/src/assets/player/shared/manager-options/manager-options-builder.ts169
-rw-r--r--client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts36
-rw-r--r--client/src/assets/player/shared/mobile/index.ts2
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts94
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts155
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts419
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/index.ts5
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts183
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts42
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts17
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/segment-validator.ts106
-rw-r--r--client/src/assets/player/shared/peertube/index.ts1
-rw-r--r--client/src/assets/player/shared/peertube/peertube-plugin.ts302
-rw-r--r--client/src/assets/player/shared/playlist/index.ts4
-rw-r--r--client/src/assets/player/shared/playlist/playlist-button.ts61
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu-item.ts136
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu.ts137
-rw-r--r--client/src/assets/player/shared/playlist/playlist-plugin.ts35
-rw-r--r--client/src/assets/player/shared/resolutions/index.ts1
-rw-r--r--client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts88
-rw-r--r--client/src/assets/player/shared/settings/index.ts7
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-button.ts86
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-item.ts77
-rw-r--r--client/src/assets/player/shared/settings/settings-dialog.ts35
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-button.ts277
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-item.ts377
-rw-r--r--client/src/assets/player/shared/settings/settings-panel-child.ts18
-rw-r--r--client/src/assets/player/shared/settings/settings-panel.ts18
-rw-r--r--client/src/assets/player/shared/stats/index.ts2
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts271
-rw-r--r--client/src/assets/player/shared/stats/stats-plugin.ts31
-rw-r--r--client/src/assets/player/shared/upnext/end-card.ts157
-rw-r--r--client/src/assets/player/shared/upnext/index.ts2
-rw-r--r--client/src/assets/player/shared/upnext/upnext-plugin.ts31
-rw-r--r--client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts233
-rw-r--r--client/src/assets/player/shared/webtorrent/video-renderer.ts133
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts641
56 files changed, 5484 insertions, 0 deletions
diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts
new file mode 100644
index 000000000..ca88bc1f9
--- /dev/null
+++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts
@@ -0,0 +1,21 @@
1import videojs from 'video.js'
2import './pause-bezel'
3
4const Plugin = videojs.getPlugin('plugin')
5
6class BezelsPlugin extends Plugin {
7
8 constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
9 super(player)
10
11 this.player.ready(() => {
12 player.addClass('vjs-bezels')
13 })
14
15 player.addChild('PauseBezel', options)
16 }
17}
18
19videojs.registerPlugin('bezels', BezelsPlugin)
20
21export { BezelsPlugin }
diff --git a/client/src/assets/player/shared/bezels/index.ts b/client/src/assets/player/shared/bezels/index.ts
new file mode 100644
index 000000000..da861b07a
--- /dev/null
+++ b/client/src/assets/player/shared/bezels/index.ts
@@ -0,0 +1,2 @@
1export * from './bezels-plugin'
2export * from './pause-bezel'
diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts
new file mode 100644
index 000000000..e35c39a5f
--- /dev/null
+++ b/client/src/assets/player/shared/bezels/pause-bezel.ts
@@ -0,0 +1,76 @@
1import videojs from 'video.js'
2import { isMobile } from '@root-helpers/web-browser'
3
4function getPauseBezel () {
5 return `
6 <div class="vjs-bezels-pause">
7 <div class="vjs-bezel" role="status" aria-label="Pause">
8 <div class="vjs-bezel-icon">
9 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
10 <use class="vjs-svg-shadow" xlink:href="#vjs-id-1"></use>
11 <path class="vjs-svg-fill" d="M 12,26 16,26 16,10 12,10 z M 21,26 25,26 25,10 21,10 z" id="vjs-id-1"></path>
12 </svg>
13 </div>
14 </div>
15 </div>
16 `
17}
18
19function getPlayBezel () {
20 return `
21 <div class="vjs-bezels-play">
22 <div class="vjs-bezel" role="status" aria-label="Play">
23 <div class="vjs-bezel-icon">
24 <svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%">
25 <use class="vjs-svg-shadow" xlink:href="#vjs-id-2"></use>
26 <path class="vjs-svg-fill" d="M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z" id="ytp-id-2"></path>
27 </svg>
28 </div>
29 </div>
30 </div>
31 `
32}
33
34const Component = videojs.getComponent('Component')
35class PauseBezel extends Component {
36 container: HTMLDivElement
37
38 constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
39 super(player, options)
40
41 // Hide bezels on mobile since we already have our mobile overlay
42 if (isMobile()) return
43
44 player.on('pause', (_: any) => {
45 if (player.seeking() || player.ended()) return
46 this.container.innerHTML = getPauseBezel()
47 this.showBezel()
48 })
49
50 player.on('play', (_: any) => {
51 if (player.seeking()) return
52 this.container.innerHTML = getPlayBezel()
53 this.showBezel()
54 })
55 }
56
57 createEl () {
58 this.container = super.createEl('div', {
59 className: 'vjs-bezels-content'
60 }) as HTMLDivElement
61
62 this.container.style.display = 'none'
63
64 return this.container
65 }
66
67 showBezel () {
68 this.container.style.display = 'inherit'
69
70 setTimeout(() => {
71 this.container.style.display = 'none'
72 }, 500) // matching the animation duration
73 }
74}
75
76videojs.registerComponent('PauseBezel', PauseBezel)
diff --git a/client/src/assets/player/shared/common/index.ts b/client/src/assets/player/shared/common/index.ts
new file mode 100644
index 000000000..9c56149ef
--- /dev/null
+++ b/client/src/assets/player/shared/common/index.ts
@@ -0,0 +1 @@
export * from './utils'
diff --git a/client/src/assets/player/shared/common/utils.ts b/client/src/assets/player/shared/common/utils.ts
new file mode 100644
index 000000000..da7dda0c7
--- /dev/null
+++ b/client/src/assets/player/shared/common/utils.ts
@@ -0,0 +1,66 @@
1import { VideoFile } from '@shared/models'
2
3function toTitleCase (str: string) {
4 return str.charAt(0).toUpperCase() + str.slice(1)
5}
6
7// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
8// Don't import all Angular stuff, just copy the code with shame
9const dictionaryBytes: Array<{max: number, type: string}> = [
10 { max: 1024, type: 'B' },
11 { max: 1048576, type: 'KB' },
12 { max: 1073741824, type: 'MB' },
13 { max: 1.0995116e12, type: 'GB' }
14]
15function bytes (value: number) {
16 const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1]
17 const calc = Math.floor(value / (format.max / 1024)).toString()
18
19 return [ calc, format.type ]
20}
21
22function videoFileMaxByResolution (files: VideoFile[]) {
23 let max = files[0]
24
25 for (let i = 1; i < files.length; i++) {
26 const file = files[i]
27 if (max.resolution.id < file.resolution.id) max = file
28 }
29
30 return max
31}
32
33function videoFileMinByResolution (files: VideoFile[]) {
34 let min = files[0]
35
36 for (let i = 1; i < files.length; i++) {
37 const file = files[i]
38 if (min.resolution.id > file.resolution.id) min = file
39 }
40
41 return min
42}
43
44function getRtcConfig () {
45 return {
46 iceServers: [
47 {
48 urls: 'stun:stun.stunprotocol.org'
49 },
50 {
51 urls: 'stun:stun.framasoft.org'
52 }
53 ]
54 }
55}
56
57// ---------------------------------------------------------------------------
58
59export {
60 getRtcConfig,
61 toTitleCase,
62
63 videoFileMaxByResolution,
64 videoFileMinByResolution,
65 bytes
66}
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
new file mode 100644
index 000000000..db5b8938d
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -0,0 +1,5 @@
1export * from './next-previous-video-button'
2export * from './p2p-info-button'
3export * from './peertube-link-button'
4export * from './peertube-load-progress-bar'
5export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts
new file mode 100644
index 000000000..b7b986806
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts
@@ -0,0 +1,50 @@
1import videojs from 'video.js'
2import { NextPreviousVideoButtonOptions } from '../../types'
3
4const Button = videojs.getComponent('Button')
5
6class NextPreviousVideoButton extends Button {
7 private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions
8
9 constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) {
10 super(player, options as any)
11
12 this.nextPreviousVideoButtonOptions = options
13
14 this.update()
15 }
16
17 createEl () {
18 const type = (this.options_ as NextPreviousVideoButtonOptions).type
19
20 const button = videojs.dom.createEl('button', {
21 className: 'vjs-' + type + '-video'
22 }) as HTMLButtonElement
23 const nextIcon = videojs.dom.createEl('span', {
24 className: 'icon icon-' + type
25 })
26 button.appendChild(nextIcon)
27
28 if (type === 'next') {
29 button.title = this.player_.localize('Next video')
30 } else {
31 button.title = this.player_.localize('Previous video')
32 }
33
34 return button
35 }
36
37 handleClick () {
38 this.nextPreviousVideoButtonOptions.handler()
39 }
40
41 update () {
42 const disabled = this.nextPreviousVideoButtonOptions.isDisabled()
43
44 if (disabled) this.addClass('vjs-disabled')
45 else this.removeClass('vjs-disabled')
46 }
47}
48
49videojs.registerComponent('NextVideoButton', NextPreviousVideoButton)
50videojs.registerComponent('PreviousVideoButton', NextPreviousVideoButton)
diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts
new file mode 100644
index 000000000..36517e125
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts
@@ -0,0 +1,124 @@
1import videojs from 'video.js'
2import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types'
3import { bytes } from '../common'
4
5const Button = videojs.getComponent('Button')
6class P2pInfoButton extends Button {
7
8 constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) {
9 super(player, options as any)
10 }
11
12 createEl () {
13 const div = videojs.dom.createEl('div', {
14 className: 'vjs-peertube'
15 })
16 const subDivWebtorrent = videojs.dom.createEl('div', {
17 className: 'vjs-peertube-hidden' // Hide the stats before we get the info
18 }) as HTMLDivElement
19 div.appendChild(subDivWebtorrent)
20
21 // Stop here if P2P is not enabled
22 const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled
23 if (!p2pEnabled) return div as HTMLButtonElement
24
25 const downloadIcon = videojs.dom.createEl('span', {
26 className: 'icon icon-download'
27 })
28 subDivWebtorrent.appendChild(downloadIcon)
29
30 const downloadSpeedText = videojs.dom.createEl('span', {
31 className: 'download-speed-text'
32 })
33 const downloadSpeedNumber = videojs.dom.createEl('span', {
34 className: 'download-speed-number'
35 })
36 const downloadSpeedUnit = videojs.dom.createEl('span')
37 downloadSpeedText.appendChild(downloadSpeedNumber)
38 downloadSpeedText.appendChild(downloadSpeedUnit)
39 subDivWebtorrent.appendChild(downloadSpeedText)
40
41 const uploadIcon = videojs.dom.createEl('span', {
42 className: 'icon icon-upload'
43 })
44 subDivWebtorrent.appendChild(uploadIcon)
45
46 const uploadSpeedText = videojs.dom.createEl('span', {
47 className: 'upload-speed-text'
48 })
49 const uploadSpeedNumber = videojs.dom.createEl('span', {
50 className: 'upload-speed-number'
51 })
52 const uploadSpeedUnit = videojs.dom.createEl('span')
53 uploadSpeedText.appendChild(uploadSpeedNumber)
54 uploadSpeedText.appendChild(uploadSpeedUnit)
55 subDivWebtorrent.appendChild(uploadSpeedText)
56
57 const peersText = videojs.dom.createEl('span', {
58 className: 'peers-text'
59 })
60 const peersNumber = videojs.dom.createEl('span', {
61 className: 'peers-number'
62 })
63 subDivWebtorrent.appendChild(peersNumber)
64 subDivWebtorrent.appendChild(peersText)
65
66 const subDivHttp = videojs.dom.createEl('div', {
67 className: 'vjs-peertube-hidden'
68 })
69 const subDivHttpText = videojs.dom.createEl('span', {
70 className: 'http-fallback',
71 textContent: 'HTTP'
72 })
73
74 subDivHttp.appendChild(subDivHttpText)
75 div.appendChild(subDivHttp)
76
77 this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => {
78 // We are in HTTP fallback
79 if (!data) {
80 subDivHttp.className = 'vjs-peertube-displayed'
81 subDivWebtorrent.className = 'vjs-peertube-hidden'
82
83 return
84 }
85
86 const p2pStats = data.p2p
87 const httpStats = data.http
88
89 const downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed)
90 const uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed)
91 const totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded)
92 const totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded)
93 const numPeers = p2pStats.numPeers
94
95 subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n'
96
97 if (data.source === 'p2p-media-loader') {
98 const downloadedFromServer = bytes(httpStats.downloaded).join(' ')
99 const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
100
101 subDivWebtorrent.title +=
102 ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' +
103 ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n'
104 }
105 subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ')
106
107 downloadSpeedNumber.textContent = downloadSpeed[0]
108 downloadSpeedUnit.textContent = ' ' + downloadSpeed[1]
109
110 uploadSpeedNumber.textContent = uploadSpeed[0]
111 uploadSpeedUnit.textContent = ' ' + uploadSpeed[1]
112
113 peersNumber.textContent = numPeers.toString()
114 peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer'))
115
116 subDivHttp.className = 'vjs-peertube-hidden'
117 subDivWebtorrent.className = 'vjs-peertube-displayed'
118 })
119
120 return div as HTMLButtonElement
121 }
122}
123
124videojs.registerComponent('P2PInfoButton', P2pInfoButton)
diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts
new file mode 100644
index 000000000..6d83263cc
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts
@@ -0,0 +1,45 @@
1import videojs from 'video.js'
2import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
3import { PeerTubeLinkButtonOptions } from '../../types'
4
5const Button = videojs.getComponent('Button')
6class PeerTubeLinkButton extends Button {
7
8 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
9 super(player, options as any)
10 }
11
12 createEl () {
13 return this.buildElement()
14 }
15
16 updateHref () {
17 this.el().setAttribute('href', this.buildLink())
18 }
19
20 handleClick () {
21 this.player().pause()
22 }
23
24 private buildElement () {
25 const el = videojs.dom.createEl('a', {
26 href: this.buildLink(),
27 innerHTML: 'PeerTube',
28 title: this.player().localize('Video page (new window)'),
29 className: 'vjs-peertube-link',
30 target: '_blank'
31 })
32
33 el.addEventListener('mouseenter', () => this.updateHref())
34
35 return el as HTMLButtonElement
36 }
37
38 private buildLink () {
39 const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID })
40
41 return decorateVideoLink({ url, startTime: this.player().currentTime() })
42 }
43}
44
45videojs.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton)
diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts
new file mode 100644
index 000000000..623e70eb2
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts
@@ -0,0 +1,33 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class PeerTubeLoadProgressBar extends Component {
6
7 constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
8 super(player, options)
9
10 this.on(player, 'progress', this.update)
11 }
12
13 createEl () {
14 return super.createEl('div', {
15 className: 'vjs-load-progress',
16 innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
17 })
18 }
19
20 dispose () {
21 super.dispose()
22 }
23
24 update () {
25 const torrent = this.player().webtorrent().getTorrent()
26 if (!torrent) return
27
28 (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%'
29 }
30
31}
32
33Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts
new file mode 100644
index 000000000..56c349d6b
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/theater-button.ts
@@ -0,0 +1,53 @@
1import videojs from 'video.js'
2import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage'
3
4const Button = videojs.getComponent('Button')
5class TheaterButton extends Button {
6
7 private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled'
8
9 constructor (player: videojs.Player, options: videojs.ComponentOptions) {
10 super(player, options)
11
12 const enabled = getStoredTheater()
13 if (enabled === true) {
14 this.player().addClass(TheaterButton.THEATER_MODE_CLASS)
15
16 this.handleTheaterChange()
17 }
18
19 this.controlText('Theater mode')
20
21 this.player().theaterEnabled = enabled
22 }
23
24 buildCSSClass () {
25 return `vjs-theater-control ${super.buildCSSClass()}`
26 }
27
28 handleTheaterChange () {
29 const theaterEnabled = this.isTheaterEnabled()
30
31 if (theaterEnabled) {
32 this.controlText('Normal mode')
33 } else {
34 this.controlText('Theater mode')
35 }
36
37 saveTheaterInStore(theaterEnabled)
38
39 this.player_.trigger('theaterChange', theaterEnabled)
40 }
41
42 handleClick () {
43 this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS)
44
45 this.handleTheaterChange()
46 }
47
48 private isTheaterEnabled () {
49 return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS)
50 }
51}
52
53videojs.registerComponent('TheaterButton', TheaterButton)
diff --git a/client/src/assets/player/shared/dock/index.ts b/client/src/assets/player/shared/dock/index.ts
new file mode 100644
index 000000000..16e70a9c1
--- /dev/null
+++ b/client/src/assets/player/shared/dock/index.ts
@@ -0,0 +1,2 @@
1export * from './peertube-dock-component'
2export * from './peertube-dock-plugin'
diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts
new file mode 100644
index 000000000..183c7a00f
--- /dev/null
+++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts
@@ -0,0 +1,65 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5export type PeerTubeDockComponentOptions = {
6 title?: string
7 description?: string
8 avatarUrl?: string
9}
10
11class PeerTubeDockComponent extends Component {
12
13 createEl () {
14 const options = this.options_ as PeerTubeDockComponentOptions
15
16 const el = super.createEl('div', {
17 className: 'peertube-dock'
18 })
19
20 if (options.avatarUrl) {
21 const avatar = videojs.dom.createEl('img', {
22 className: 'peertube-dock-avatar',
23 src: options.avatarUrl
24 })
25
26 el.appendChild(avatar)
27 }
28
29 const elWrapperTitleDescription = super.createEl('div', {
30 className: 'peertube-dock-title-description'
31 })
32
33 if (options.title) {
34 const title = videojs.dom.createEl('div', {
35 className: 'peertube-dock-title',
36 title: options.title,
37 innerHTML: options.title
38 })
39
40 elWrapperTitleDescription.appendChild(title)
41 }
42
43 if (options.description) {
44 const description = videojs.dom.createEl('div', {
45 className: 'peertube-dock-description',
46 title: options.description,
47 innerHTML: options.description
48 })
49
50 elWrapperTitleDescription.appendChild(description)
51 }
52
53 if (options.title || options.description) {
54 el.appendChild(elWrapperTitleDescription)
55 }
56
57 return el
58 }
59}
60
61videojs.registerComponent('PeerTubeDockComponent', PeerTubeDockComponent)
62
63export {
64 PeerTubeDockComponent
65}
diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts
new file mode 100644
index 000000000..245981692
--- /dev/null
+++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts
@@ -0,0 +1,25 @@
1import videojs from 'video.js'
2import { PeerTubeDockComponent } from './peertube-dock-component'
3
4const Plugin = videojs.getPlugin('plugin')
5
6export type PeerTubeDockPluginOptions = {
7 title?: string
8 description?: string
9 avatarUrl?: string
10}
11
12class PeerTubeDockPlugin extends Plugin {
13 constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) {
14 super(player, options)
15
16 this.player.addClass('peertube-dock')
17
18 this.player.ready(() => {
19 this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent
20 })
21 }
22}
23
24videojs.registerPlugin('peertubeDock', PeerTubeDockPlugin)
25export { PeerTubeDockPlugin }
diff --git a/client/src/assets/player/shared/hotkeys/index.ts b/client/src/assets/player/shared/hotkeys/index.ts
new file mode 100644
index 000000000..cc99a1ea8
--- /dev/null
+++ b/client/src/assets/player/shared/hotkeys/index.ts
@@ -0,0 +1 @@
export * from './peertube-hotkeys-plugin'
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
new file mode 100644
index 000000000..5920450bd
--- /dev/null
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -0,0 +1,196 @@
1import videojs from 'video.js'
2
3type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void }
4
5const Plugin = videojs.getPlugin('plugin')
6
7class PeerTubeHotkeysPlugin extends Plugin {
8 private static readonly VOLUME_STEP = 0.1
9 private static readonly SEEK_STEP = 5
10
11 private readonly handleKeyFunction: (event: KeyboardEvent) => void
12
13 private readonly handlers: KeyHandler[]
14
15 constructor (player: videojs.Player, options: videojs.PlayerOptions) {
16 super(player, options)
17
18 this.handlers = this.buildHandlers()
19
20 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
21 document.addEventListener('keydown', this.handleKeyFunction)
22 }
23
24 dispose () {
25 document.removeEventListener('keydown', this.handleKeyFunction)
26 }
27
28 private onKeyDown (event: KeyboardEvent) {
29 if (!this.isValidKeyTarget(event.target as HTMLElement)) return
30
31 for (const handler of this.handlers) {
32 if (handler.accept(event)) {
33 handler.cb(event)
34 return
35 }
36 }
37 }
38
39 private buildHandlers () {
40 const handlers: KeyHandler[] = [
41 // Play
42 {
43 accept: e => (e.key === ' ' || e.key === 'MediaPlayPause'),
44 cb: e => {
45 e.preventDefault()
46 e.stopPropagation()
47
48 if (this.player.paused()) this.player.play()
49 else this.player.pause()
50 }
51 },
52
53 // Increase volume
54 {
55 accept: e => this.isNaked(e, 'ArrowUp'),
56 cb: e => {
57 e.preventDefault()
58 this.player.volume(this.player.volume() + PeerTubeHotkeysPlugin.VOLUME_STEP)
59 }
60 },
61
62 // Decrease volume
63 {
64 accept: e => this.isNaked(e, 'ArrowDown'),
65 cb: e => {
66 e.preventDefault()
67 this.player.volume(this.player.volume() - PeerTubeHotkeysPlugin.VOLUME_STEP)
68 }
69 },
70
71 // Rewind
72 {
73 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
74 cb: e => {
75 e.preventDefault()
76
77 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
78 this.player.currentTime(target)
79 }
80 },
81
82 // Forward
83 {
84 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
85 cb: e => {
86 e.preventDefault()
87
88 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
89 this.player.currentTime(target)
90 }
91 },
92
93 // Fullscreen
94 {
95 // f key or Ctrl + Enter
96 accept: e => this.isNaked(e, 'f') || (!e.altKey && e.ctrlKey && e.key === 'Enter'),
97 cb: e => {
98 e.preventDefault()
99
100 if (this.player.isFullscreen()) this.player.exitFullscreen()
101 else this.player.requestFullscreen()
102 }
103 },
104
105 // Mute
106 {
107 accept: e => this.isNaked(e, 'm'),
108 cb: e => {
109 e.preventDefault()
110
111 this.player.muted(!this.player.muted())
112 }
113 },
114
115 // Increase playback rate
116 {
117 accept: e => e.key === '>',
118 cb: () => {
119 const target = Math.min(this.player.playbackRate() + 0.1, 5)
120
121 this.player.playbackRate(parseFloat(target.toFixed(2)))
122 }
123 },
124
125 // Decrease playback rate
126 {
127 accept: e => e.key === '<',
128 cb: () => {
129 const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
130
131 this.player.playbackRate(parseFloat(target.toFixed(2)))
132 }
133 },
134
135 // Previous frame
136 {
137 accept: e => e.key === ',',
138 cb: () => {
139 this.player.pause()
140
141 // Calculate movement distance (assuming 30 fps)
142 const dist = 1 / 30
143 this.player.currentTime(this.player.currentTime() - dist)
144 }
145 },
146
147 // Next frame
148 {
149 accept: e => e.key === '.',
150 cb: () => {
151 this.player.pause()
152
153 // Calculate movement distance (assuming 30 fps)
154 const dist = 1 / 30
155 this.player.currentTime(this.player.currentTime() + dist)
156 }
157 }
158 ]
159
160 // 0-9 key handlers
161 for (let i = 0; i < 10; i++) {
162 handlers.push({
163 accept: e => e.key === i + '',
164 cb: e => {
165 e.preventDefault()
166
167 this.player.currentTime(this.player.duration() * i * 0.1)
168 }
169 })
170 }
171
172 return handlers
173 }
174
175 private isValidKeyTarget (eventEl: HTMLElement) {
176 const playerEl = this.player.el()
177 const activeEl = document.activeElement
178 const currentElTagName = eventEl.tagName.toLowerCase()
179
180 return (
181 activeEl === playerEl ||
182 activeEl === playerEl.querySelector('.vjs-tech') ||
183 activeEl === playerEl.querySelector('.vjs-control-bar') ||
184 eventEl.id === 'content' ||
185 currentElTagName === 'body' ||
186 currentElTagName === 'video'
187 )
188 }
189
190 private isNaked (event: KeyboardEvent, key: string) {
191 return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key)
192 }
193}
194
195videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin)
196export { PeerTubeHotkeysPlugin }
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
new file mode 100644
index 000000000..72a10eb26
--- /dev/null
+++ b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
@@ -0,0 +1,137 @@
1import {
2 CommonOptions,
3 NextPreviousVideoButtonOptions,
4 PeerTubeLinkButtonOptions,
5 PeertubePlayerManagerOptions,
6 PlayerMode
7} from '../../types'
8
9export class ControlBarOptionsBuilder {
10 private options: CommonOptions
11
12 constructor (
13 globalOptions: PeertubePlayerManagerOptions,
14 private mode: PlayerMode
15 ) {
16 this.options = globalOptions.common
17 }
18
19 getChildrenOptions () {
20 const children = {}
21
22 if (this.options.previousVideo) {
23 Object.assign(children, this.getPreviousVideo())
24 }
25
26 Object.assign(children, { playToggle: {} })
27
28 if (this.options.nextVideo) {
29 Object.assign(children, this.getNextVideo())
30 }
31
32 Object.assign(children, {
33 currentTimeDisplay: {},
34 timeDivider: {},
35 durationDisplay: {},
36 liveDisplay: {},
37
38 flexibleWidthSpacer: {},
39
40 ...this.getProgressControl(),
41
42 p2PInfoButton: {
43 p2pEnabled: this.options.p2pEnabled
44 },
45
46 muteToggle: {},
47 volumeControl: {},
48
49 ...this.getSettingsButton()
50 })
51
52 if (this.options.peertubeLink === true) {
53 Object.assign(children, {
54 peerTubeLinkButton: { shortUUID: this.options.videoShortUUID } as PeerTubeLinkButtonOptions
55 })
56 }
57
58 if (this.options.theaterButton === true) {
59 Object.assign(children, {
60 theaterButton: {}
61 })
62 }
63
64 Object.assign(children, {
65 fullscreenToggle: {}
66 })
67
68 return children
69 }
70
71 private getSettingsButton () {
72 const settingEntries: string[] = []
73
74 settingEntries.push('playbackRateMenuButton')
75
76 if (this.options.captions === true) settingEntries.push('captionsButton')
77
78 settingEntries.push('resolutionMenuButton')
79
80 return {
81 settingsButton: {
82 setup: {
83 maxHeightOffset: 40
84 },
85 entries: settingEntries
86 }
87 }
88 }
89
90 private getProgressControl () {
91 const loadProgressBar = this.mode === 'webtorrent'
92 ? 'peerTubeLoadProgressBar'
93 : 'loadProgressBar'
94
95 return {
96 progressControl: {
97 children: {
98 seekBar: {
99 children: {
100 [loadProgressBar]: {},
101 mouseTimeDisplay: {},
102 playProgressBar: {}
103 }
104 }
105 }
106 }
107 }
108 }
109
110 private getPreviousVideo () {
111 const buttonOptions: NextPreviousVideoButtonOptions = {
112 type: 'previous',
113 handler: this.options.previousVideo,
114 isDisabled: () => {
115 if (!this.options.hasPreviousVideo) return false
116
117 return !this.options.hasPreviousVideo()
118 }
119 }
120
121 return { previousVideoButton: buttonOptions }
122 }
123
124 private getNextVideo () {
125 const buttonOptions: NextPreviousVideoButtonOptions = {
126 type: 'next',
127 handler: this.options.nextVideo,
128 isDisabled: () => {
129 if (!this.options.hasNextVideo) return false
130
131 return !this.options.hasNextVideo()
132 }
133 }
134
135 return { nextVideoButton: buttonOptions }
136 }
137}
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/manager-options/hls-options-builder.ts
new file mode 100644
index 000000000..e7f664fd4
--- /dev/null
+++ b/client/src/assets/player/shared/manager-options/hls-options-builder.ts
@@ -0,0 +1,192 @@
1import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
2import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
3import { LiveVideoLatencyMode } from '@shared/models'
4import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
5import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types'
6import { PeertubePlayerManagerOptions } from '../../types/manager-options'
7import { getRtcConfig } from '../common'
8import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
9import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
10import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator'
11
12export class HLSOptionsBuilder {
13
14 constructor (
15 private options: PeertubePlayerManagerOptions,
16 private p2pMediaLoaderModule?: any
17 ) {
18
19 }
20
21 getPluginOptions () {
22 const commonOptions = this.options.common
23
24 const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls)
25
26 const p2pMediaLoaderConfig = this.getP2PMediaLoaderOptions(redundancyUrlManager)
27 const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
28
29 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
30 redundancyUrlManager,
31 type: 'application/x-mpegURL',
32 startTime: commonOptions.startTime,
33 src: this.options.p2pMediaLoader.playlistUrl,
34 loader
35 }
36
37 const hlsjs = {
38 levelLabelHandler: (level: { height: number, width: number }) => {
39 const resolution = Math.min(level.height || 0, level.width || 0)
40
41 const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution)
42 // We don't have files for live videos
43 if (!file) return level.height
44
45 let label = file.resolution.label
46 if (file.fps >= 50) label += file.fps
47
48 return label
49 },
50 html5: {
51 hlsjsConfig: this.getHLSJSOptions(loader)
52 }
53 }
54
55 return { p2pMediaLoader, hlsjs }
56 }
57
58 // ---------------------------------------------------------------------------
59
60 private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings {
61 let consumeOnly = false
62 if ((navigator as any)?.connection?.type === 'cellular') {
63 console.log('We are on a cellular connection: disabling seeding.')
64 consumeOnly = true
65 }
66
67 const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce
68 .filter(t => t.startsWith('ws'))
69
70 const specificLiveOrVODOptions = this.options.common.isLive
71 ? this.getP2PMediaLoaderLiveOptions()
72 : this.getP2PMediaLoaderVODOptions()
73
74 return {
75 loader: {
76
77 trackerAnnounce,
78 rtcConfig: getRtcConfig(),
79
80 simultaneousHttpDownloads: 1,
81 httpFailedSegmentTimeout: 1000,
82
83 segmentValidator: segmentValidatorFactory(this.options.p2pMediaLoader.segmentsSha256Url, this.options.common.isLive),
84 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager, 1),
85
86 useP2P: this.options.common.p2pEnabled,
87 consumeOnly,
88
89 ...specificLiveOrVODOptions
90 },
91 segments: {
92 swarmId: this.options.p2pMediaLoader.playlistUrl,
93 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority
94 }
95 }
96 }
97
98 private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
99 const base = {
100 requiredSegmentsPriority: 1
101 }
102
103 const latencyMode = this.options.common.liveOptions.latencyMode
104
105 switch (latencyMode) {
106 case LiveVideoLatencyMode.SMALL_LATENCY:
107 return {
108 ...base,
109
110 useP2P: false,
111 httpDownloadProbability: 1
112 }
113
114 case LiveVideoLatencyMode.HIGH_LATENCY:
115 return base
116
117 default:
118 return base
119 }
120 }
121
122 private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
123 return {
124 requiredSegmentsPriority: 3,
125
126 cachedSegmentExpiration: 86400000,
127 cachedSegmentsCount: 100,
128
129 httpDownloadMaxPriority: 9,
130 httpDownloadProbability: 0.06,
131 httpDownloadProbabilitySkipIfNoPeers: true,
132
133 p2pDownloadMaxPriority: 50
134 }
135 }
136
137 // ---------------------------------------------------------------------------
138
139 private getHLSJSOptions (loader: P2PMediaLoader) {
140 const specificLiveOrVODOptions = this.options.common.isLive
141 ? this.getHLSLiveOptions()
142 : this.getHLSVODOptions()
143
144 const base = {
145 capLevelToPlayerSize: true,
146 autoStartLoad: false,
147
148 loader,
149
150 ...specificLiveOrVODOptions
151 }
152
153 const averageBandwidth = getAverageBandwidthInStore()
154 if (!averageBandwidth) return base
155
156 return {
157 ...base,
158
159 abrEwmaDefaultEstimate: averageBandwidth * 8, // We want bit/s
160 startLevel: -1,
161 testBandwidth: false,
162 debug: false
163 }
164 }
165
166 private getHLSLiveOptions () {
167 const latencyMode = this.options.common.liveOptions.latencyMode
168
169 switch (latencyMode) {
170 case LiveVideoLatencyMode.SMALL_LATENCY:
171 return {
172 liveSyncDurationCount: 2
173 }
174
175 case LiveVideoLatencyMode.HIGH_LATENCY:
176 return {
177 liveSyncDurationCount: 10
178 }
179
180 default:
181 return {
182 liveSyncDurationCount: 5
183 }
184 }
185 }
186
187 private getHLSVODOptions () {
188 return {
189 liveSyncDurationCount: 5
190 }
191 }
192}
diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts
new file mode 100644
index 000000000..4934d8302
--- /dev/null
+++ b/client/src/assets/player/shared/manager-options/index.ts
@@ -0,0 +1 @@
export * from './manager-options-builder'
diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts
new file mode 100644
index 000000000..5dab1f7a9
--- /dev/null
+++ b/client/src/assets/player/shared/manager-options/manager-options-builder.ts
@@ -0,0 +1,169 @@
1import videojs from 'video.js'
2import { copyToClipboard } from '@root-helpers/utils'
3import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
4import { isIOS, isSafari } from '@root-helpers/web-browser'
5import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
6import { isDefaultLocale } from '@shared/core-utils/i18n'
7import { VideoJSPluginOptions } from '../../types'
8import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options'
9import { ControlBarOptionsBuilder } from './control-bar-options-builder'
10import { HLSOptionsBuilder } from './hls-options-builder'
11import { WebTorrentOptionsBuilder } from './webtorrent-options-builder'
12
13export class ManagerOptionsBuilder {
14
15 constructor (
16 private mode: PlayerMode,
17 private options: PeertubePlayerManagerOptions,
18 private p2pMediaLoaderModule?: any
19 ) {
20
21 }
22
23 getVideojsOptions (alreadyPlayed: boolean): videojs.PlayerOptions {
24 const commonOptions = this.options.common
25
26 let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed)
27 const html5 = {
28 preloadTextTracks: false
29 }
30
31 const plugins: VideoJSPluginOptions = {
32 peertube: {
33 mode: this.mode,
34 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
35 videoViewUrl: commonOptions.videoViewUrl,
36 videoDuration: commonOptions.videoDuration,
37 userWatching: commonOptions.userWatching,
38 subtitle: commonOptions.subtitle,
39 videoCaptions: commonOptions.videoCaptions,
40 stopTime: commonOptions.stopTime,
41 isLive: commonOptions.isLive,
42 videoUUID: commonOptions.videoUUID
43 }
44 }
45
46 if (commonOptions.playlist) {
47 plugins.playlist = commonOptions.playlist
48 }
49
50 if (this.mode === 'p2p-media-loader') {
51 const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule)
52
53 Object.assign(plugins, hlsOptionsBuilder.getPluginOptions())
54 } else if (this.mode === 'webtorrent') {
55 const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed))
56
57 Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions())
58
59 // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
60 autoplay = false
61 }
62
63 const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode)
64
65 const videojsOptions = {
66 html5,
67
68 // We don't use text track settings for now
69 textTrackSettings: false as any, // FIXME: typings
70 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
71 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
72
73 muted: commonOptions.muted !== undefined
74 ? commonOptions.muted
75 : undefined, // Undefined so the player knows it has to check the local storage
76
77 autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed),
78
79 poster: commonOptions.poster,
80 inactivityTimeout: commonOptions.inactivityTimeout,
81 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
82
83 plugins,
84
85 controlBar: {
86 children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings
87 }
88 }
89
90 if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
91 Object.assign(videojsOptions, { language: commonOptions.language })
92 }
93
94 return videojsOptions
95 }
96
97 private getAutoPlayValue (autoplay: any, alreadyPlayed: boolean) {
98 if (autoplay !== true) return autoplay
99
100 // On first play, disable autoplay to avoid issues
101 // But if the player already played videos, we can safely autoplay next ones
102 if (isIOS() || isSafari()) {
103 return alreadyPlayed ? 'play' : false
104 }
105
106 return 'play'
107 }
108
109 getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) {
110 const content = () => {
111 const isLoopEnabled = player.options_['loop']
112
113 const items = [
114 {
115 icon: 'repeat',
116 label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
117 listener: function () {
118 player.options_['loop'] = !isLoopEnabled
119 }
120 },
121 {
122 label: player.localize('Copy the video URL'),
123 listener: function () {
124 copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID }))
125 }
126 },
127 {
128 label: player.localize('Copy the video URL at the current time'),
129 listener: function (this: videojs.Player) {
130 const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID })
131
132 copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() }))
133 }
134 },
135 {
136 icon: 'code',
137 label: player.localize('Copy embed code'),
138 listener: () => {
139 copyToClipboard(buildVideoOrPlaylistEmbed(commonOptions.embedUrl, commonOptions.embedTitle))
140 }
141 }
142 ]
143
144 if (this.mode === 'webtorrent') {
145 items.push({
146 label: player.localize('Copy magnet URI'),
147 listener: function (this: videojs.Player) {
148 copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
149 }
150 })
151 }
152
153 items.push({
154 icon: 'info',
155 label: player.localize('Stats for nerds'),
156 listener: () => {
157 player.stats().show()
158 }
159 })
160
161 return items.map(i => ({
162 ...i,
163 label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
164 }))
165 }
166
167 return { content }
168 }
169}
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
new file mode 100644
index 000000000..257cf1e05
--- /dev/null
+++ b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
@@ -0,0 +1,36 @@
1import { PeertubePlayerManagerOptions } from '../../types'
2
3export class WebTorrentOptionsBuilder {
4
5 constructor (
6 private options: PeertubePlayerManagerOptions,
7 private autoPlayValue: any
8 ) {
9
10 }
11
12 getPluginOptions () {
13 const commonOptions = this.options.common
14 const webtorrentOptions = this.options.webtorrent
15 const p2pMediaLoaderOptions = this.options.p2pMediaLoader
16
17 const autoplay = this.autoPlayValue === 'play'
18
19 const webtorrent = {
20 autoplay,
21
22 playerRefusedP2P: commonOptions.p2pEnabled === false,
23 videoDuration: commonOptions.videoDuration,
24 playerElement: commonOptions.playerElement,
25
26 videoFiles: webtorrentOptions.videoFiles.length !== 0
27 ? webtorrentOptions.videoFiles
28 // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
29 : p2pMediaLoaderOptions?.videoFiles || [],
30
31 startTime: commonOptions.startTime
32 }
33
34 return { webtorrent }
35 }
36}
diff --git a/client/src/assets/player/shared/mobile/index.ts b/client/src/assets/player/shared/mobile/index.ts
new file mode 100644
index 000000000..6f42b8db7
--- /dev/null
+++ b/client/src/assets/player/shared/mobile/index.ts
@@ -0,0 +1,2 @@
1export * from './peertube-mobile-buttons'
2export * from './peertube-mobile-plugin'
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts
new file mode 100644
index 000000000..09cb98f2e
--- /dev/null
+++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts
@@ -0,0 +1,94 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4class PeerTubeMobileButtons extends Component {
5
6 private rewind: Element
7 private forward: Element
8 private rewindText: Element
9 private forwardText: Element
10
11 createEl () {
12 const container = super.createEl('div', {
13 className: 'vjs-mobile-buttons-overlay'
14 }) as HTMLDivElement
15
16 const mainButton = super.createEl('div', {
17 className: 'main-button'
18 }) as HTMLDivElement
19
20 mainButton.addEventListener('touchstart', e => {
21 e.stopPropagation()
22
23 if (this.player_.paused() || this.player_.ended()) {
24 this.player_.play()
25 return
26 }
27
28 this.player_.pause()
29 })
30
31 this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' })
32 this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' })
33
34 for (let i = 0; i < 3; i++) {
35 this.rewind.appendChild(super.createEl('span', { className: 'icon' }))
36 this.forward.appendChild(super.createEl('span', { className: 'icon' }))
37 }
38
39 this.rewindText = this.rewind.appendChild(super.createEl('div', { className: 'text' }))
40 this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' }))
41
42 container.appendChild(this.rewind)
43 container.appendChild(mainButton)
44 container.appendChild(this.forward)
45
46 return container
47 }
48
49 displayFastSeek (amount: number) {
50 if (amount === 0) {
51 this.hideRewind()
52 this.hideForward()
53 return
54 }
55
56 if (amount > 0) {
57 this.hideRewind()
58 this.displayForward(amount)
59 return
60 }
61
62 if (amount < 0) {
63 this.hideForward()
64 this.displayRewind(amount)
65 return
66 }
67 }
68
69 private hideRewind () {
70 this.rewind.classList.add('vjs-hidden')
71 this.rewindText.textContent = ''
72 }
73
74 private displayRewind (amount: number) {
75 this.rewind.classList.remove('vjs-hidden')
76 this.rewindText.textContent = this.player().localize('{1} seconds', [ amount + '' ])
77 }
78
79 private hideForward () {
80 this.forward.classList.add('vjs-hidden')
81 this.forwardText.textContent = ''
82 }
83
84 private displayForward (amount: number) {
85 this.forward.classList.remove('vjs-hidden')
86 this.forwardText.textContent = this.player().localize('{1} seconds', [ amount + '' ])
87 }
88}
89
90videojs.registerComponent('PeerTubeMobileButtons', PeerTubeMobileButtons)
91
92export {
93 PeerTubeMobileButtons
94}
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts
new file mode 100644
index 000000000..91dda7f94
--- /dev/null
+++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts
@@ -0,0 +1,155 @@
1import { PeerTubeMobileButtons } from './peertube-mobile-buttons'
2import videojs from 'video.js'
3import debug from 'debug'
4
5const logger = debug('peertube:player:mobile')
6
7const Plugin = videojs.getPlugin('plugin')
8
9class PeerTubeMobilePlugin extends Plugin {
10 private static readonly DOUBLE_TAP_DELAY_MS = 250
11 private static readonly SET_CURRENT_TIME_DELAY = 1000
12
13 private peerTubeMobileButtons: PeerTubeMobileButtons
14
15 private seekAmount = 0
16
17 private lastTapEvent: TouchEvent
18 private tapTimeout: ReturnType<typeof setTimeout>
19 private newActiveState: boolean
20
21 private setCurrentTimeTimeout: ReturnType<typeof setTimeout>
22
23 constructor (player: videojs.Player, options: videojs.PlayerOptions) {
24 super(player, options)
25
26 this.peerTubeMobileButtons = player.addChild('PeerTubeMobileButtons', { reportTouchActivity: false }) as PeerTubeMobileButtons
27
28 if (videojs.browser.IS_ANDROID && screen.orientation) {
29 this.handleFullscreenRotation()
30 }
31
32 if (!this.player.options_.userActions) this.player.options_.userActions = {};
33
34 // FIXME: typings
35 (this.player.options_.userActions as any).click = false
36 this.player.options_.userActions.doubleClick = false
37
38 this.player.one('play', () => {
39 this.initTouchStartEvents()
40 })
41 }
42
43 private handleFullscreenRotation () {
44 this.player.on('fullscreenchange', () => {
45 if (!this.player.isFullscreen() || this.isPortraitVideo()) return
46
47 screen.orientation.lock('landscape')
48 .catch(err => console.error('Cannot lock screen to landscape.', err))
49 })
50 }
51
52 private isPortraitVideo () {
53 return this.player.videoWidth() < this.player.videoHeight()
54 }
55
56 private initTouchStartEvents () {
57 const handleTouchStart = (event: TouchEvent) => {
58 if (this.tapTimeout) {
59 clearTimeout(this.tapTimeout)
60 this.tapTimeout = undefined
61 }
62
63 if (this.lastTapEvent && event.timeStamp - this.lastTapEvent.timeStamp < PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS) {
64 logger('Detected double tap')
65
66 this.lastTapEvent = undefined
67 this.onDoubleTap(event)
68 return
69 }
70
71 this.newActiveState = !this.player.userActive()
72
73 this.tapTimeout = setTimeout(() => {
74 logger('No double tap detected, set user active state to %s.', this.newActiveState)
75
76 this.player.userActive(this.newActiveState)
77 }, PeerTubeMobilePlugin.DOUBLE_TAP_DELAY_MS)
78
79 this.lastTapEvent = event
80 }
81
82 this.player.on('touchstart', (event: TouchEvent) => {
83 // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it
84 if (this.player.userActive()) return
85
86 handleTouchStart(event)
87 })
88
89 this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => {
90 // Prevent mousemove/click events firing on the player, that conflict with our user active logic
91 event.preventDefault()
92
93 handleTouchStart(event)
94 }, { passive: false })
95 }
96
97 private onDoubleTap (event: TouchEvent) {
98 const playerWidth = this.player.currentWidth()
99
100 const rect = this.findPlayerTarget((event.target as HTMLElement)).getBoundingClientRect()
101 const offsetX = event.targetTouches[0].pageX - rect.left
102
103 logger('Calculating double tap zone (player width: %d, offset X: %d)', playerWidth, offsetX)
104
105 if (offsetX > 0.66 * playerWidth) {
106 if (this.seekAmount < 0) this.seekAmount = 0
107
108 this.seekAmount += 10
109
110 logger('Will forward %d seconds', this.seekAmount)
111 } else if (offsetX < 0.33 * playerWidth) {
112 if (this.seekAmount > 0) this.seekAmount = 0
113
114 this.seekAmount -= 10
115 logger('Will rewind %d seconds', this.seekAmount)
116 }
117
118 this.peerTubeMobileButtons.displayFastSeek(this.seekAmount)
119
120 this.scheduleSetCurrentTime()
121 }
122
123 private findPlayerTarget (target: HTMLElement): HTMLElement {
124 if (target.classList.contains('video-js')) return target
125
126 return this.findPlayerTarget(target.parentElement)
127 }
128
129 private scheduleSetCurrentTime () {
130 this.player.pause()
131 this.player.addClass('vjs-fast-seeking')
132
133 if (this.setCurrentTimeTimeout) clearTimeout(this.setCurrentTimeTimeout)
134
135 this.setCurrentTimeTimeout = setTimeout(() => {
136 let newTime = this.player.currentTime() + this.seekAmount
137 this.seekAmount = 0
138
139 newTime = Math.max(0, newTime)
140 newTime = Math.min(this.player.duration(), newTime)
141
142 this.player.currentTime(newTime)
143 this.seekAmount = 0
144 this.peerTubeMobileButtons.displayFastSeek(0)
145
146 this.player.removeClass('vjs-fast-seeking')
147 this.player.userActive(false)
148
149 this.player.play()
150 }, PeerTubeMobilePlugin.SET_CURRENT_TIME_DELAY)
151 }
152}
153
154videojs.registerPlugin('peertubeMobile', PeerTubeMobilePlugin)
155export { PeerTubeMobilePlugin }
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
new file mode 100644
index 000000000..d0105fa36
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
@@ -0,0 +1,419 @@
1// Thanks https://github.com/streamroot/videojs-hlsjs-plugin
2// We duplicated this plugin to choose the hls.js version we want, because streamroot only provide a bundled file
3
4import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js'
5import videojs from 'video.js'
6import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types'
7
8type ErrorCounts = {
9 [ type: string ]: number
10}
11
12type Metadata = {
13 levels: Level[]
14}
15
16type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
17
18const registerSourceHandler = function (vjs: typeof videojs) {
19 if (!Hlsjs.isSupported()) {
20 console.warn('Hls.js is not supported in this browser!')
21 return
22 }
23
24 const html5 = vjs.getTech('Html5')
25
26 if (!html5) {
27 console.error('No Hml5 tech found in videojs')
28 return
29 }
30
31 // FIXME: typings
32 (html5 as any).registerSourceHandler({
33 canHandleSource: function (source: videojs.Tech.SourceObject) {
34 const hlsTypeRE = /^application\/x-mpegURL|application\/vnd\.apple\.mpegurl$/i
35 const hlsExtRE = /\.m3u8/i
36
37 if (hlsTypeRE.test(source.type)) return 'probably'
38 if (hlsExtRE.test(source.src)) return 'maybe'
39
40 return ''
41 },
42
43 handleSource: function (source: videojs.Tech.SourceObject, tech: VideoJSTechHLS) {
44 if (tech.hlsProvider) {
45 tech.hlsProvider.dispose()
46 }
47
48 tech.hlsProvider = new Html5Hlsjs(vjs, source, tech)
49
50 return tech.hlsProvider
51 }
52 }, 0);
53
54 // FIXME: typings
55 (vjs as any).Html5Hlsjs = Html5Hlsjs
56}
57
58function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) {
59 const player = this
60
61 if (!options) return
62
63 if (!player.srOptions_) {
64 player.srOptions_ = {}
65 }
66
67 if (!player.srOptions_.hlsjsConfig) {
68 player.srOptions_.hlsjsConfig = options.hlsjsConfig
69 }
70
71 if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
72 player.srOptions_.levelLabelHandler = options.levelLabelHandler
73 }
74}
75
76const registerConfigPlugin = function (vjs: typeof videojs) {
77 // Used in Brightcove since we don't pass options directly there
78 const registerVjsPlugin = vjs.registerPlugin || vjs.plugin
79 registerVjsPlugin('hlsjs', hlsjsConfigHandler)
80}
81
82class Html5Hlsjs {
83 private static readonly hooks: { [id: string]: HookFn[] } = {}
84
85 private readonly videoElement: HTMLVideoElement
86 private readonly errorCounts: ErrorCounts = {}
87 private readonly player: videojs.Player
88 private readonly tech: videojs.Tech
89 private readonly source: videojs.Tech.SourceObject
90 private readonly vjs: typeof videojs
91
92 private maxNetworkErrorRecovery = 5
93
94 private hls: Hlsjs
95 private hlsjsConfig: Partial<HlsConfig & { cueHandler: any }> = null
96
97 private _duration: number = null
98 private metadata: Metadata = null
99 private isLive: boolean = null
100 private dvrDuration: number = null
101 private edgeMargin: number = null
102
103 private handlers: { [ id in 'play' ]: EventListener } = {
104 play: null
105 }
106
107 constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
108 this.vjs = vjs
109 this.source = source
110
111 this.tech = tech;
112 (this.tech as any).name_ = 'Hlsjs'
113
114 this.videoElement = tech.el() as HTMLVideoElement
115 this.player = vjs((tech.options_ as any).playerId)
116
117 this.videoElement.addEventListener('error', event => {
118 let errorTxt: string
119 const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error
120
121 if (!mediaError) return
122
123 console.log(mediaError)
124 switch (mediaError.code) {
125 case mediaError.MEDIA_ERR_ABORTED:
126 errorTxt = 'You aborted the video playback'
127 break
128 case mediaError.MEDIA_ERR_DECODE:
129 errorTxt = 'The video playback was aborted due to a corruption problem or because the video used features ' +
130 'your browser did not support'
131 this._handleMediaError(mediaError)
132 break
133 case mediaError.MEDIA_ERR_NETWORK:
134 errorTxt = 'A network error caused the video download to fail part-way'
135 break
136 case mediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
137 errorTxt = 'The video could not be loaded, either because the server or network failed or because the format is not supported'
138 break
139
140 default:
141 errorTxt = mediaError.message
142 }
143
144 console.error('MEDIA_ERROR: ', errorTxt)
145 })
146
147 this.initialize()
148 }
149
150 duration () {
151 if (this._duration === Infinity) return Infinity
152 if (!isNaN(this.videoElement.duration)) return this.videoElement.duration
153
154 return this._duration || 0
155 }
156
157 seekable () {
158 if (this.hls.media) {
159 if (!this.isLive) {
160 return this.vjs.createTimeRanges(0, this.hls.media.duration)
161 }
162
163 // Video.js doesn't seem to like floating point timeranges
164 const startTime = Math.round(this.hls.media.duration - this.dvrDuration)
165 const endTime = Math.round(this.hls.media.duration - this.edgeMargin)
166
167 return this.vjs.createTimeRanges(startTime, endTime)
168 }
169
170 return this.vjs.createTimeRanges()
171 }
172
173 // See comment for `initialize` method.
174 dispose () {
175 this.videoElement.removeEventListener('play', this.handlers.play)
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
183 this.hls.destroy()
184 }
185
186 static addHook (type: string, callback: HookFn) {
187 Html5Hlsjs.hooks[type] = this.hooks[type] || []
188 Html5Hlsjs.hooks[type].push(callback)
189 }
190
191 static removeHook (type: string, callback: HookFn) {
192 if (Html5Hlsjs.hooks[type] === undefined) return false
193
194 const index = Html5Hlsjs.hooks[type].indexOf(callback)
195 if (index === -1) return false
196
197 Html5Hlsjs.hooks[type].splice(index, 1)
198
199 return true
200 }
201
202 private _executeHooksFor (type: string) {
203 if (Html5Hlsjs.hooks[type] === undefined) {
204 return
205 }
206
207 // ES3 and IE < 9
208 for (let i = 0; i < Html5Hlsjs.hooks[type].length; i++) {
209 Html5Hlsjs.hooks[type][i](this.player, this.hls)
210 }
211 }
212
213 private _handleMediaError (error: any) {
214 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 1) {
215 console.info('trying to recover media error')
216 this.hls.recoverMediaError()
217 return
218 }
219
220 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] === 2) {
221 console.info('2nd try to recover media error (by swapping audio codec')
222 this.hls.swapAudioCodec()
223 this.hls.recoverMediaError()
224 return
225 }
226
227 if (this.errorCounts[Hlsjs.ErrorTypes.MEDIA_ERROR] > 2) {
228 console.info('bubbling media error up to VIDEOJS')
229 this.hls.destroy()
230 this.tech.error = () => error
231 this.tech.trigger('error')
232 }
233 }
234
235 private _handleNetworkError (error: any) {
236 if (this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] <= this.maxNetworkErrorRecovery) {
237 console.info('trying to recover network error')
238
239 // Wait 1 second and retry
240 setTimeout(() => this.hls.startLoad(), 1000)
241
242 // Reset error count on success
243 this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
244 this.errorCounts[Hlsjs.ErrorTypes.NETWORK_ERROR] = 0
245 })
246
247 return
248 }
249
250 console.info('bubbling network error up to VIDEOJS')
251 this.hls.destroy()
252 this.tech.error = () => error
253 this.tech.trigger('error')
254 }
255
256 private _onError (_event: any, data: ErrorData) {
257 const error: { message: string, code?: number } = {
258 message: `HLS.js error: ${data.type} - fatal: ${data.fatal} - ${data.details}`
259 }
260
261 // increment/set error count
262 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
263 else this.errorCounts[data.type] = 1
264
265 if (data.fatal) console.warn(error.message)
266 else console.error(error.message, data)
267
268 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
269 error.code = 2
270 this._handleNetworkError(error)
271 } else if (data.fatal && data.type === Hlsjs.ErrorTypes.MEDIA_ERROR && data.details !== 'manifestIncompatibleCodecsError') {
272 error.code = 3
273 this._handleMediaError(error)
274 } else if (data.fatal) {
275 this.hls.destroy()
276 console.info('bubbling error up to VIDEOJS')
277 this.tech.error = () => error as any
278 this.tech.trigger('error')
279 }
280 }
281
282 private buildLevelLabel (level: Level) {
283 if (this.player.srOptions_.levelLabelHandler) {
284 return this.player.srOptions_.levelLabelHandler(level as any)
285 }
286
287 if (level.height) return level.height + 'p'
288 if (level.width) return Math.round(level.width * 9 / 16) + 'p'
289 if (level.bitrate) return (level.bitrate / 1000) + 'kbps'
290
291 return '0'
292 }
293
294 private _notifyVideoQualities () {
295 if (!this.metadata) return
296
297 const resolutions: PeerTubeResolution[] = []
298
299 this.metadata.levels.forEach((level, index) => {
300 resolutions.push({
301 id: index,
302 height: level.height,
303 width: level.width,
304 bitrate: level.bitrate,
305 label: this.buildLevelLabel(level),
306 selected: level.id === this.hls.manualLevel,
307
308 selectCallback: () => {
309 this.hls.currentLevel = index
310 }
311 })
312 })
313
314 resolutions.push({
315 id: -1,
316 label: this.player.localize('Auto'),
317 selected: true,
318 selectCallback: () => this.hls.currentLevel = -1
319 })
320
321 this.player.peertubeResolutions().add(resolutions)
322 }
323
324 private _startLoad () {
325 this.hls.startLoad(-1)
326 this.videoElement.removeEventListener('play', this.handlers.play)
327 }
328
329 private _oneLevelObjClone (obj: { [ id: string ]: any }) {
330 const result = {}
331 const objKeys = Object.keys(obj)
332 for (let i = 0; i < objKeys.length; i++) {
333 result[objKeys[i]] = obj[objKeys[i]]
334 }
335
336 return result
337 }
338
339 private _onMetaData (_event: any, data: ManifestParsedData) {
340 // This could arrive before 'loadedqualitydata' handlers is registered, remember it so we can raise it later
341 this.metadata = data
342 this._notifyVideoQualities()
343 }
344
345 private _initHlsjs () {
346 const techOptions = this.tech.options_ as HlsjsConfigHandlerOptions
347 const srOptions_ = this.player.srOptions_
348
349 const hlsjsConfigRef = srOptions_?.hlsjsConfig || techOptions.hlsjsConfig
350 // Hls.js will write to the reference thus change the object for later streams
351 this.hlsjsConfig = hlsjsConfigRef ? this._oneLevelObjClone(hlsjsConfigRef) : {}
352
353 if ([ '', 'auto' ].includes(this.videoElement.preload) && !this.videoElement.autoplay && this.hlsjsConfig.autoStartLoad === undefined) {
354 this.hlsjsConfig.autoStartLoad = false
355 }
356
357 // If the user explicitly sets autoStartLoad to false, we're not going to enter the if block above
358 // That's why we have a separate if block here to set the 'play' listener
359 if (this.hlsjsConfig.autoStartLoad === false) {
360 this.handlers.play = this._startLoad.bind(this)
361 this.videoElement.addEventListener('play', this.handlers.play)
362 }
363
364 this.hls = new Hlsjs(this.hlsjsConfig)
365
366 this._executeHooksFor('beforeinitialize')
367
368 this.hls.on(Hlsjs.Events.ERROR, (event, data) => this._onError(event, data))
369 this.hls.on(Hlsjs.Events.MANIFEST_PARSED, (event, data) => this._onMetaData(event, data))
370 this.hls.on(Hlsjs.Events.LEVEL_LOADED, (event, data) => {
371 // The DVR plugin will auto seek to "live edge" on start up
372 if (this.hlsjsConfig.liveSyncDuration) {
373 this.edgeMargin = this.hlsjsConfig.liveSyncDuration
374 } else if (this.hlsjsConfig.liveSyncDurationCount) {
375 this.edgeMargin = this.hlsjsConfig.liveSyncDurationCount * data.details.targetduration
376 }
377
378 this.isLive = data.details.live
379 this.dvrDuration = data.details.totalduration
380
381 this._duration = this.isLive ? Infinity : data.details.totalduration
382
383 // Increase network error recovery for lives since they can be broken (server restart, stream interruption etc)
384 if (this.isLive) this.maxNetworkErrorRecovery = 300
385 })
386
387 this.hls.once(Hlsjs.Events.FRAG_LOADED, () => {
388 // Emit custom 'loadedmetadata' event for parity with `videojs-contrib-hls`
389 // Ref: https://github.com/videojs/videojs-contrib-hls#loadedmetadata
390 this.tech.trigger('loadedmetadata')
391 })
392
393 this.hls.on(Hlsjs.Events.LEVEL_SWITCHING, (_e, data: LevelSwitchingData) => {
394 const resolutionId = this.hls.autoLevelEnabled
395 ? -1
396 : data.level
397
398 const autoResolutionChosenId = this.hls.autoLevelEnabled
399 ? data.level
400 : -1
401
402 this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true })
403 })
404
405 this.hls.attachMedia(this.videoElement)
406
407 this.hls.loadSource(this.source.src)
408 }
409
410 private initialize () {
411 this._initHlsjs()
412 }
413}
414
415export {
416 Html5Hlsjs,
417 registerSourceHandler,
418 registerConfigPlugin
419}
diff --git a/client/src/assets/player/shared/p2p-media-loader/index.ts b/client/src/assets/player/shared/p2p-media-loader/index.ts
new file mode 100644
index 000000000..02fe71e73
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/index.ts
@@ -0,0 +1,5 @@
1export * from './hls-plugin'
2export * from './p2p-media-loader-plugin'
3export * from './redundancy-url-manager'
4export * from './segment-url-builder'
5export * from './segment-validator'
diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
new file mode 100644
index 000000000..5c0f0021f
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -0,0 +1,183 @@
1import Hlsjs from 'hls.js'
2import videojs from 'video.js'
3import { Events, Segment } from '@peertube/p2p-media-loader-core'
4import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
5import { timeToInt } from '@shared/core-utils'
6import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
7import { registerConfigPlugin, registerSourceHandler } from './hls-plugin'
8
9registerConfigPlugin(videojs)
10registerSourceHandler(videojs)
11
12const Plugin = videojs.getPlugin('plugin')
13class P2pMediaLoaderPlugin extends Plugin {
14
15 private readonly CONSTANTS = {
16 INFO_SCHEDULER: 1000 // Don't change this
17 }
18 private readonly options: P2PMediaLoaderPluginOptions
19
20 private hlsjs: Hlsjs
21 private p2pEngine: Engine
22 private statsP2PBytes = {
23 pendingDownload: [] as number[],
24 pendingUpload: [] as number[],
25 numPeers: 0,
26 totalDownload: 0,
27 totalUpload: 0
28 }
29 private statsHTTPBytes = {
30 pendingDownload: [] as number[],
31 pendingUpload: [] as number[],
32 totalDownload: 0,
33 totalUpload: 0
34 }
35 private startTime: number
36
37 private networkInfoInterval: any
38
39 constructor (player: videojs.Player, options?: P2PMediaLoaderPluginOptions) {
40 super(player)
41
42 this.options = options
43
44 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
45 if (!(videojs as any).Html5Hlsjs) {
46 console.warn('HLS.js does not seem to be supported. Try to fallback to built in HLS.')
47
48 if (!player.canPlayType('application/vnd.apple.mpegurl')) {
49 const message = 'Cannot fallback to built-in HLS'
50 console.warn(message)
51
52 player.ready(() => player.trigger('error', new Error(message)))
53 return
54 }
55 } else {
56 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
57 (videojs as any).Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: any) => {
58 this.hlsjs = hlsjs
59 })
60
61 initVideoJsContribHlsJsPlayer(player)
62 }
63
64 this.startTime = timeToInt(options.startTime)
65
66 player.src({
67 type: options.type,
68 src: options.src
69 })
70
71 player.ready(() => {
72 this.initializeCore()
73
74 if ((videojs as any).Html5Hlsjs) {
75 this.initializePlugin()
76 }
77 })
78 }
79
80 dispose () {
81 if (this.hlsjs) this.hlsjs.destroy()
82 if (this.p2pEngine) this.p2pEngine.destroy()
83
84 clearInterval(this.networkInfoInterval)
85 }
86
87 getCurrentLevel () {
88 return this.hlsjs.levels[this.hlsjs.currentLevel]
89 }
90
91 getLiveLatency () {
92 return Math.round(this.hlsjs.latency)
93 }
94
95 getHLSJS () {
96 return this.hlsjs
97 }
98
99 private initializeCore () {
100 this.player.one('play', () => {
101 this.player.addClass('vjs-has-big-play-button-clicked')
102 })
103
104 this.player.one('canplay', () => {
105 if (this.startTime) {
106 this.player.currentTime(this.startTime)
107 }
108 })
109 }
110
111 private initializePlugin () {
112 initHlsJsPlayer(this.hlsjs)
113
114 this.p2pEngine = this.options.loader.getEngine()
115
116 this.p2pEngine.on(Events.SegmentError, (segment: Segment, err) => {
117 console.error('Segment error.', segment, err)
118
119 this.options.redundancyUrlManager.removeBySegmentUrl(segment.requestUrl)
120 })
121
122 this.statsP2PBytes.numPeers = 1 + this.options.redundancyUrlManager.countBaseUrls()
123
124 this.runStats()
125 }
126
127 private runStats () {
128 this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, _segment, bytes: number) => {
129 const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
130
131 elem.pendingDownload.push(bytes)
132 elem.totalDownload += bytes
133 })
134
135 this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, _segment, bytes: number) => {
136 const elem = method === 'p2p' ? this.statsP2PBytes : this.statsHTTPBytes
137
138 elem.pendingUpload.push(bytes)
139 elem.totalUpload += bytes
140 })
141
142 this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
143 this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--)
144
145 this.networkInfoInterval = setInterval(() => {
146 const p2pDownloadSpeed = this.arraySum(this.statsP2PBytes.pendingDownload)
147 const p2pUploadSpeed = this.arraySum(this.statsP2PBytes.pendingUpload)
148
149 const httpDownloadSpeed = this.arraySum(this.statsHTTPBytes.pendingDownload)
150 const httpUploadSpeed = this.arraySum(this.statsHTTPBytes.pendingUpload)
151
152 this.statsP2PBytes.pendingDownload = []
153 this.statsP2PBytes.pendingUpload = []
154 this.statsHTTPBytes.pendingDownload = []
155 this.statsHTTPBytes.pendingUpload = []
156
157 return this.player.trigger('p2pInfo', {
158 source: 'p2p-media-loader',
159 http: {
160 downloadSpeed: httpDownloadSpeed,
161 uploadSpeed: httpUploadSpeed,
162 downloaded: this.statsHTTPBytes.totalDownload,
163 uploaded: this.statsHTTPBytes.totalUpload
164 },
165 p2p: {
166 downloadSpeed: p2pDownloadSpeed,
167 uploadSpeed: p2pUploadSpeed,
168 numPeers: this.statsP2PBytes.numPeers,
169 downloaded: this.statsP2PBytes.totalDownload,
170 uploaded: this.statsP2PBytes.totalUpload
171 },
172 bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8
173 } as PlayerNetworkInfo)
174 }, this.CONSTANTS.INFO_SCHEDULER)
175 }
176
177 private arraySum (data: number[]) {
178 return data.reduce((a: number, b: number) => a + b, 0)
179 }
180}
181
182videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)
183export { P2pMediaLoaderPlugin }
diff --git a/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts b/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts
new file mode 100644
index 000000000..abab8aa99
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/redundancy-url-manager.ts
@@ -0,0 +1,42 @@
1import { basename, dirname } from 'path'
2
3class RedundancyUrlManager {
4
5 constructor (private baseUrls: string[] = []) {
6 // empty
7 }
8
9 removeBySegmentUrl (segmentUrl: string) {
10 console.log('Removing redundancy of segment URL %s.', segmentUrl)
11
12 const baseUrl = dirname(segmentUrl)
13
14 this.baseUrls = this.baseUrls.filter(u => u !== baseUrl && u !== baseUrl + '/')
15 }
16
17 buildUrl (url: string) {
18 const max = this.baseUrls.length + 1
19 const i = this.getRandomInt(max)
20
21 if (i === max - 1) return url
22
23 const newBaseUrl = this.baseUrls[i]
24 const slashPart = newBaseUrl.endsWith('/') ? '' : '/'
25
26 return newBaseUrl + slashPart + basename(url)
27 }
28
29 countBaseUrls () {
30 return this.baseUrls.length
31 }
32
33 private getRandomInt (max: number) {
34 return Math.floor(Math.random() * Math.floor(max))
35 }
36}
37
38// ---------------------------------------------------------------------------
39
40export {
41 RedundancyUrlManager
42}
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts b/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts
new file mode 100644
index 000000000..9d324078a
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/segment-url-builder.ts
@@ -0,0 +1,17 @@
1import { Segment } from '@peertube/p2p-media-loader-core'
2import { RedundancyUrlManager } from './redundancy-url-manager'
3
4function segmentUrlBuilderFactory (redundancyUrlManager: RedundancyUrlManager, useOriginPriority: number) {
5 return function segmentBuilder (segment: Segment) {
6 // Don't use redundancy for high priority segments
7 if (segment.priority <= useOriginPriority) return segment.url
8
9 return redundancyUrlManager.buildUrl(segment.url)
10 }
11}
12
13// ---------------------------------------------------------------------------
14
15export {
16 segmentUrlBuilderFactory
17}
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
new file mode 100644
index 000000000..f7f83a8a4
--- /dev/null
+++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
@@ -0,0 +1,106 @@
1import { wait } from '@root-helpers/utils'
2import { Segment } from '@peertube/p2p-media-loader-core'
3import { basename } from 'path'
4
5type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
6
7const maxRetries = 3
8
9function segmentValidatorFactory (segmentsSha256Url: string, isLive: boolean) {
10 let segmentsJSON = fetchSha256Segments(segmentsSha256Url)
11 const regex = /bytes=(\d+)-(\d+)/
12
13 return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) {
14 // Wait for hash generation from the server
15 if (isLive) await wait(1000)
16
17 const filename = basename(segment.url)
18
19 const segmentValue = (await segmentsJSON)[filename]
20
21 if (!segmentValue && retry > maxRetries) {
22 throw new Error(`Unknown segment name ${filename} in segment validator`)
23 }
24
25 if (!segmentValue) {
26 console.log('Refetching sha segments for %s.', filename)
27
28 await wait(1000)
29
30 segmentsJSON = fetchSha256Segments(segmentsSha256Url)
31 await segmentValidator(segment, _method, _peerId, retry + 1)
32
33 return
34 }
35
36 let hashShouldBe: string
37 let range = ''
38
39 if (typeof segmentValue === 'string') {
40 hashShouldBe = segmentValue
41 } else {
42 const captured = regex.exec(segment.range)
43 range = captured[1] + '-' + captured[2]
44
45 hashShouldBe = segmentValue[range]
46 }
47
48 if (hashShouldBe === undefined) {
49 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
50 }
51
52 const calculatedSha = await sha256Hex(segment.data)
53 if (calculatedSha !== hashShouldBe) {
54 throw new Error(
55 `Hashes does not correspond for segment ${filename}/${range}` +
56 `(expected: ${hashShouldBe} instead of ${calculatedSha})`
57 )
58 }
59 }
60}
61
62// ---------------------------------------------------------------------------
63
64export {
65 segmentValidatorFactory
66}
67
68// ---------------------------------------------------------------------------
69
70function fetchSha256Segments (url: string) {
71 return fetch(url)
72 .then(res => res.json() as Promise<SegmentsJSON>)
73 .catch(err => {
74 console.error('Cannot get sha256 segments', err)
75 return {}
76 })
77}
78
79async function sha256Hex (data?: ArrayBuffer) {
80 if (!data) return undefined
81
82 if (window.crypto.subtle) {
83 return window.crypto.subtle.digest('SHA-256', data)
84 .then(data => bufferToHex(data))
85 }
86
87 // Fallback for non HTTPS context
88 const shaModule = (await import('sha.js') as any).default
89 // eslint-disable-next-line new-cap
90 return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
91}
92
93// Thanks: https://stackoverflow.com/a/53307879
94function bufferToHex (buffer?: ArrayBuffer) {
95 if (!buffer) return ''
96
97 let s = ''
98 const h = '0123456789abcdef'
99 const o = new Uint8Array(buffer)
100
101 o.forEach((v: any) => {
102 s += h[v >> 4] + h[v & 15]
103 })
104
105 return s
106}
diff --git a/client/src/assets/player/shared/peertube/index.ts b/client/src/assets/player/shared/peertube/index.ts
new file mode 100644
index 000000000..ff4d5241b
--- /dev/null
+++ b/client/src/assets/player/shared/peertube/index.ts
@@ -0,0 +1 @@
export * from './peertube-plugin'
diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts
new file mode 100644
index 000000000..1dc3e3de0
--- /dev/null
+++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts
@@ -0,0 +1,302 @@
1import debug from 'debug'
2import videojs from 'video.js'
3import { isMobile } from '@root-helpers/web-browser'
4import { timeToInt } from '@shared/core-utils'
5import {
6 getStoredLastSubtitle,
7 getStoredMute,
8 getStoredVolume,
9 saveLastSubtitle,
10 saveMuteInStore,
11 saveVideoWatchHistory,
12 saveVolumeInStore
13} from '../../peertube-player-local-storage'
14import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types'
15import { SettingsButton } from '../settings/settings-menu-button'
16
17const logger = debug('peertube:player:peertube')
18
19const Plugin = videojs.getPlugin('plugin')
20
21class PeerTubePlugin extends Plugin {
22 private readonly videoViewUrl: string
23 private readonly videoDuration: number
24 private readonly CONSTANTS = {
25 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
26 }
27
28 private videoCaptions: VideoJSCaption[]
29 private defaultSubtitle: string
30
31 private videoViewInterval: any
32 private userWatchingVideoInterval: any
33
34 private isLive: boolean
35
36 private menuOpened = false
37 private mouseInControlBar = false
38 private mouseInSettings = false
39 private readonly initialInactivityTimeout: number
40
41 constructor (player: videojs.Player, options?: PeerTubePluginOptions) {
42 super(player)
43
44 this.videoViewUrl = options.videoViewUrl
45 this.videoDuration = options.videoDuration
46 this.videoCaptions = options.videoCaptions
47 this.isLive = options.isLive
48 this.initialInactivityTimeout = this.player.options_.inactivityTimeout
49
50 if (options.autoplay) this.player.addClass('vjs-has-autoplay')
51
52 this.player.on('autoplay-failure', () => {
53 this.player.removeClass('vjs-has-autoplay')
54 })
55
56 this.player.ready(() => {
57 const playerOptions = this.player.options_
58
59 const volume = getStoredVolume()
60 if (volume !== undefined) this.player.volume(volume)
61
62 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
63 if (muted !== undefined) this.player.muted(muted)
64
65 this.defaultSubtitle = options.subtitle || getStoredLastSubtitle()
66
67 this.player.on('volumechange', () => {
68 saveVolumeInStore(this.player.volume())
69 saveMuteInStore(this.player.muted())
70 })
71
72 if (options.stopTime) {
73 const stopTime = timeToInt(options.stopTime)
74 const self = this
75
76 this.player.on('timeupdate', function onTimeUpdate () {
77 if (self.player.currentTime() > stopTime) {
78 self.player.pause()
79 self.player.trigger('stopped')
80
81 self.player.off('timeupdate', onTimeUpdate)
82 }
83 })
84 }
85
86 this.player.textTracks().addEventListener('change', () => {
87 const showing = this.player.textTracks().tracks_.find(t => {
88 return t.kind === 'captions' && t.mode === 'showing'
89 })
90
91 if (!showing) {
92 saveLastSubtitle('off')
93 return
94 }
95
96 saveLastSubtitle(showing.language)
97 })
98
99 this.player.on('sourcechange', () => this.initCaptions())
100
101 this.player.duration(options.videoDuration)
102
103 this.initializePlayer()
104 this.runViewAdd()
105
106 this.runUserWatchVideo(options.userWatching, options.videoUUID)
107 })
108 }
109
110 dispose () {
111 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
112 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
113 }
114
115 onMenuOpened () {
116 this.menuOpened = true
117 this.alterInactivity()
118 }
119
120 onMenuClosed () {
121 this.menuOpened = false
122 this.alterInactivity()
123 }
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
133 private initializePlayer () {
134 if (isMobile()) this.player.addClass('vjs-is-mobile')
135
136 this.initSmoothProgressBar()
137
138 this.initCaptions()
139
140 this.listenControlBarMouse()
141
142 this.listenFullScreenChange()
143 }
144
145 private runViewAdd () {
146 this.clearVideoViewInterval()
147
148 // After 30 seconds (or 3/4 of the video), add a view to the video
149 let minSecondsToView = 30
150
151 if (!this.isLive && this.videoDuration < minSecondsToView) {
152 minSecondsToView = (this.videoDuration * 3) / 4
153 }
154
155 let secondsViewed = 0
156 this.videoViewInterval = setInterval(() => {
157 if (this.player && !this.player.paused()) {
158 secondsViewed += 1
159
160 if (secondsViewed > minSecondsToView) {
161 // Restart the loop if this is a live
162 if (this.isLive) {
163 secondsViewed = 0
164 } else {
165 this.clearVideoViewInterval()
166 }
167
168 this.addViewToVideo().catch(err => console.error(err))
169 }
170 }
171 }, 1000)
172 }
173
174 private runUserWatchVideo (options: UserWatching, videoUUID: string) {
175 let lastCurrentTime = 0
176
177 this.userWatchingVideoInterval = setInterval(() => {
178 const currentTime = Math.floor(this.player.currentTime())
179
180 if (currentTime - lastCurrentTime >= 1) {
181 lastCurrentTime = currentTime
182
183 if (options) {
184 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
185 .catch(err => console.error('Cannot notify user is watching.', err))
186 } else {
187 saveVideoWatchHistory(videoUUID, currentTime)
188 }
189 }
190 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
191 }
192
193 private clearVideoViewInterval () {
194 if (this.videoViewInterval !== undefined) {
195 clearInterval(this.videoViewInterval)
196 this.videoViewInterval = undefined
197 }
198 }
199
200 private addViewToVideo () {
201 if (!this.videoViewUrl) return Promise.resolve(undefined)
202
203 return fetch(this.videoViewUrl, { method: 'POST' })
204 }
205
206 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) {
207 const body = new URLSearchParams()
208 body.append('currentTime', currentTime.toString())
209
210 const headers = new Headers({ Authorization: authorizationHeader })
211
212 return fetch(url, { method: 'PUT', body, headers })
213 }
214
215 private listenFullScreenChange () {
216 this.player.on('fullscreenchange', () => {
217 if (this.player.isFullscreen()) this.player.focus()
218 })
219 }
220
221 private listenControlBarMouse () {
222 const controlBar = this.player.controlBar
223 const settingsButton: SettingsButton = (controlBar as any).settingsButton
224
225 controlBar.on('mouseenter', () => {
226 this.mouseInControlBar = true
227 this.alterInactivity()
228 })
229
230 controlBar.on('mouseleave', () => {
231 this.mouseInControlBar = false
232 this.alterInactivity()
233 })
234
235 settingsButton.dialog.on('mouseenter', () => {
236 this.mouseInSettings = true
237 this.alterInactivity()
238 })
239
240 settingsButton.dialog.on('mouseleave', () => {
241 this.mouseInSettings = false
242 this.alterInactivity()
243 })
244 }
245
246 private alterInactivity () {
247 if (this.menuOpened || this.mouseInSettings || this.mouseInControlBar) {
248 this.setInactivityTimeout(0)
249 return
250 }
251
252 this.setInactivityTimeout(this.initialInactivityTimeout)
253 this.player.reportUserActivity(true)
254 }
255
256 private setInactivityTimeout (timeout: number) {
257 (this.player as any).cache_.inactivityTimeout = timeout
258 this.player.options_.inactivityTimeout = timeout
259
260 logger('Set player inactivity to ' + timeout)
261 }
262
263 private initCaptions () {
264 for (const caption of this.videoCaptions) {
265 this.player.addRemoteTextTrack({
266 kind: 'captions',
267 label: caption.label,
268 language: caption.language,
269 id: caption.language,
270 src: caption.src,
271 default: this.defaultSubtitle === caption.language
272 }, false)
273 }
274
275 this.player.trigger('captionsChanged')
276 }
277
278 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
279 private initSmoothProgressBar () {
280 const SeekBar = videojs.getComponent('SeekBar') as any
281 SeekBar.prototype.getPercent = function getPercent () {
282 // Allows for smooth scrubbing, when player can't keep up.
283 // const time = (this.player_.scrubbing()) ?
284 // this.player_.getCache().currentTime :
285 // this.player_.currentTime()
286 const time = this.player_.currentTime()
287 const percent = time / this.player_.duration()
288 return percent >= 1 ? 1 : percent
289 }
290 SeekBar.prototype.handleMouseMove = function handleMouseMove (event: any) {
291 let newTime = this.calculateDistance(event) * this.player_.duration()
292 if (newTime === this.player_.duration()) {
293 newTime = newTime - 0.1
294 }
295 this.player_.currentTime(newTime)
296 this.update()
297 }
298 }
299}
300
301videojs.registerPlugin('peertube', PeerTubePlugin)
302export { PeerTubePlugin }
diff --git a/client/src/assets/player/shared/playlist/index.ts b/client/src/assets/player/shared/playlist/index.ts
new file mode 100644
index 000000000..0be6e4d3c
--- /dev/null
+++ b/client/src/assets/player/shared/playlist/index.ts
@@ -0,0 +1,4 @@
1export * from './playlist-button'
2export * from './playlist-menu-item'
3export * from './playlist-menu'
4export * from './playlist-plugin'
diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts
new file mode 100644
index 000000000..6cfaf4158
--- /dev/null
+++ b/client/src/assets/player/shared/playlist/playlist-button.ts
@@ -0,0 +1,61 @@
1import videojs from 'video.js'
2import { PlaylistPluginOptions } from '../../types'
3import { PlaylistMenu } from './playlist-menu'
4
5const ClickableComponent = videojs.getComponent('ClickableComponent')
6
7class PlaylistButton extends ClickableComponent {
8 private playlistInfoElement: HTMLElement
9 private wrapper: HTMLElement
10
11 constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) {
12 super(player, options as any)
13 }
14
15 createEl () {
16 this.wrapper = super.createEl('div', {
17 className: 'vjs-playlist-button',
18 innerHTML: '',
19 tabIndex: -1
20 }) as HTMLElement
21
22 const icon = super.createEl('div', {
23 className: 'vjs-playlist-icon',
24 innerHTML: '',
25 tabIndex: -1
26 })
27
28 this.playlistInfoElement = super.createEl('div', {
29 className: 'vjs-playlist-info',
30 innerHTML: '',
31 tabIndex: -1
32 }) as HTMLElement
33
34 this.wrapper.appendChild(icon)
35 this.wrapper.appendChild(this.playlistInfoElement)
36
37 this.update()
38
39 return this.wrapper
40 }
41
42 update () {
43 const options = this.options_ as PlaylistPluginOptions
44
45 this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength
46 this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
47 }
48
49 handleClick () {
50 const playlistMenu = this.getPlaylistMenu()
51 playlistMenu.open()
52 }
53
54 private getPlaylistMenu () {
55 return (this.options_ as any).playlistMenu as PlaylistMenu
56 }
57}
58
59videojs.registerComponent('PlaylistButton', PlaylistButton)
60
61export { PlaylistButton }
diff --git a/client/src/assets/player/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts
new file mode 100644
index 000000000..81b5acf30
--- /dev/null
+++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts
@@ -0,0 +1,136 @@
1import videojs from 'video.js'
2import { secondsToTime } from '@shared/core-utils'
3import { VideoPlaylistElement } from '@shared/models'
4import { PlaylistItemOptions } from '../../types'
5
6const Component = videojs.getComponent('Component')
7
8class PlaylistMenuItem extends Component {
9 private element: VideoPlaylistElement
10
11 constructor (player: videojs.Player, options?: PlaylistItemOptions) {
12 super(player, options as any)
13
14 this.emitTapEvents()
15
16 this.element = options.element
17
18 this.on([ 'click', 'tap' ], () => this.switchPlaylistItem())
19 this.on('keydown', event => this.handleKeyDown(event))
20 }
21
22 createEl () {
23 const options = this.options_ as PlaylistItemOptions
24
25 const li = super.createEl('li', {
26 className: 'vjs-playlist-menu-item',
27 innerHTML: ''
28 }) as HTMLElement
29
30 if (!options.element.video) {
31 li.classList.add('vjs-disabled')
32 }
33
34 const positionBlock = super.createEl('div', {
35 className: 'item-position-block'
36 }) as HTMLElement
37
38 const position = super.createEl('div', {
39 className: 'item-position',
40 innerHTML: options.element.position
41 })
42
43 positionBlock.appendChild(position)
44 li.appendChild(positionBlock)
45
46 if (options.element.video) {
47 this.buildAvailableVideo(li, positionBlock, options)
48 } else {
49 this.buildUnavailableVideo(li)
50 }
51
52 return li
53 }
54
55 setSelected (selected: boolean) {
56 if (selected) this.addClass('vjs-selected')
57 else this.removeClass('vjs-selected')
58 }
59
60 getElement () {
61 return this.element
62 }
63
64 private buildAvailableVideo (li: HTMLElement, positionBlock: HTMLElement, options: PlaylistItemOptions) {
65 const videoElement = options.element
66
67 const player = super.createEl('div', {
68 className: 'item-player'
69 })
70
71 positionBlock.appendChild(player)
72
73 const thumbnail = super.createEl('img', {
74 src: window.location.origin + videoElement.video.thumbnailPath
75 })
76
77 const infoBlock = super.createEl('div', {
78 className: 'info-block'
79 })
80
81 const title = super.createEl('div', {
82 innerHTML: videoElement.video.name,
83 className: 'title'
84 })
85
86 const channel = super.createEl('div', {
87 innerHTML: videoElement.video.channel.displayName,
88 className: 'channel'
89 })
90
91 infoBlock.appendChild(title)
92 infoBlock.appendChild(channel)
93
94 if (videoElement.startTimestamp || videoElement.stopTimestamp) {
95 let html = ''
96
97 if (videoElement.startTimestamp) html += secondsToTime(videoElement.startTimestamp)
98 if (videoElement.stopTimestamp) html += ' - ' + secondsToTime(videoElement.stopTimestamp)
99
100 const timestamps = super.createEl('div', {
101 innerHTML: html,
102 className: 'timestamps'
103 })
104
105 infoBlock.append(timestamps)
106 }
107
108 li.append(thumbnail)
109 li.append(infoBlock)
110 }
111
112 private buildUnavailableVideo (li: HTMLElement) {
113 const block = super.createEl('div', {
114 className: 'item-unavailable',
115 innerHTML: this.player().localize('Unavailable video')
116 })
117
118 li.appendChild(block)
119 }
120
121 private handleKeyDown (event: KeyboardEvent) {
122 if (event.code === 'Space' || event.code === 'Enter') {
123 this.switchPlaylistItem()
124 }
125 }
126
127 private switchPlaylistItem () {
128 const options = this.options_ as PlaylistItemOptions
129
130 options.onClicked()
131 }
132}
133
134Component.registerComponent('PlaylistMenuItem', PlaylistMenuItem)
135
136export { PlaylistMenuItem }
diff --git a/client/src/assets/player/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts
new file mode 100644
index 000000000..1ec9ac804
--- /dev/null
+++ b/client/src/assets/player/shared/playlist/playlist-menu.ts
@@ -0,0 +1,137 @@
1import videojs from 'video.js'
2import { VideoPlaylistElement } from '@shared/models'
3import { PlaylistPluginOptions } from '../../types'
4import { PlaylistMenuItem } from './playlist-menu-item'
5
6const Component = videojs.getComponent('Component')
7
8class PlaylistMenu extends Component {
9 private menuItems: PlaylistMenuItem[]
10
11 constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
12 super(player, options as any)
13
14 const self = this
15
16 function userInactiveHandler () {
17 self.close()
18 }
19
20 this.el().addEventListener('mouseenter', () => {
21 this.player().off('userinactive', userInactiveHandler)
22 })
23
24 this.el().addEventListener('mouseleave', () => {
25 this.player().one('userinactive', userInactiveHandler)
26 })
27
28 this.player().on('click', event => {
29 let current = event.target as HTMLElement
30
31 do {
32 if (
33 current.classList.contains('vjs-playlist-menu') ||
34 current.classList.contains('vjs-playlist-button')
35 ) {
36 return
37 }
38
39 current = current.parentElement
40 } while (current)
41
42 this.close()
43 })
44 }
45
46 createEl () {
47 this.menuItems = []
48
49 const options = this.getOptions()
50
51 const menu = super.createEl('div', {
52 className: 'vjs-playlist-menu',
53 innerHTML: '',
54 tabIndex: -1
55 })
56
57 const header = super.createEl('div', {
58 className: 'header'
59 })
60
61 const headerLeft = super.createEl('div')
62
63 const leftTitle = super.createEl('div', {
64 innerHTML: options.playlist.displayName,
65 className: 'title'
66 })
67
68 const playlistChannel = options.playlist.videoChannel
69 const leftSubtitle = super.createEl('div', {
70 innerHTML: playlistChannel
71 ? this.player().localize('By {1}', [ playlistChannel.displayName ])
72 : '',
73 className: 'channel'
74 })
75
76 headerLeft.appendChild(leftTitle)
77 headerLeft.appendChild(leftSubtitle)
78
79 const tick = super.createEl('div', {
80 className: 'cross'
81 })
82 tick.addEventListener('click', () => this.close())
83
84 header.appendChild(headerLeft)
85 header.appendChild(tick)
86
87 const list = super.createEl('ol')
88
89 for (const playlistElement of options.elements) {
90 const item = new PlaylistMenuItem(this.player(), {
91 element: playlistElement,
92 onClicked: () => this.onItemClicked(playlistElement)
93 })
94
95 list.appendChild(item.el())
96
97 this.menuItems.push(item)
98 }
99
100 menu.appendChild(header)
101 menu.appendChild(list)
102
103 return menu
104 }
105
106 update () {
107 const options = this.getOptions()
108
109 this.updateSelected(options.getCurrentPosition())
110 }
111
112 open () {
113 this.player().addClass('playlist-menu-displayed')
114 }
115
116 close () {
117 this.player().removeClass('playlist-menu-displayed')
118 }
119
120 updateSelected (newPosition: number) {
121 for (const item of this.menuItems) {
122 item.setSelected(item.getElement().position === newPosition)
123 }
124 }
125
126 private getOptions () {
127 return this.options_ as PlaylistPluginOptions
128 }
129
130 private onItemClicked (element: VideoPlaylistElement) {
131 this.getOptions().onItemClicked(element)
132 }
133}
134
135Component.registerComponent('PlaylistMenu', PlaylistMenu)
136
137export { PlaylistMenu }
diff --git a/client/src/assets/player/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts
new file mode 100644
index 000000000..44de0da5a
--- /dev/null
+++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts
@@ -0,0 +1,35 @@
1import videojs from 'video.js'
2import { PlaylistPluginOptions } from '../../types'
3import { PlaylistButton } from './playlist-button'
4import { PlaylistMenu } from './playlist-menu'
5
6const Plugin = videojs.getPlugin('plugin')
7
8class PlaylistPlugin extends Plugin {
9 private playlistMenu: PlaylistMenu
10 private playlistButton: PlaylistButton
11 private options: PlaylistPluginOptions
12
13 constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
14 super(player, options)
15
16 this.options = options
17
18 this.player.ready(() => {
19 player.addClass('vjs-playlist')
20 })
21
22 this.playlistMenu = new PlaylistMenu(player, options)
23 this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu })
24
25 player.addChild(this.playlistMenu, options)
26 player.addChild(this.playlistButton, options)
27 }
28
29 updateSelected () {
30 this.playlistMenu.updateSelected(this.options.getCurrentPosition())
31 }
32}
33
34videojs.registerPlugin('playlist', PlaylistPlugin)
35export { PlaylistPlugin }
diff --git a/client/src/assets/player/shared/resolutions/index.ts b/client/src/assets/player/shared/resolutions/index.ts
new file mode 100644
index 000000000..e56473f43
--- /dev/null
+++ b/client/src/assets/player/shared/resolutions/index.ts
@@ -0,0 +1 @@
export * from './peertube-resolutions-plugin'
diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts
new file mode 100644
index 000000000..e7899ac71
--- /dev/null
+++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts
@@ -0,0 +1,88 @@
1import videojs from 'video.js'
2import { PeerTubeResolution } from '../../types'
3
4const Plugin = videojs.getPlugin('plugin')
5
6class PeerTubeResolutionsPlugin extends Plugin {
7 private currentSelection: PeerTubeResolution
8 private resolutions: PeerTubeResolution[] = []
9
10 private autoResolutionChosenId: number
11 private autoResolutionEnabled = true
12
13 add (resolutions: PeerTubeResolution[]) {
14 for (const r of resolutions) {
15 this.resolutions.push(r)
16 }
17
18 this.currentSelection = this.getSelected()
19
20 this.sort()
21 this.trigger('resolutionsAdded')
22 }
23
24 getResolutions () {
25 return this.resolutions
26 }
27
28 getSelected () {
29 return this.resolutions.find(r => r.selected)
30 }
31
32 getAutoResolutionChosen () {
33 return this.resolutions.find(r => r.id === this.autoResolutionChosenId)
34 }
35
36 select (options: {
37 id: number
38 byEngine: boolean
39 autoResolutionChosenId?: number
40 }) {
41 const { id, autoResolutionChosenId, byEngine } = options
42
43 if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
44
45 this.autoResolutionChosenId = autoResolutionChosenId
46
47 for (const r of this.resolutions) {
48 r.selected = r.id === id
49
50 if (r.selected) {
51 this.currentSelection = r
52
53 if (!byEngine) r.selectCallback()
54 }
55 }
56
57 this.trigger('resolutionChanged')
58 }
59
60 disableAutoResolution () {
61 this.autoResolutionEnabled = false
62 this.trigger('autoResolutionEnabledChanged')
63 }
64
65 enabledAutoResolution () {
66 this.autoResolutionEnabled = true
67 this.trigger('autoResolutionEnabledChanged')
68 }
69
70 isAutoResolutionEnabeld () {
71 return this.autoResolutionEnabled
72 }
73
74 private sort () {
75 this.resolutions.sort((a, b) => {
76 if (a.id === -1) return 1
77 if (b.id === -1) return -1
78
79 if (a.height > b.height) return -1
80 if (a.height === b.height) return 0
81 return 1
82 })
83 }
84
85}
86
87videojs.registerPlugin('peertubeResolutions', PeerTubeResolutionsPlugin)
88export { PeerTubeResolutionsPlugin }
diff --git a/client/src/assets/player/shared/settings/index.ts b/client/src/assets/player/shared/settings/index.ts
new file mode 100644
index 000000000..736d50c16
--- /dev/null
+++ b/client/src/assets/player/shared/settings/index.ts
@@ -0,0 +1,7 @@
1export * from './resolution-menu-button'
2export * from './resolution-menu-item'
3export * from './settings-dialog'
4export * from './settings-menu-button'
5export * from './settings-menu-item'
6export * from './settings-panel-child'
7export * from './settings-panel'
diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts
new file mode 100644
index 000000000..8bd5b4f03
--- /dev/null
+++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts
@@ -0,0 +1,86 @@
1import videojs from 'video.js'
2import { ResolutionMenuItem } from './resolution-menu-item'
3
4const Menu = videojs.getComponent('Menu')
5const MenuButton = videojs.getComponent('MenuButton')
6class ResolutionMenuButton extends MenuButton {
7 labelEl_: HTMLElement
8
9 constructor (player: videojs.Player, options?: videojs.MenuButtonOptions) {
10 super(player, options)
11
12 this.controlText('Quality')
13
14 player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities())
15
16 // For parent
17 player.peertubeResolutions().on('resolutionChanged', () => {
18 setTimeout(() => this.trigger('labelUpdated'))
19 })
20 }
21
22 createEl () {
23 const el = super.createEl()
24
25 this.labelEl_ = videojs.dom.createEl('div', {
26 className: 'vjs-resolution-value'
27 }) as HTMLElement
28
29 el.appendChild(this.labelEl_)
30
31 return el
32 }
33
34 updateARIAAttributes () {
35 this.el().setAttribute('aria-label', 'Quality')
36 }
37
38 createMenu () {
39 return new Menu(this.player_)
40 }
41
42 buildCSSClass () {
43 return super.buildCSSClass() + ' vjs-resolution-button'
44 }
45
46 buildWrapperCSSClass () {
47 return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
48 }
49
50 private addClickListener (component: any) {
51 component.on('click', () => {
52 const children = this.menu.children()
53
54 for (const child of children) {
55 if (component !== child) {
56 (child as videojs.MenuItem).selected(false)
57 }
58 }
59 })
60 }
61
62 private buildQualities () {
63 for (const d of this.player().peertubeResolutions().getResolutions()) {
64 const label = d.label === '0p'
65 ? this.player().localize('Audio-only')
66 : d.label
67
68 this.menu.addChild(new ResolutionMenuItem(
69 this.player_,
70 {
71 id: d.id,
72 label,
73 selected: d.selected
74 })
75 )
76 }
77
78 for (const m of this.menu.children()) {
79 this.addClickListener(m)
80 }
81
82 this.trigger('menuChanged')
83 }
84}
85
86videojs.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts
new file mode 100644
index 000000000..6047f52f7
--- /dev/null
+++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts
@@ -0,0 +1,77 @@
1import videojs from 'video.js'
2
3const MenuItem = videojs.getComponent('MenuItem')
4
5export interface ResolutionMenuItemOptions extends videojs.MenuItemOptions {
6 id: number
7}
8
9class ResolutionMenuItem extends MenuItem {
10 private readonly resolutionId: number
11 private readonly label: string
12
13 private autoResolutionEnabled: boolean
14 private autoResolutionChosen: string
15
16 constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) {
17 options.selectable = true
18
19 super(player, options)
20
21 this.autoResolutionEnabled = true
22 this.autoResolutionChosen = ''
23
24 this.resolutionId = options.id
25 this.label = options.label
26
27 player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection())
28
29 // We only want to disable the "Auto" item
30 if (this.resolutionId === -1) {
31 player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution())
32 }
33 }
34
35 handleClick (event: any) {
36 // Auto button disabled?
37 if (this.autoResolutionEnabled === false && this.resolutionId === -1) return
38
39 super.handleClick(event)
40
41 this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false })
42 }
43
44 updateSelection () {
45 const selectedResolution = this.player().peertubeResolutions().getSelected()
46
47 if (this.resolutionId === -1) {
48 this.autoResolutionChosen = this.player().peertubeResolutions().getAutoResolutionChosen()?.label
49 }
50
51 this.selected(this.resolutionId === selectedResolution.id)
52 }
53
54 updateAutoResolution () {
55 const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld()
56
57 // Check if the auto resolution is enabled or not
58 if (enabled === false) {
59 this.addClass('disabled')
60 } else {
61 this.removeClass('disabled')
62 }
63
64 this.autoResolutionEnabled = enabled
65 }
66
67 getLabel () {
68 if (this.resolutionId === -1) {
69 return this.label + ' <small>' + this.autoResolutionChosen + '</small>'
70 }
71
72 return this.label
73 }
74}
75videojs.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
76
77export { ResolutionMenuItem }
diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts
new file mode 100644
index 000000000..8cd98967f
--- /dev/null
+++ b/client/src/assets/player/shared/settings/settings-dialog.ts
@@ -0,0 +1,35 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsDialog extends Component {
6 constructor (player: videojs.Player) {
7 super(player)
8
9 this.hide()
10 }
11
12 /**
13 * Create the component's DOM element
14 *
15 */
16 createEl () {
17 const uniqueId = this.id()
18 const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId
19 const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId
20
21 return super.createEl('div', {
22 className: 'vjs-settings-dialog vjs-modal-overlay',
23 innerHTML: '',
24 tabIndex: -1
25 }, {
26 role: 'dialog',
27 'aria-labelledby': dialogLabelId,
28 'aria-describedby': dialogDescriptionId
29 })
30 }
31}
32
33Component.registerComponent('SettingsDialog', SettingsDialog)
34
35export { SettingsDialog }
diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts
new file mode 100644
index 000000000..64866aab2
--- /dev/null
+++ b/client/src/assets/player/shared/settings/settings-menu-button.ts
@@ -0,0 +1,277 @@
1import videojs from 'video.js'
2import { toTitleCase } from '../common'
3import { SettingsDialog } from './settings-dialog'
4import { SettingsMenuItem } from './settings-menu-item'
5import { SettingsPanel } from './settings-panel'
6import { SettingsPanelChild } from './settings-panel-child'
7
8const Button = videojs.getComponent('Button')
9const Menu = videojs.getComponent('Menu')
10const Component = videojs.getComponent('Component')
11
12export interface SettingsButtonOptions extends videojs.ComponentOptions {
13 entries: any[]
14 setup?: {
15 maxHeightOffset: number
16 }
17}
18
19class SettingsButton extends Button {
20 dialog: SettingsDialog
21 dialogEl: HTMLElement
22 menu: videojs.Menu
23 panel: SettingsPanel
24 panelChild: SettingsPanelChild
25
26 addSettingsItemHandler: typeof SettingsButton.prototype.onAddSettingsItem
27 disposeSettingsItemHandler: typeof SettingsButton.prototype.onDisposeSettingsItem
28 documentClickHandler: typeof SettingsButton.prototype.onDocumentClick
29 userInactiveHandler: typeof SettingsButton.prototype.onUserInactive
30
31 private settingsButtonOptions: SettingsButtonOptions
32
33 constructor (player: videojs.Player, options?: SettingsButtonOptions) {
34 super(player, options)
35
36 this.settingsButtonOptions = options
37
38 this.controlText('Settings')
39
40 this.dialog = this.player().addChild('settingsDialog')
41 this.dialogEl = this.dialog.el() as HTMLElement
42 this.menu = null
43 this.panel = this.dialog.addChild('settingsPanel')
44 this.panelChild = this.panel.addChild('settingsPanelChild')
45
46 this.addClass('vjs-settings')
47 this.el().setAttribute('aria-label', 'Settings Button')
48
49 // Event handlers
50 this.addSettingsItemHandler = this.onAddSettingsItem.bind(this)
51 this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this)
52 this.documentClickHandler = this.onDocumentClick.bind(this)
53 this.userInactiveHandler = this.onUserInactive.bind(this)
54
55 this.buildMenu()
56 this.bindEvents()
57
58 // Prepare the dialog
59 this.player().one('play', () => this.hideDialog())
60 }
61
62 onDocumentClick (event: MouseEvent) {
63 const element = event.target as HTMLElement
64
65 if (element?.classList?.contains('vjs-settings') || element?.parentElement?.classList?.contains('vjs-settings')) {
66 return
67 }
68
69 if (!this.dialog.hasClass('vjs-hidden')) {
70 this.hideDialog()
71 }
72 }
73
74 onDisposeSettingsItem (event: any, name: string) {
75 if (name === undefined) {
76 const children = this.menu.children()
77
78 while (children.length > 0) {
79 children[0].dispose()
80 this.menu.removeChild(children[0])
81 }
82
83 this.addClass('vjs-hidden')
84 } else {
85 const item = this.menu.getChild(name)
86
87 if (item) {
88 item.dispose()
89 this.menu.removeChild(item)
90 }
91 }
92
93 this.hideDialog()
94
95 if (this.settingsButtonOptions.entries.length === 0) {
96 this.addClass('vjs-hidden')
97 }
98 }
99
100 dispose () {
101 document.removeEventListener('click', this.documentClickHandler)
102
103 if (this.isInIframe()) {
104 window.removeEventListener('blur', this.documentClickHandler)
105 }
106 }
107
108 onAddSettingsItem (event: any, data: any) {
109 const [ entry, options ] = data
110
111 this.addMenuItem(entry, options)
112 this.removeClass('vjs-hidden')
113 }
114
115 onUserInactive () {
116 if (!this.dialog.hasClass('vjs-hidden')) {
117 this.hideDialog()
118 }
119 }
120
121 bindEvents () {
122 document.addEventListener('click', this.documentClickHandler)
123 if (this.isInIframe()) {
124 window.addEventListener('blur', this.documentClickHandler)
125 }
126
127 this.player().on('addsettingsitem', this.addSettingsItemHandler)
128 this.player().on('disposesettingsitem', this.disposeSettingsItemHandler)
129 this.player().on('userinactive', this.userInactiveHandler)
130 }
131
132 buildCSSClass () {
133 return `vjs-icon-settings ${super.buildCSSClass()}`
134 }
135
136 handleClick () {
137 if (this.dialog.hasClass('vjs-hidden')) {
138 this.showDialog()
139 } else {
140 this.hideDialog()
141 }
142 }
143
144 showDialog () {
145 this.player().peertube().onMenuOpened();
146
147 (this.menu.el() as HTMLElement).style.opacity = '1'
148
149 this.dialog.show()
150 this.el().setAttribute('aria-expanded', 'true')
151
152 this.setDialogSize(this.getComponentSize(this.menu))
153
154 const firstChild = this.menu.children()[0]
155 if (firstChild) firstChild.focus()
156 }
157
158 hideDialog () {
159 this.player_.peertube().onMenuClosed()
160
161 this.dialog.hide()
162 this.el().setAttribute('aria-expanded', 'false')
163
164 this.setDialogSize(this.getComponentSize(this.menu));
165 (this.menu.el() as HTMLElement).style.opacity = '1'
166 this.resetChildren()
167 }
168
169 getComponentSize (element: videojs.Component | HTMLElement) {
170 let width: number = null
171 let height: number = null
172
173 // Could be component or just DOM element
174 if (element instanceof Component) {
175 const el = element.el() as HTMLElement
176
177 width = el.offsetWidth
178 height = el.offsetHeight;
179
180 (element as any).width = width;
181 (element as any).height = height
182 } else {
183 width = element.offsetWidth
184 height = element.offsetHeight
185 }
186
187 return [ width, height ]
188 }
189
190 setDialogSize ([ width, height ]: number[]) {
191 if (typeof height !== 'number') {
192 return
193 }
194
195 const offset = this.settingsButtonOptions.setup.maxHeightOffset
196 const maxHeight = (this.player().el() as HTMLElement).offsetHeight - offset
197
198 const panelEl = this.panel.el() as HTMLElement
199
200 if (height > maxHeight) {
201 height = maxHeight
202 width += 17
203 panelEl.style.maxHeight = `${height}px`
204 } else if (panelEl.style.maxHeight !== '') {
205 panelEl.style.maxHeight = ''
206 }
207
208 this.dialogEl.style.width = `${width}px`
209 this.dialogEl.style.height = `${height}px`
210 }
211
212 buildMenu () {
213 this.menu = new Menu(this.player())
214 this.menu.addClass('vjs-main-menu')
215 const entries = this.settingsButtonOptions.entries
216
217 if (entries.length === 0) {
218 this.addClass('vjs-hidden')
219 this.panelChild.addChild(this.menu)
220 return
221 }
222
223 for (const entry of entries) {
224 this.addMenuItem(entry, this.settingsButtonOptions)
225 }
226
227 this.panelChild.addChild(this.menu)
228 }
229
230 addMenuItem (entry: any, options: any) {
231 const openSubMenu = function (this: any) {
232 if (videojs.dom.hasClass(this.el_, 'open')) {
233 videojs.dom.removeClass(this.el_, 'open')
234 } else {
235 videojs.dom.addClass(this.el_, 'open')
236 }
237 }
238
239 options.name = toTitleCase(entry)
240
241 const newOptions = Object.assign({}, options, { entry, menuButton: this })
242 const settingsMenuItem = new SettingsMenuItem(this.player(), newOptions)
243
244 this.menu.addChild(settingsMenuItem)
245
246 // Hide children to avoid sub menus stacking on top of each other
247 // or having multiple menus open
248 settingsMenuItem.on('click', videojs.bind(this, this.hideChildren))
249
250 // Whether to add or remove selected class on the settings sub menu element
251 settingsMenuItem.on('click', openSubMenu)
252 }
253
254 resetChildren () {
255 for (const menuChild of this.menu.children()) {
256 (menuChild as SettingsMenuItem).reset()
257 }
258 }
259
260 /**
261 * Hide all the sub menus
262 */
263 hideChildren () {
264 for (const menuChild of this.menu.children()) {
265 (menuChild as SettingsMenuItem).hideSubMenu()
266 }
267 }
268
269 isInIframe () {
270 return window.self !== window.top
271 }
272
273}
274
275Component.registerComponent('SettingsButton', SettingsButton)
276
277export { SettingsButton }
diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts
new file mode 100644
index 000000000..8d1819a2d
--- /dev/null
+++ b/client/src/assets/player/shared/settings/settings-menu-item.ts
@@ -0,0 +1,377 @@
1import videojs from 'video.js'
2import { toTitleCase } from '../common'
3import { SettingsDialog } from './settings-dialog'
4import { SettingsButton } from './settings-menu-button'
5import { SettingsPanel } from './settings-panel'
6import { SettingsPanelChild } from './settings-panel-child'
7
8const MenuItem = videojs.getComponent('MenuItem')
9const component = videojs.getComponent('Component')
10
11export interface SettingsMenuItemOptions extends videojs.MenuItemOptions {
12 entry: string
13 menuButton: SettingsButton
14}
15
16class SettingsMenuItem extends MenuItem {
17 settingsButton: SettingsButton
18 dialog: SettingsDialog
19 mainMenu: videojs.Menu
20 panel: SettingsPanel
21 panelChild: SettingsPanelChild
22 panelChildEl: HTMLElement
23 size: number[]
24 menuToLoad: string
25 subMenu: SettingsButton
26
27 submenuClickHandler: typeof SettingsMenuItem.prototype.onSubmenuClick
28 transitionEndHandler: typeof SettingsMenuItem.prototype.onTransitionEnd
29
30 settingsSubMenuTitleEl_: HTMLElement
31 settingsSubMenuValueEl_: HTMLElement
32 settingsSubMenuEl_: HTMLElement
33
34 constructor (player: videojs.Player, options?: SettingsMenuItemOptions) {
35 super(player, options)
36
37 this.settingsButton = options.menuButton
38 this.dialog = this.settingsButton.dialog
39 this.mainMenu = this.settingsButton.menu
40 this.panel = this.dialog.getChild('settingsPanel')
41 this.panelChild = this.panel.getChild('settingsPanelChild')
42 this.panelChildEl = this.panelChild.el() as HTMLElement
43
44 this.size = null
45
46 // keep state of what menu type is loading next
47 this.menuToLoad = 'mainmenu'
48
49 const subMenuName = toTitleCase(options.entry)
50 const SubMenuComponent = videojs.getComponent(subMenuName)
51
52 if (!SubMenuComponent) {
53 throw new Error(`Component ${subMenuName} does not exist`)
54 }
55
56 const newOptions = Object.assign({}, options, { entry: options.menuButton, menuButton: this })
57
58 this.subMenu = new SubMenuComponent(this.player(), newOptions) as SettingsButton
59 const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0]
60 this.settingsSubMenuEl_.className += ' ' + subMenuClass
61
62 this.eventHandlers()
63
64 player.ready(() => {
65 // Voodoo magic for IOS
66 setTimeout(() => {
67 // Player was destroyed
68 if (!this.player_) return
69
70 this.build()
71
72 // Update on rate change
73 player.on('ratechange', this.submenuClickHandler)
74
75 if (subMenuName === 'CaptionsButton') {
76 // Hack to regenerate captions on HTTP fallback
77 player.on('captionsChanged', () => {
78 setTimeout(() => {
79 this.settingsSubMenuEl_.innerHTML = ''
80 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
81 this.update()
82 this.bindClickEvents()
83 }, 0)
84 })
85 }
86
87 this.reset()
88 }, 0)
89 })
90 }
91
92 eventHandlers () {
93 this.submenuClickHandler = this.onSubmenuClick.bind(this)
94 this.transitionEndHandler = this.onTransitionEnd.bind(this)
95 }
96
97 onSubmenuClick (event: any) {
98 let target = null
99
100 if (event.type === 'tap') {
101 target = event.target
102 } else {
103 target = event.currentTarget || event.target
104 }
105
106 if (target?.classList.contains('vjs-back-button')) {
107 this.loadMainMenu()
108 return
109 }
110
111 // To update the sub menu value on click, setTimeout is needed because
112 // updating the value is not instant
113 setTimeout(() => this.update(event), 0)
114
115 // Seems like videojs adds a vjs-hidden class on the caption menu after a click
116 // We don't need it
117 this.subMenu.menu.removeClass('vjs-hidden')
118 }
119
120 /**
121 * Create the component's DOM element
122 *
123 */
124 createEl () {
125 const el = videojs.dom.createEl('li', {
126 className: 'vjs-menu-item',
127 tabIndex: -1
128 })
129
130 this.settingsSubMenuTitleEl_ = videojs.dom.createEl('div', {
131 className: 'vjs-settings-sub-menu-title'
132 }) as HTMLElement
133
134 el.appendChild(this.settingsSubMenuTitleEl_)
135
136 this.settingsSubMenuValueEl_ = videojs.dom.createEl('div', {
137 className: 'vjs-settings-sub-menu-value'
138 }) as HTMLElement
139
140 el.appendChild(this.settingsSubMenuValueEl_)
141
142 this.settingsSubMenuEl_ = videojs.dom.createEl('div', {
143 className: 'vjs-settings-sub-menu'
144 }) as HTMLElement
145
146 return el as HTMLLIElement
147 }
148
149 /**
150 * Handle click on menu item
151 *
152 * @method handleClick
153 */
154 handleClick (event: videojs.EventTarget.Event) {
155 this.menuToLoad = 'submenu'
156 // Remove open class to ensure only the open submenu gets this class
157 videojs.dom.removeClass(this.el(), 'open')
158
159 super.handleClick(event);
160
161 (this.mainMenu.el() as HTMLElement).style.opacity = '0'
162 // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element
163 if (videojs.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) {
164 videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
165
166 // animation not played without timeout
167 setTimeout(() => {
168 this.settingsSubMenuEl_.style.opacity = '1'
169 this.settingsSubMenuEl_.style.marginRight = '0px'
170 }, 0)
171
172 this.settingsButton.setDialogSize(this.size)
173
174 const firstChild = this.subMenu.menu.children()[0]
175 if (firstChild) firstChild.focus()
176 } else {
177 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
178 }
179 }
180
181 /**
182 * Create back button
183 *
184 * @method createBackButton
185 */
186 createBackButton () {
187 const button = this.subMenu.menu.addChild('MenuItem', {}, 0)
188
189 button.addClass('vjs-back-button');
190 (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText())
191 }
192
193 /**
194 * Add/remove prefixed event listener for CSS Transition
195 *
196 * @method PrefixedEvent
197 */
198 PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
199 const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ]
200
201 for (let p = 0; p < prefix.length; p++) {
202 if (!prefix[p]) {
203 type = type.toLowerCase()
204 }
205
206 if (action === 'addEvent') {
207 element.addEventListener(prefix[p] + type, callback, false)
208 } else if (action === 'removeEvent') {
209 element.removeEventListener(prefix[p] + type, callback, false)
210 }
211 }
212 }
213
214 onTransitionEnd (event: any) {
215 if (event.propertyName !== 'margin-right') {
216 return
217 }
218
219 if (this.menuToLoad === 'mainmenu') {
220 // hide submenu
221 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
222
223 // reset opacity to 0
224 this.settingsSubMenuEl_.style.opacity = '0'
225 }
226 }
227
228 reset () {
229 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
230 this.settingsSubMenuEl_.style.opacity = '0'
231 this.setMargin()
232 }
233
234 loadMainMenu () {
235 const mainMenuEl = this.mainMenu.el() as HTMLElement
236 this.menuToLoad = 'mainmenu'
237 this.mainMenu.show()
238 mainMenuEl.style.opacity = '0'
239
240 // back button will always take you to main menu, so set dialog sizes
241 const mainMenuAny = this.mainMenu as any
242 this.settingsButton.setDialogSize([ mainMenuAny.width, mainMenuAny.height ])
243
244 // animation not triggered without timeout (some async stuff ?!?)
245 setTimeout(() => {
246 // animate margin and opacity before hiding the submenu
247 // this triggers CSS Transition event
248 this.setMargin()
249 mainMenuEl.style.opacity = '1'
250
251 const firstChild = this.mainMenu.children()[0]
252 if (firstChild) firstChild.focus()
253 }, 0)
254 }
255
256 build () {
257 this.subMenu.on('labelUpdated', () => {
258 this.update()
259 })
260 this.subMenu.on('menuChanged', () => {
261 this.bindClickEvents()
262 this.setSize()
263 this.update()
264 })
265
266 this.settingsSubMenuTitleEl_.innerHTML = this.player().localize(this.subMenu.controlText())
267 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
268 this.panelChildEl.appendChild(this.settingsSubMenuEl_)
269 this.update()
270
271 this.createBackButton()
272 this.setSize()
273 this.bindClickEvents()
274
275 // prefixed event listeners for CSS TransitionEnd
276 this.PrefixedEvent(
277 this.settingsSubMenuEl_,
278 'TransitionEnd',
279 this.transitionEndHandler,
280 'addEvent'
281 )
282 }
283
284 update (event?: any) {
285 let target: HTMLElement = null
286 const subMenu = this.subMenu.name()
287
288 if (event && event.type === 'tap') {
289 target = event.target
290 } else if (event) {
291 target = event.currentTarget
292 }
293
294 // Playback rate menu button doesn't get a vjs-selected class
295 // or sets options_['selected'] on the selected playback rate.
296 // Thus we get the submenu value based on the labelEl of playbackRateMenuButton
297 if (subMenu === 'PlaybackRateMenuButton') {
298 const html = (this.subMenu as any).labelEl_.innerHTML
299
300 setTimeout(() => {
301 this.settingsSubMenuValueEl_.innerHTML = html
302 }, 250)
303 } else {
304 // Loop trough the submenu items to find the selected child
305 for (const subMenuItem of this.subMenu.menu.children_) {
306 if (!(subMenuItem instanceof component)) {
307 continue
308 }
309
310 if (subMenuItem.hasClass('vjs-selected')) {
311 const subMenuItemUntyped = subMenuItem as any
312
313 // Prefer to use the function
314 if (typeof subMenuItemUntyped.getLabel === 'function') {
315 this.settingsSubMenuValueEl_.innerHTML = subMenuItemUntyped.getLabel()
316 break
317 }
318
319 this.settingsSubMenuValueEl_.innerHTML = this.player().localize(subMenuItemUntyped.options_.label)
320 }
321 }
322 }
323
324 if (target && !target.classList.contains('vjs-back-button')) {
325 this.settingsButton.hideDialog()
326 }
327 }
328
329 bindClickEvents () {
330 for (const item of this.subMenu.menu.children()) {
331 if (!(item instanceof component)) {
332 continue
333 }
334 item.on([ 'tap', 'click' ], this.submenuClickHandler)
335 }
336 }
337
338 // save size of submenus on first init
339 // if number of submenu items change dynamically more logic will be needed
340 setSize () {
341 this.dialog.removeClass('vjs-hidden')
342 videojs.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
343 this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
344 this.setMargin()
345 this.dialog.addClass('vjs-hidden')
346 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
347 }
348
349 setMargin () {
350 if (!this.size) return
351
352 const [ width ] = this.size
353
354 this.settingsSubMenuEl_.style.marginRight = `-${width}px`
355 }
356
357 /**
358 * Hide the sub menu
359 */
360 hideSubMenu () {
361 // after removing settings item this.el_ === null
362 if (!this.el()) {
363 return
364 }
365
366 if (videojs.dom.hasClass(this.el(), 'open')) {
367 videojs.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden')
368 videojs.dom.removeClass(this.el(), 'open')
369 }
370 }
371
372}
373
374(SettingsMenuItem as any).prototype.contentElType = 'button'
375videojs.registerComponent('SettingsMenuItem', SettingsMenuItem)
376
377export { SettingsMenuItem }
diff --git a/client/src/assets/player/shared/settings/settings-panel-child.ts b/client/src/assets/player/shared/settings/settings-panel-child.ts
new file mode 100644
index 000000000..161420c38
--- /dev/null
+++ b/client/src/assets/player/shared/settings/settings-panel-child.ts
@@ -0,0 +1,18 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsPanelChild extends Component {
6
7 createEl () {
8 return super.createEl('div', {
9 className: 'vjs-settings-panel-child',
10 innerHTML: '',
11 tabIndex: -1
12 })
13 }
14}
15
16Component.registerComponent('SettingsPanelChild', SettingsPanelChild)
17
18export { SettingsPanelChild }
diff --git a/client/src/assets/player/shared/settings/settings-panel.ts b/client/src/assets/player/shared/settings/settings-panel.ts
new file mode 100644
index 000000000..28b579bdd
--- /dev/null
+++ b/client/src/assets/player/shared/settings/settings-panel.ts
@@ -0,0 +1,18 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class SettingsPanel extends Component {
6
7 createEl () {
8 return super.createEl('div', {
9 className: 'vjs-settings-panel',
10 innerHTML: '',
11 tabIndex: -1
12 })
13 }
14}
15
16Component.registerComponent('SettingsPanel', SettingsPanel)
17
18export { SettingsPanel }
diff --git a/client/src/assets/player/shared/stats/index.ts b/client/src/assets/player/shared/stats/index.ts
new file mode 100644
index 000000000..017ec044c
--- /dev/null
+++ b/client/src/assets/player/shared/stats/index.ts
@@ -0,0 +1,2 @@
1export * from './stats-card'
2export * from './stats-plugin'
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts
new file mode 100644
index 000000000..1bf631d2c
--- /dev/null
+++ b/client/src/assets/player/shared/stats/stats-card.ts
@@ -0,0 +1,271 @@
1import videojs from 'video.js'
2import { secondsToTime } from '@shared/core-utils'
3import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types'
4import { bytes } from '../common'
5
6interface StatsCardOptions extends videojs.ComponentOptions {
7 videoUUID: string
8 videoIsLive: boolean
9 mode: 'webtorrent' | 'p2p-media-loader'
10 p2pEnabled: boolean
11}
12
13interface PlayerNetworkInfo {
14 downloadSpeed?: string
15 uploadSpeed?: string
16 totalDownloaded?: string
17 totalUploaded?: string
18 numPeers?: number
19 averageBandwidth?: string
20
21 downloadedFromServer?: string
22 downloadedFromPeers?: string
23}
24
25const Component = videojs.getComponent('Component')
26class StatsCard extends Component {
27 options_: StatsCardOptions
28
29 container: HTMLDivElement
30
31 list: HTMLDivElement
32 closeButton: HTMLElement
33
34 updateInterval: any
35
36 mode: 'webtorrent' | 'p2p-media-loader'
37
38 metadataStore: any = {}
39
40 intervalMs = 300
41 playerNetworkInfo: PlayerNetworkInfo = {}
42
43 createEl () {
44 const container = super.createEl('div', {
45 className: 'vjs-stats-content',
46 innerHTML: this.getMainTemplate()
47 }) as HTMLDivElement
48 this.container = container
49 this.container.style.display = 'none'
50
51 this.closeButton = this.container.getElementsByClassName('vjs-stats-close')[0] as HTMLElement
52 this.closeButton.onclick = () => this.hide()
53
54 this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement
55
56 this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => {
57 if (!data) return // HTTP fallback
58
59 this.mode = data.source
60
61 const p2pStats = data.p2p
62 const httpStats = data.http
63
64 this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ')
65 this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed).join(' ')
66 this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ')
67 this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ')
68 this.playerNetworkInfo.numPeers = p2pStats.numPeers
69 this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s'
70
71 if (data.source === 'p2p-media-loader') {
72 this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ')
73 this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
74 }
75 })
76
77 return container
78 }
79
80 toggle () {
81 if (this.updateInterval) this.hide()
82 else this.show()
83 }
84
85 show () {
86 this.container.style.display = 'block'
87 this.updateInterval = setInterval(async () => {
88 try {
89 const options = this.mode === 'p2p-media-loader'
90 ? this.buildHLSOptions()
91 : await this.buildWebTorrentOptions() // Default
92
93 this.list.innerHTML = this.getListTemplate(options)
94 } catch (err) {
95 console.error('Cannot update stats.', err)
96 clearInterval(this.updateInterval)
97 }
98 }, this.intervalMs)
99 }
100
101 hide () {
102 clearInterval(this.updateInterval)
103 this.container.style.display = 'none'
104 }
105
106 private buildHLSOptions () {
107 const p2pMediaLoader = this.player_.p2pMediaLoader()
108 const level = p2pMediaLoader.getCurrentLevel()
109
110 const codecs = level?.videoCodec || level?.audioCodec
111 ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}`
112 : undefined
113
114 const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}`
115 const buffer = this.timeRangesToString(this.player().buffered())
116
117 let progress: number
118 let latency: string
119
120 if (this.options_.videoIsLive) {
121 latency = secondsToTime(p2pMediaLoader.getLiveLatency())
122 } else {
123 progress = this.player().bufferedPercent()
124 }
125
126 return {
127 playerNetworkInfo: this.playerNetworkInfo,
128 resolution,
129 codecs,
130 buffer,
131 latency,
132 progress
133 }
134 }
135
136 private async buildWebTorrentOptions () {
137 const videoFile = this.player_.webtorrent().getCurrentVideoFile()
138
139 if (!this.metadataStore[videoFile.fileUrl]) {
140 this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json())
141 }
142
143 const metadata = this.metadataStore[videoFile.fileUrl]
144
145 let colorSpace = 'unknown'
146 let codecs = 'unknown'
147
148 if (metadata?.streams[0]) {
149 const stream = metadata.streams[0]
150
151 colorSpace = stream['color_space'] !== 'unknown'
152 ? stream['color_space']
153 : 'bt709'
154
155 codecs = stream['codec_name'] || 'avc1'
156 }
157
158 const resolution = videoFile?.resolution.label + videoFile?.fps
159 const buffer = this.timeRangesToString(this.player().buffered())
160 const progress = this.player_.webtorrent().getTorrent()?.progress
161
162 return {
163 playerNetworkInfo: this.playerNetworkInfo,
164 progress,
165 colorSpace,
166 codecs,
167 resolution,
168 buffer
169 }
170 }
171
172 private getListTemplate (options: {
173 playerNetworkInfo: PlayerNetworkInfo
174 progress: number
175 codecs: string
176 resolution: string
177 buffer: string
178
179 latency?: string
180 colorSpace?: string
181 }) {
182 const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options
183 const player = this.player()
184
185 const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality()
186 const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
187 const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
188 const pr = (window.devicePixelRatio || 1).toFixed(2)
189 const frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}`
190
191 const duration = player.duration()
192
193 let volume = `${Math.round(player.volume() * 100)}`
194 if (player.muted()) volume += ' (muted)'
195
196 const networkActivity = playerNetworkInfo.downloadSpeed
197 ? `${playerNetworkInfo.downloadSpeed} &dArr; / ${playerNetworkInfo.uploadSpeed} &uArr;`
198 : undefined
199
200 const totalTransferred = playerNetworkInfo.totalDownloaded
201 ? `${playerNetworkInfo.totalDownloaded} &dArr; / ${playerNetworkInfo.totalUploaded} &uArr;`
202 : undefined
203 const downloadBreakdown = playerNetworkInfo.downloadedFromServer
204 ? `${playerNetworkInfo.downloadedFromServer} from servers · ${playerNetworkInfo.downloadedFromPeers} from peers`
205 : undefined
206
207 const bufferProgress = progress !== undefined
208 ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)`
209 : undefined
210
211 return `
212 ${this.buildElement(player.localize('Player mode'), this.mode || 'HTTP')}
213 ${this.buildElement(player.localize('P2P'), player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))}
214
215 ${this.buildElement(player.localize('Video UUID'), this.options_.videoUUID)}
216
217 ${this.buildElement(player.localize('Viewport / Frames'), frames)}
218
219 ${this.buildElement(player.localize('Resolution'), resolution)}
220
221 ${this.buildElement(player.localize('Volume'), volume)}
222
223 ${this.buildElement(player.localize('Codecs'), codecs)}
224 ${this.buildElement(player.localize('Color'), colorSpace)}
225
226 ${this.buildElement(player.localize('Connection Speed'), playerNetworkInfo.averageBandwidth)}
227
228 ${this.buildElement(player.localize('Network Activity'), networkActivity)}
229 ${this.buildElement(player.localize('Total Transfered'), totalTransferred)}
230 ${this.buildElement(player.localize('Download Breakdown'), downloadBreakdown)}
231
232 ${this.buildElement(player.localize('Buffer Progress'), bufferProgress)}
233 ${this.buildElement(player.localize('Buffer State'), buffer)}
234
235 ${this.buildElement(player.localize('Live Latency'), latency)}
236 `
237 }
238
239 private getMainTemplate () {
240 return `
241 <button class="vjs-stats-close" tabindex=0 aria-label="Close stats" title="Close stats">[x]</button>
242 <div class="vjs-stats-list"></div>
243 `
244 }
245
246 private buildElement (label: string, value?: string) {
247 if (!value) return ''
248
249 return `<div><div>${label}</div><span>${value}</span></div>`
250 }
251
252 private timeRangesToString (r: videojs.TimeRange) {
253 let result = ''
254
255 for (let i = 0; i < r.length; i++) {
256 const start = Math.floor(r.start(i))
257 const end = Math.floor(r.end(i))
258
259 result += `[${secondsToTime(start)}, ${secondsToTime(end)}] `
260 }
261
262 return result
263 }
264}
265
266videojs.registerComponent('StatsCard', StatsCard)
267
268export {
269 StatsCard,
270 StatsCardOptions
271}
diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts
new file mode 100644
index 000000000..8aad80e8a
--- /dev/null
+++ b/client/src/assets/player/shared/stats/stats-plugin.ts
@@ -0,0 +1,31 @@
1import videojs from 'video.js'
2import { StatsCard, StatsCardOptions } from './stats-card'
3
4const Plugin = videojs.getPlugin('plugin')
5
6class StatsForNerdsPlugin extends Plugin {
7 private statsCard: StatsCard
8
9 constructor (player: videojs.Player, options: StatsCardOptions) {
10 const settings = {
11 ...options
12 }
13
14 super(player)
15
16 this.player.ready(() => {
17 player.addClass('vjs-stats-for-nerds')
18 })
19
20 this.statsCard = new StatsCard(player, options)
21
22 player.addChild(this.statsCard, settings)
23 }
24
25 show () {
26 this.statsCard.show()
27 }
28}
29
30videojs.registerPlugin('stats', StatsForNerdsPlugin)
31export { StatsForNerdsPlugin }
diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts
new file mode 100644
index 000000000..61668e407
--- /dev/null
+++ b/client/src/assets/player/shared/upnext/end-card.ts
@@ -0,0 +1,157 @@
1import videojs from 'video.js'
2
3function getMainTemplate (options: any) {
4 return `
5 <div class="vjs-upnext-top">
6 <span class="vjs-upnext-headtext">${options.headText}</span>
7 <div class="vjs-upnext-title"></div>
8 </div>
9 <div class="vjs-upnext-autoplay-icon">
10 <svg height="100%" version="1.1" viewbox="0 0 98 98" width="100%">
11 <circle class="vjs-upnext-svg-autoplay-circle" cx="49" cy="49" fill="#000" fill-opacity="0.8" r="48"></circle>
12 <circle class="vjs-upnext-svg-autoplay-ring" cx="-49" cy="49" fill-opacity="0" r="46.5"
13 stroke="#FFFFFF" stroke-width="4" transform="rotate(-90)"
14 ></circle>
15 <polygon class="vjs-upnext-svg-autoplay-triangle" fill="#fff" points="32,27 72,49 32,71"></polygon></svg>
16 </div>
17 <span class="vjs-upnext-bottom">
18 <span class="vjs-upnext-cancel">
19 <button class="vjs-upnext-cancel-button" tabindex="0" aria-label="Cancel autoplay">${options.cancelText}</button>
20 </span>
21 <span class="vjs-upnext-suspended">${options.suspendedText}</span>
22 </span>
23 `
24}
25
26export interface EndCardOptions extends videojs.ComponentOptions {
27 next: () => void
28 getTitle: () => string
29 timeout: number
30 cancelText: string
31 headText: string
32 suspendedText: string
33 condition: () => boolean
34 suspended: () => boolean
35}
36
37const Component = videojs.getComponent('Component')
38class EndCard extends Component {
39 options_: EndCardOptions
40
41 dashOffsetTotal = 586
42 dashOffsetStart = 293
43 interval = 50
44 upNextEvents = new videojs.EventTarget()
45 ticks = 0
46 totalTicks: number
47
48 container: HTMLDivElement
49 title: HTMLElement
50 autoplayRing: HTMLElement
51 cancelButton: HTMLElement
52 suspendedMessage: HTMLElement
53 nextButton: HTMLElement
54
55 constructor (player: videojs.Player, options: EndCardOptions) {
56 super(player, options)
57
58 this.totalTicks = this.options_.timeout / this.interval
59
60 player.on('ended', (_: any) => {
61 if (!this.options_.condition()) return
62
63 player.addClass('vjs-upnext--showing')
64 this.showCard((canceled: boolean) => {
65 player.removeClass('vjs-upnext--showing')
66 this.container.style.display = 'none'
67 if (!canceled) {
68 this.options_.next()
69 }
70 })
71 })
72
73 player.on('playing', () => {
74 this.upNextEvents.trigger('playing')
75 })
76 }
77
78 createEl () {
79 const container = super.createEl('div', {
80 className: 'vjs-upnext-content',
81 innerHTML: getMainTemplate(this.options_)
82 }) as HTMLDivElement
83
84 this.container = container
85 container.style.display = 'none'
86
87 this.autoplayRing = container.getElementsByClassName('vjs-upnext-svg-autoplay-ring')[0] as HTMLElement
88 this.title = container.getElementsByClassName('vjs-upnext-title')[0] as HTMLElement
89 this.cancelButton = container.getElementsByClassName('vjs-upnext-cancel-button')[0] as HTMLElement
90 this.suspendedMessage = container.getElementsByClassName('vjs-upnext-suspended')[0] as HTMLElement
91 this.nextButton = container.getElementsByClassName('vjs-upnext-autoplay-icon')[0] as HTMLElement
92
93 this.cancelButton.onclick = () => {
94 this.upNextEvents.trigger('cancel')
95 }
96
97 this.nextButton.onclick = () => {
98 this.upNextEvents.trigger('next')
99 }
100
101 return container
102 }
103
104 showCard (cb: (value: boolean) => void) {
105 let timeout: any
106
107 this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
108 this.autoplayRing.setAttribute('stroke-dashoffset', `${-this.dashOffsetStart}`)
109
110 this.title.innerHTML = this.options_.getTitle()
111
112 this.upNextEvents.one('cancel', () => {
113 clearTimeout(timeout)
114 cb(true)
115 })
116
117 this.upNextEvents.one('playing', () => {
118 clearTimeout(timeout)
119 cb(true)
120 })
121
122 this.upNextEvents.one('next', () => {
123 clearTimeout(timeout)
124 cb(false)
125 })
126
127 const goToPercent = (percent: number) => {
128 const newOffset = Math.max(-this.dashOffsetTotal, -this.dashOffsetStart - percent * this.dashOffsetTotal / 2 / 100)
129 this.autoplayRing.setAttribute('stroke-dashoffset', '' + newOffset)
130 }
131
132 const tick = () => {
133 goToPercent((this.ticks++) * 100 / this.totalTicks)
134 }
135
136 const update = () => {
137 if (this.options_.suspended()) {
138 this.suspendedMessage.innerText = this.options_.suspendedText
139 goToPercent(0)
140 this.ticks = 0
141 timeout = setTimeout(update.bind(this), 300) // checks once supsended can be a bit longer
142 } else if (this.ticks >= this.totalTicks) {
143 clearTimeout(timeout)
144 cb(false)
145 } else {
146 this.suspendedMessage.innerText = ''
147 tick()
148 timeout = setTimeout(update.bind(this), this.interval)
149 }
150 }
151
152 this.container.style.display = 'block'
153 timeout = setTimeout(update.bind(this), this.interval)
154 }
155}
156
157videojs.registerComponent('EndCard', EndCard)
diff --git a/client/src/assets/player/shared/upnext/index.ts b/client/src/assets/player/shared/upnext/index.ts
new file mode 100644
index 000000000..c63c5fd83
--- /dev/null
+++ b/client/src/assets/player/shared/upnext/index.ts
@@ -0,0 +1,2 @@
1export * from './end-card'
2export * from './upnext-plugin'
diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts
new file mode 100644
index 000000000..db969024d
--- /dev/null
+++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts
@@ -0,0 +1,31 @@
1import videojs from 'video.js'
2import { EndCardOptions } from './end-card'
3
4const Plugin = videojs.getPlugin('plugin')
5
6class UpNextPlugin extends Plugin {
7
8 constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) {
9 const settings = {
10 next: options.next,
11 getTitle: options.getTitle,
12 timeout: options.timeout || 5000,
13 cancelText: options.cancelText || 'Cancel',
14 headText: options.headText || 'Up Next',
15 suspendedText: options.suspendedText || 'Autoplay is suspended',
16 condition: options.condition,
17 suspended: options.suspended
18 }
19
20 super(player)
21
22 this.player.ready(() => {
23 player.addClass('vjs-upnext')
24 })
25
26 player.addChild('EndCard', settings)
27 }
28}
29
30videojs.registerPlugin('upnext', UpNextPlugin)
31export { UpNextPlugin }
diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts
new file mode 100644
index 000000000..81378c277
--- /dev/null
+++ b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts
@@ -0,0 +1,233 @@
1// From https://github.com/MinEduTDF/idb-chunk-store
2// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
3// Thanks @santiagogil and @Feross
4
5import { EventEmitter } from 'events'
6import Dexie from 'dexie'
7
8class ChunkDatabase extends Dexie {
9 chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
10
11 constructor (dbname: string) {
12 super(dbname)
13
14 this.version(1).stores({
15 chunks: 'id'
16 })
17 }
18}
19
20class ExpirationDatabase extends Dexie {
21 databases: Dexie.Table<{ name: string, expiration: number }, number>
22
23 constructor () {
24 super('webtorrent-expiration')
25
26 this.version(1).stores({
27 databases: 'name,expiration'
28 })
29 }
30}
31
32export class PeertubeChunkStore extends EventEmitter {
33 private static readonly BUFFERING_PUT_MS = 1000
34 private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
35 private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
36
37 chunkLength: number
38
39 private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = []
40 // If the store is full
41 private memoryChunks: { [ id: number ]: Buffer | true } = {}
42 private databaseName: string
43 private putBulkTimeout: any
44 private cleanerInterval: any
45 private db: ChunkDatabase
46 private expirationDB: ExpirationDatabase
47 private readonly length: number
48 private readonly lastChunkLength: number
49 private readonly lastChunkIndex: number
50
51 constructor (chunkLength: number, opts: any) {
52 super()
53
54 this.databaseName = 'webtorrent-chunks-'
55
56 if (!opts) opts = {}
57 if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash
58 else this.databaseName += '-default'
59
60 this.setMaxListeners(100)
61
62 this.chunkLength = Number(chunkLength)
63 if (!this.chunkLength) throw new Error('First argument must be a chunk length')
64
65 this.length = Number(opts.length) || Infinity
66
67 if (this.length !== Infinity) {
68 this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
69 this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
70 }
71
72 this.db = new ChunkDatabase(this.databaseName)
73 // Track databases that expired
74 this.expirationDB = new ExpirationDatabase()
75
76 this.runCleaner()
77 }
78
79 put (index: number, buf: Buffer, cb: (err?: Error) => void) {
80 const isLastChunk = (index === this.lastChunkIndex)
81 if (isLastChunk && buf.length !== this.lastChunkLength) {
82 return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
83 }
84 if (!isLastChunk && buf.length !== this.chunkLength) {
85 return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
86 }
87
88 // Specify we have this chunk
89 this.memoryChunks[index] = true
90
91 // Add it to the pending put
92 this.pendingPut.push({ id: index, buf, cb })
93 // If it's already planned, return
94 if (this.putBulkTimeout) return
95
96 // Plan a future bulk insert
97 this.putBulkTimeout = setTimeout(async () => {
98 const processing = this.pendingPut
99 this.pendingPut = []
100 this.putBulkTimeout = undefined
101
102 try {
103 await this.db.transaction('rw', this.db.chunks, () => {
104 return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
105 })
106 } catch (err) {
107 console.log('Cannot bulk insert chunks. Store them in memory.', { err })
108
109 processing.forEach(p => {
110 this.memoryChunks[p.id] = p.buf
111 })
112 } finally {
113 processing.forEach(p => p.cb())
114 }
115 }, PeertubeChunkStore.BUFFERING_PUT_MS)
116 }
117
118 get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
119 if (typeof opts === 'function') return this.get(index, null, opts)
120
121 // IndexDB could be slow, use our memory index first
122 const memoryChunk = this.memoryChunks[index]
123 if (memoryChunk === undefined) {
124 const err = new Error('Chunk not found') as any
125 err['notFound'] = true
126
127 return process.nextTick(() => cb(err))
128 }
129
130 // Chunk in memory
131 if (memoryChunk !== true) return cb(null, memoryChunk)
132
133 // Chunk in store
134 this.db.transaction('r', this.db.chunks, async () => {
135 const result = await this.db.chunks.get({ id: index })
136 if (result === undefined) return cb(null, Buffer.alloc(0))
137
138 const buf = result.buf
139 if (!opts) return this.nextTick(cb, null, buf)
140
141 const offset = opts.offset || 0
142 const len = opts.length || (buf.length - offset)
143 return cb(null, buf.slice(offset, len + offset))
144 })
145 .catch(err => {
146 console.error(err)
147 return cb(err)
148 })
149 }
150
151 close (cb: (err?: Error) => void) {
152 return this.destroy(cb)
153 }
154
155 async destroy (cb: (err?: Error) => void) {
156 try {
157 if (this.pendingPut) {
158 clearTimeout(this.putBulkTimeout)
159 this.pendingPut = null
160 }
161 if (this.cleanerInterval) {
162 clearInterval(this.cleanerInterval)
163 this.cleanerInterval = null
164 }
165
166 if (this.db) {
167 this.db.close()
168
169 await this.dropDatabase(this.databaseName)
170 }
171
172 if (this.expirationDB) {
173 this.expirationDB.close()
174 this.expirationDB = null
175 }
176
177 return cb()
178 } catch (err) {
179 console.error('Cannot destroy peertube chunk store.', err)
180 return cb(err)
181 }
182 }
183
184 private runCleaner () {
185 this.checkExpiration()
186
187 this.cleanerInterval = setInterval(() => {
188 this.checkExpiration()
189 }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
190 }
191
192 private async checkExpiration () {
193 let databasesToDeleteInfo: { name: string }[] = []
194
195 try {
196 await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
197 // Update our database expiration since we are alive
198 await this.expirationDB.databases.put({
199 name: this.databaseName,
200 expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
201 })
202
203 const now = new Date().getTime()
204 databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
205 })
206 } catch (err) {
207 console.error('Cannot update expiration of fetch expired databases.', err)
208 }
209
210 for (const databaseToDeleteInfo of databasesToDeleteInfo) {
211 await this.dropDatabase(databaseToDeleteInfo.name)
212 }
213 }
214
215 private async dropDatabase (databaseName: string) {
216 const dbToDelete = new ChunkDatabase(databaseName)
217 console.log('Destroying IndexDB database %s.', databaseName)
218
219 try {
220 await dbToDelete.delete()
221
222 await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
223 return this.expirationDB.databases.where({ name: databaseName }).delete()
224 })
225 } catch (err) {
226 console.error('Cannot delete %s.', databaseName, err)
227 }
228 }
229
230 private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
231 process.nextTick(() => cb(err, val), undefined)
232 }
233}
diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts
new file mode 100644
index 000000000..9b80fea2c
--- /dev/null
+++ b/client/src/assets/player/shared/webtorrent/video-renderer.ts
@@ -0,0 +1,133 @@
1// Thanks: https://github.com/feross/render-media
2
3const MediaElementWrapper = require('mediasource')
4import { extname } from 'path'
5const Videostream = require('videostream')
6
7const VIDEOSTREAM_EXTS = [
8 '.m4a',
9 '.m4v',
10 '.mp4'
11]
12
13type RenderMediaOptions = {
14 controls: boolean
15 autoplay: boolean
16}
17
18function renderVideo (
19 file: any,
20 elem: HTMLVideoElement,
21 opts: RenderMediaOptions,
22 callback: (err: Error, renderer: any) => void
23) {
24 validateFile(file)
25
26 return renderMedia(file, elem, opts, callback)
27}
28
29function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) {
30 const extension = extname(file.name).toLowerCase()
31 let preparedElem: any
32 let currentTime = 0
33 let renderer: any
34
35 try {
36 if (VIDEOSTREAM_EXTS.includes(extension)) {
37 renderer = useVideostream()
38 } else {
39 renderer = useMediaSource()
40 }
41 } catch (err) {
42 return callback(err)
43 }
44
45 function useVideostream () {
46 prepareElem()
47 preparedElem.addEventListener('error', function onError (err: Error) {
48 preparedElem.removeEventListener('error', onError)
49
50 return callback(err)
51 })
52 preparedElem.addEventListener('loadstart', onLoadStart)
53 return new Videostream(file, preparedElem)
54 }
55
56 function useMediaSource (useVP9 = false) {
57 const codecs = getCodec(file.name, useVP9)
58
59 prepareElem()
60 preparedElem.addEventListener('error', function onError (err: Error) {
61 preparedElem.removeEventListener('error', onError)
62
63 // Try with vp9 before returning an error
64 if (codecs.includes('vp8')) return fallbackToMediaSource(true)
65
66 return callback(err)
67 })
68 preparedElem.addEventListener('loadstart', onLoadStart)
69
70 const wrapper = new MediaElementWrapper(preparedElem)
71 const writable = wrapper.createWriteStream(codecs)
72 file.createReadStream().pipe(writable)
73
74 if (currentTime) preparedElem.currentTime = currentTime
75
76 return wrapper
77 }
78
79 function fallbackToMediaSource (useVP9 = false) {
80 if (useVP9 === true) console.log('Falling back to media source with VP9 enabled.')
81 else console.log('Falling back to media source..')
82
83 useMediaSource(useVP9)
84 }
85
86 function prepareElem () {
87 if (preparedElem === undefined) {
88 preparedElem = elem
89
90 preparedElem.addEventListener('progress', function () {
91 currentTime = elem.currentTime
92 })
93 }
94 }
95
96 function onLoadStart () {
97 preparedElem.removeEventListener('loadstart', onLoadStart)
98 if (opts.autoplay) preparedElem.play()
99
100 callback(null, renderer)
101 }
102}
103
104function validateFile (file: any) {
105 if (file == null) {
106 throw new Error('file cannot be null or undefined')
107 }
108 if (typeof file.name !== 'string') {
109 throw new Error('missing or invalid file.name property')
110 }
111 if (typeof file.createReadStream !== 'function') {
112 throw new Error('missing or invalid file.createReadStream property')
113 }
114}
115
116function getCodec (name: string, useVP9 = false) {
117 const ext = extname(name).toLowerCase()
118 if (ext === '.mp4') {
119 return 'video/mp4; codecs="avc1.640029, mp4a.40.5"'
120 }
121
122 if (ext === '.webm') {
123 if (useVP9 === true) return 'video/webm; codecs="vp9, opus"'
124
125 return 'video/webm; codecs="vp8, vorbis"'
126 }
127
128 return undefined
129}
130
131export {
132 renderVideo
133}
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
new file mode 100644
index 000000000..b48203148
--- /dev/null
+++ b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
@@ -0,0 +1,641 @@
1import videojs from 'video.js'
2import * as WebTorrent from 'webtorrent'
3import { isIOS } from '@root-helpers/web-browser'
4import { timeToInt } from '@shared/core-utils'
5import { VideoFile } from '@shared/models'
6import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage'
7import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types'
8import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common'
9import { PeertubeChunkStore } from './peertube-chunk-store'
10import { renderVideo } from './video-renderer'
11
12const CacheChunkStore = require('cache-chunk-store')
13
14type PlayOptions = {
15 forcePlay?: boolean
16 seek?: number
17 delay?: number
18}
19
20const Plugin = videojs.getPlugin('plugin')
21
22class WebTorrentPlugin extends Plugin {
23 readonly videoFiles: VideoFile[]
24
25 private readonly playerElement: HTMLVideoElement
26
27 private readonly autoplay: boolean = false
28 private readonly startTime: number = 0
29 private readonly savePlayerSrcFunction: videojs.Player['src']
30 private readonly videoDuration: number
31 private readonly CONSTANTS = {
32 INFO_SCHEDULER: 1000, // Don't change this
33 AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
34 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
35 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
36 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
37 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
38 }
39
40 private readonly webtorrent = new WebTorrent({
41 tracker: {
42 rtcConfig: getRtcConfig()
43 },
44 dht: false
45 })
46
47 private currentVideoFile: VideoFile
48 private torrent: WebTorrent.Torrent
49
50 private renderer: any
51 private fakeRenderer: any
52 private destroyingFakeRenderer = false
53
54 private autoResolution = true
55 private autoResolutionPossible = true
56 private isAutoResolutionObservation = false
57 private playerRefusedP2P = false
58
59 private torrentInfoInterval: any
60 private autoQualityInterval: any
61 private addTorrentDelay: any
62 private qualityObservationTimer: any
63 private runAutoQualitySchedulerTimer: any
64
65 private downloadSpeeds: number[] = []
66
67 constructor (player: videojs.Player, options?: WebtorrentPluginOptions) {
68 super(player)
69
70 this.startTime = timeToInt(options.startTime)
71
72 // Custom autoplay handled by webtorrent because we lazy play the video
73 this.autoplay = options.autoplay
74
75 this.playerRefusedP2P = options.playerRefusedP2P
76
77 this.videoFiles = options.videoFiles
78 this.videoDuration = options.videoDuration
79
80 this.savePlayerSrcFunction = this.player.src
81 this.playerElement = options.playerElement
82
83 this.player.ready(() => {
84 const playerOptions = this.player.options_
85
86 const volume = getStoredVolume()
87 if (volume !== undefined) this.player.volume(volume)
88
89 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
90 if (muted !== undefined) this.player.muted(muted)
91
92 this.player.duration(options.videoDuration)
93
94 this.initializePlayer()
95 this.runTorrentInfoScheduler()
96
97 this.player.one('play', () => {
98 // Don't run immediately scheduler, wait some seconds the TCP connections are made
99 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
100 })
101 })
102 }
103
104 dispose () {
105 clearTimeout(this.addTorrentDelay)
106 clearTimeout(this.qualityObservationTimer)
107 clearTimeout(this.runAutoQualitySchedulerTimer)
108
109 clearInterval(this.torrentInfoInterval)
110 clearInterval(this.autoQualityInterval)
111
112 // Don't need to destroy renderer, video player will be destroyed
113 this.flushVideoFile(this.currentVideoFile, false)
114
115 this.destroyFakeRenderer()
116 }
117
118 getCurrentResolutionId () {
119 return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
120 }
121
122 updateVideoFile (
123 videoFile?: VideoFile,
124 options: {
125 forcePlay?: boolean
126 seek?: number
127 delay?: number
128 } = {},
129 done: () => void = () => { /* empty */ }
130 ) {
131 // Automatically choose the adapted video file
132 if (!videoFile) {
133 const savedAverageBandwidth = getAverageBandwidthInStore()
134 videoFile = savedAverageBandwidth
135 ? this.getAppropriateFile(savedAverageBandwidth)
136 : this.pickAverageVideoFile()
137 }
138
139 if (!videoFile) {
140 throw Error(`Can't update video file since videoFile is undefined.`)
141 }
142
143 // Don't add the same video file once again
144 if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
145 return
146 }
147
148 // Do not display error to user because we will have multiple fallback
149 this.player.peertube().hideFatalError();
150
151 // Hack to "simulate" src link in video.js >= 6
152 // Without this, we can't play the video after pausing it
153 // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
154 (this.player as any).src = () => true
155 const oldPlaybackRate = this.player.playbackRate()
156
157 const previousVideoFile = this.currentVideoFile
158 this.currentVideoFile = videoFile
159
160 // Don't try on iOS that does not support MediaSource
161 // Or don't use P2P if webtorrent is disabled
162 if (isIOS() || this.playerRefusedP2P) {
163 return this.fallbackToHttp(options, () => {
164 this.player.playbackRate(oldPlaybackRate)
165 return done()
166 })
167 }
168
169 this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
170 this.player.playbackRate(oldPlaybackRate)
171 return done()
172 })
173
174 this.selectAppropriateResolution(true)
175 }
176
177 updateEngineResolution (resolutionId: number, delay = 0) {
178 // Remember player state
179 const currentTime = this.player.currentTime()
180 const isPaused = this.player.paused()
181
182 // Hide bigPlayButton
183 if (!isPaused) {
184 this.player.bigPlayButton.hide()
185 }
186
187 // Audio-only (resolutionId === 0) gets special treatment
188 if (resolutionId === 0) {
189 // Audio-only: show poster, do not auto-hide controls
190 this.player.addClass('vjs-playing-audio-only-content')
191 this.player.posterImage.show()
192 } else {
193 // Hide poster to have black background
194 this.player.removeClass('vjs-playing-audio-only-content')
195 this.player.posterImage.hide()
196 }
197
198 const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
199 const options = {
200 forcePlay: false,
201 delay,
202 seek: currentTime + (delay / 1000)
203 }
204
205 this.updateVideoFile(newVideoFile, options)
206 }
207
208 flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
209 if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
210 if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
211
212 this.webtorrent.remove(videoFile.magnetUri)
213 console.log('Removed ' + videoFile.magnetUri)
214 }
215 }
216
217 disableAutoResolution () {
218 this.autoResolution = false
219 this.autoResolutionPossible = false
220 this.player.peertubeResolutions().disableAutoResolution()
221 }
222
223 isAutoResolutionPossible () {
224 return this.autoResolutionPossible
225 }
226
227 getTorrent () {
228 return this.torrent
229 }
230
231 getCurrentVideoFile () {
232 return this.currentVideoFile
233 }
234
235 changeQuality (id: number) {
236 if (id === -1) {
237 if (this.autoResolutionPossible === true) {
238 this.autoResolution = true
239
240 this.selectAppropriateResolution(false)
241 }
242
243 return
244 }
245
246 this.autoResolution = false
247 this.updateEngineResolution(id)
248 this.selectAppropriateResolution(false)
249 }
250
251 private addTorrent (
252 magnetOrTorrentUrl: string,
253 previousVideoFile: VideoFile,
254 options: PlayOptions,
255 done: (err?: Error) => void
256 ) {
257 if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done)
258
259 console.log('Adding ' + magnetOrTorrentUrl + '.')
260
261 const oldTorrent = this.torrent
262 const torrentOptions = {
263 // Don't use arrow function: it breaks webtorrent (that uses `new` keyword)
264 store: function (chunkLength: number, storeOpts: any) {
265 return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
266 max: 100
267 })
268 }
269 }
270
271 this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
272 console.log('Added ' + magnetOrTorrentUrl + '.')
273
274 if (oldTorrent) {
275 // Pause the old torrent
276 this.stopTorrent(oldTorrent)
277
278 // We use a fake renderer so we download correct pieces of the next file
279 if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay)
280 }
281
282 // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
283 this.addTorrentDelay = setTimeout(() => {
284 // We don't need the fake renderer anymore
285 this.destroyFakeRenderer()
286
287 const paused = this.player.paused()
288
289 this.flushVideoFile(previousVideoFile)
290
291 // Update progress bar (just for the UI), do not wait rendering
292 if (options.seek) this.player.currentTime(options.seek)
293
294 const renderVideoOptions = { autoplay: false, controls: true }
295 renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => {
296 this.renderer = renderer
297
298 if (err) return this.fallbackToHttp(options, done)
299
300 return this.tryToPlay(err => {
301 if (err) return done(err)
302
303 if (options.seek) this.seek(options.seek)
304 if (options.forcePlay === false && paused === true) this.player.pause()
305
306 return done()
307 })
308 })
309 }, options.delay || 0)
310 })
311
312 this.torrent.on('error', (err: any) => console.error(err))
313
314 this.torrent.on('warning', (err: any) => {
315 // We don't support HTTP tracker but we don't care -> we use the web socket tracker
316 if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
317
318 // Users don't care about issues with WebRTC, but developers do so log it in the console
319 if (err.message.indexOf('Ice connection failed') !== -1) {
320 console.log(err)
321 return
322 }
323
324 // Magnet hash is not up to date with the torrent file, add directly the torrent file
325 if (err.message.indexOf('incorrect info hash') !== -1) {
326 console.error('Incorrect info hash detected, falling back to torrent file.')
327 const newOptions = { forcePlay: true, seek: options.seek }
328 return this.addTorrent(this.torrent['xs'], previousVideoFile, newOptions, done)
329 }
330
331 // Remote instance is down
332 if (err.message.indexOf('from xs param') !== -1) {
333 this.handleError(err)
334 }
335
336 console.warn(err)
337 })
338 }
339
340 private tryToPlay (done?: (err?: Error) => void) {
341 if (!done) done = function () { /* empty */ }
342
343 const playPromise = this.player.play()
344 if (playPromise !== undefined) {
345 return playPromise.then(() => done())
346 .catch((err: Error) => {
347 if (err.message.includes('The play() request was interrupted by a call to pause()')) {
348 return
349 }
350
351 console.error(err)
352 this.player.pause()
353 this.player.posterImage.show()
354 this.player.removeClass('vjs-has-autoplay')
355 this.player.removeClass('vjs-has-big-play-button-clicked')
356 this.player.removeClass('vjs-playing-audio-only-content')
357
358 return done()
359 })
360 }
361
362 return done()
363 }
364
365 private seek (time: number) {
366 this.player.currentTime(time)
367 this.player.handleTechSeeked_()
368 }
369
370 private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
371 if (this.videoFiles === undefined) return undefined
372 if (this.videoFiles.length === 1) return this.videoFiles[0]
373
374 const files = this.videoFiles.filter(f => f.resolution.id !== 0)
375 if (files.length === 0) return undefined
376
377 // Don't change the torrent if the player ended
378 if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
379
380 if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
381
382 // Limit resolution according to player height
383 const playerHeight = this.playerElement.offsetHeight
384
385 // We take the first resolution just above the player height
386 // Example: player height is 530px, we want the 720p file instead of 480p
387 let maxResolution = files[0].resolution.id
388 for (let i = files.length - 1; i >= 0; i--) {
389 const resolutionId = files[i].resolution.id
390 if (resolutionId !== 0 && resolutionId >= playerHeight) {
391 maxResolution = resolutionId
392 break
393 }
394 }
395
396 // Filter videos we can play according to our screen resolution and bandwidth
397 const filteredFiles = files.filter(f => f.resolution.id <= maxResolution)
398 .filter(f => {
399 const fileBitrate = (f.size / this.videoDuration)
400 let threshold = fileBitrate
401
402 // If this is for a higher resolution or an initial load: add a margin
403 if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
404 threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
405 }
406
407 return averageDownloadSpeed > threshold
408 })
409
410 // If the download speed is too bad, return the lowest resolution we have
411 if (filteredFiles.length === 0) return videoFileMinByResolution(files)
412
413 return videoFileMaxByResolution(filteredFiles)
414 }
415
416 private getAndSaveActualDownloadSpeed () {
417 const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
418 const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
419 if (lastDownloadSpeeds.length === 0) return -1
420
421 const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
422 const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
423
424 // Save the average bandwidth for future use
425 saveAverageBandwidth(averageBandwidth)
426
427 return averageBandwidth
428 }
429
430 private initializePlayer () {
431 this.buildQualities()
432
433 if (this.autoplay) {
434 this.player.posterImage.hide()
435
436 return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
437 }
438
439 // Proxy first play
440 const oldPlay = this.player.play.bind(this.player);
441 (this.player as any).play = () => {
442 this.player.addClass('vjs-has-big-play-button-clicked')
443 this.player.play = oldPlay
444
445 this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
446 }
447 }
448
449 private runAutoQualityScheduler () {
450 this.autoQualityInterval = setInterval(() => {
451
452 // Not initialized or in HTTP fallback
453 if (this.torrent === undefined || this.torrent === null) return
454 if (this.autoResolution === false) return
455 if (this.isAutoResolutionObservation === true) return
456
457 const file = this.getAppropriateFile()
458 let changeResolution = false
459 let changeResolutionDelay = 0
460
461 // Lower resolution
462 if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
463 console.log('Downgrading automatically the resolution to: %s', file.resolution.label)
464 changeResolution = true
465 } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
466 console.log('Upgrading automatically the resolution to: %s', file.resolution.label)
467 changeResolution = true
468 changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
469 }
470
471 if (changeResolution === true) {
472 this.updateEngineResolution(file.resolution.id, changeResolutionDelay)
473
474 // Wait some seconds in observation of our new resolution
475 this.isAutoResolutionObservation = true
476
477 this.qualityObservationTimer = setTimeout(() => {
478 this.isAutoResolutionObservation = false
479 }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
480 }
481 }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
482 }
483
484 private isPlayerWaiting () {
485 return this.player?.hasClass('vjs-waiting')
486 }
487
488 private runTorrentInfoScheduler () {
489 this.torrentInfoInterval = setInterval(() => {
490 // Not initialized yet
491 if (this.torrent === undefined) return
492
493 // Http fallback
494 if (this.torrent === null) return this.player.trigger('p2pInfo', false)
495
496 // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
497 if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
498
499 return this.player.trigger('p2pInfo', {
500 source: 'webtorrent',
501 http: {
502 downloadSpeed: 0,
503 uploadSpeed: 0,
504 downloaded: 0,
505 uploaded: 0
506 },
507 p2p: {
508 downloadSpeed: this.torrent.downloadSpeed,
509 numPeers: this.torrent.numPeers,
510 uploadSpeed: this.torrent.uploadSpeed,
511 downloaded: this.torrent.downloaded,
512 uploaded: this.torrent.uploaded
513 },
514 bandwidthEstimate: this.webtorrent.downloadSpeed
515 } as PlayerNetworkInfo)
516 }, this.CONSTANTS.INFO_SCHEDULER)
517 }
518
519 private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) {
520 const paused = this.player.paused()
521
522 this.disableAutoResolution()
523
524 this.flushVideoFile(this.currentVideoFile, true)
525 this.torrent = null
526
527 // Enable error display now this is our last fallback
528 this.player.one('error', () => this.player.peertube().displayFatalError())
529
530 const httpUrl = this.currentVideoFile.fileUrl
531 this.player.src = this.savePlayerSrcFunction
532 this.player.src(httpUrl)
533
534 this.selectAppropriateResolution(true)
535
536 // We changed the source, so reinit captions
537 this.player.trigger('sourcechange')
538
539 return this.tryToPlay(err => {
540 if (err && done) return done(err)
541
542 if (options.seek) this.seek(options.seek)
543 if (options.forcePlay === false && paused === true) this.player.pause()
544
545 if (done) return done()
546 })
547 }
548
549 private handleError (err: Error | string) {
550 return this.player.trigger('customError', { err })
551 }
552
553 private pickAverageVideoFile () {
554 if (this.videoFiles.length === 1) return this.videoFiles[0]
555
556 const files = this.videoFiles.filter(f => f.resolution.id !== 0)
557 return files[Math.floor(files.length / 2)]
558 }
559
560 private stopTorrent (torrent: WebTorrent.Torrent) {
561 torrent.pause()
562 // Pause does not remove actual peers (in particular the webseed peer)
563 torrent.removePeer(torrent['ws'])
564 }
565
566 private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
567 this.destroyingFakeRenderer = false
568
569 const fakeVideoElem = document.createElement('video')
570 renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
571 this.fakeRenderer = renderer
572
573 // The renderer returns an error when we destroy it, so skip them
574 if (this.destroyingFakeRenderer === false && err) {
575 console.error('Cannot render new torrent in fake video element.', err)
576 }
577
578 // Load the future file at the correct time (in delay MS - 2 seconds)
579 fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
580 })
581 }
582
583 private destroyFakeRenderer () {
584 if (this.fakeRenderer) {
585 this.destroyingFakeRenderer = true
586
587 if (this.fakeRenderer.destroy) {
588 try {
589 this.fakeRenderer.destroy()
590 } catch (err) {
591 console.log('Cannot destroy correctly fake renderer.', err)
592 }
593 }
594 this.fakeRenderer = undefined
595 }
596 }
597
598 private buildQualities () {
599 const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({
600 id: file.resolution.id,
601 label: this.buildQualityLabel(file),
602 height: file.resolution.id,
603 selected: false,
604 selectCallback: () => this.changeQuality(file.resolution.id)
605 }))
606
607 resolutions.push({
608 id: -1,
609 label: this.player.localize('Auto'),
610 selected: true,
611 selectCallback: () => this.changeQuality(-1)
612 })
613
614 this.player.peertubeResolutions().add(resolutions)
615 }
616
617 private buildQualityLabel (file: VideoFile) {
618 let label = file.resolution.label
619
620 if (file.fps && file.fps >= 50) {
621 label += file.fps
622 }
623
624 return label
625 }
626
627 private selectAppropriateResolution (byEngine: boolean) {
628 const resolution = this.autoResolution
629 ? -1
630 : this.getCurrentResolutionId()
631
632 const autoResolutionChosen = this.autoResolution
633 ? this.getCurrentResolutionId()
634 : undefined
635
636 this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine })
637 }
638}
639
640videojs.registerPlugin('webtorrent', WebTorrentPlugin)
641export { WebTorrentPlugin }