diff options
Diffstat (limited to 'client/src/assets/player/shared')
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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import './pause-bezel' | ||
3 | |||
4 | const Plugin = videojs.getPlugin('plugin') | ||
5 | |||
6 | class 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 | |||
19 | videojs.registerPlugin('bezels', BezelsPlugin) | ||
20 | |||
21 | export { 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 @@ | |||
1 | export * from './bezels-plugin' | ||
2 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { isMobile } from '@root-helpers/web-browser' | ||
3 | |||
4 | function 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 | |||
19 | function 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 | |||
34 | const Component = videojs.getComponent('Component') | ||
35 | class 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 | |||
76 | videojs.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 @@ | |||
1 | import { VideoFile } from '@shared/models' | ||
2 | |||
3 | function 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 | ||
9 | const 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 | ] | ||
15 | function 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 | |||
22 | function 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 | |||
33 | function 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 | |||
44 | function 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 | |||
59 | export { | ||
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 @@ | |||
1 | export * from './next-previous-video-button' | ||
2 | export * from './p2p-info-button' | ||
3 | export * from './peertube-link-button' | ||
4 | export * from './peertube-load-progress-bar' | ||
5 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { NextPreviousVideoButtonOptions } from '../../types' | ||
3 | |||
4 | const Button = videojs.getComponent('Button') | ||
5 | |||
6 | class 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 | |||
49 | videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) | ||
50 | videojs.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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' | ||
3 | import { bytes } from '../common' | ||
4 | |||
5 | const Button = videojs.getComponent('Button') | ||
6 | class 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 | |||
124 | videojs.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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' | ||
3 | import { PeerTubeLinkButtonOptions } from '../../types' | ||
4 | |||
5 | const Button = videojs.getComponent('Button') | ||
6 | class 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 | |||
45 | videojs.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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class 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 | |||
33 | Component.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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' | ||
3 | |||
4 | const Button = videojs.getComponent('Button') | ||
5 | class 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 | |||
53 | videojs.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 @@ | |||
1 | export * from './peertube-dock-component' | ||
2 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | export type PeerTubeDockComponentOptions = { | ||
6 | title?: string | ||
7 | description?: string | ||
8 | avatarUrl?: string | ||
9 | } | ||
10 | |||
11 | class 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 | |||
61 | videojs.registerComponent('PeerTubeDockComponent', PeerTubeDockComponent) | ||
62 | |||
63 | export { | ||
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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { PeerTubeDockComponent } from './peertube-dock-component' | ||
3 | |||
4 | const Plugin = videojs.getPlugin('plugin') | ||
5 | |||
6 | export type PeerTubeDockPluginOptions = { | ||
7 | title?: string | ||
8 | description?: string | ||
9 | avatarUrl?: string | ||
10 | } | ||
11 | |||
12 | class 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 | |||
24 | videojs.registerPlugin('peertubeDock', PeerTubeDockPlugin) | ||
25 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardEvent) => void } | ||
4 | |||
5 | const Plugin = videojs.getPlugin('plugin') | ||
6 | |||
7 | class 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 | |||
195 | videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) | ||
196 | export { 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 @@ | |||
1 | import { | ||
2 | CommonOptions, | ||
3 | NextPreviousVideoButtonOptions, | ||
4 | PeerTubeLinkButtonOptions, | ||
5 | PeertubePlayerManagerOptions, | ||
6 | PlayerMode | ||
7 | } from '../../types' | ||
8 | |||
9 | export 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 @@ | |||
1 | import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core' | ||
2 | import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' | ||
3 | import { LiveVideoLatencyMode } from '@shared/models' | ||
4 | import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' | ||
5 | import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' | ||
6 | import { PeertubePlayerManagerOptions } from '../../types/manager-options' | ||
7 | import { getRtcConfig } from '../common' | ||
8 | import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' | ||
9 | import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' | ||
10 | import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' | ||
11 | |||
12 | export 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { copyToClipboard } from '@root-helpers/utils' | ||
3 | import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' | ||
4 | import { isIOS, isSafari } from '@root-helpers/web-browser' | ||
5 | import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' | ||
6 | import { isDefaultLocale } from '@shared/core-utils/i18n' | ||
7 | import { VideoJSPluginOptions } from '../../types' | ||
8 | import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options' | ||
9 | import { ControlBarOptionsBuilder } from './control-bar-options-builder' | ||
10 | import { HLSOptionsBuilder } from './hls-options-builder' | ||
11 | import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' | ||
12 | |||
13 | export 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 @@ | |||
1 | import { PeertubePlayerManagerOptions } from '../../types' | ||
2 | |||
3 | export 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 @@ | |||
1 | export * from './peertube-mobile-buttons' | ||
2 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | class 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 | |||
90 | videojs.registerComponent('PeerTubeMobileButtons', PeerTubeMobileButtons) | ||
91 | |||
92 | export { | ||
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 @@ | |||
1 | import { PeerTubeMobileButtons } from './peertube-mobile-buttons' | ||
2 | import videojs from 'video.js' | ||
3 | import debug from 'debug' | ||
4 | |||
5 | const logger = debug('peertube:player:mobile') | ||
6 | |||
7 | const Plugin = videojs.getPlugin('plugin') | ||
8 | |||
9 | class 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 | |||
154 | videojs.registerPlugin('peertubeMobile', PeerTubeMobilePlugin) | ||
155 | export { 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 | |||
4 | import Hlsjs, { ErrorData, HlsConfig, Level, LevelSwitchingData, ManifestParsedData } from 'hls.js' | ||
5 | import videojs from 'video.js' | ||
6 | import { HlsjsConfigHandlerOptions, PeerTubeResolution, VideoJSTechHLS } from '../../types' | ||
7 | |||
8 | type ErrorCounts = { | ||
9 | [ type: string ]: number | ||
10 | } | ||
11 | |||
12 | type Metadata = { | ||
13 | levels: Level[] | ||
14 | } | ||
15 | |||
16 | type HookFn = (player: videojs.Player, hljs: Hlsjs) => void | ||
17 | |||
18 | const 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 | |||
58 | function 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 | |||
76 | const 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 | |||
82 | class 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 | |||
415 | export { | ||
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 @@ | |||
1 | export * from './hls-plugin' | ||
2 | export * from './p2p-media-loader-plugin' | ||
3 | export * from './redundancy-url-manager' | ||
4 | export * from './segment-url-builder' | ||
5 | export * 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 @@ | |||
1 | import Hlsjs from 'hls.js' | ||
2 | import videojs from 'video.js' | ||
3 | import { Events, Segment } from '@peertube/p2p-media-loader-core' | ||
4 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' | ||
5 | import { timeToInt } from '@shared/core-utils' | ||
6 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' | ||
7 | import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' | ||
8 | |||
9 | registerConfigPlugin(videojs) | ||
10 | registerSourceHandler(videojs) | ||
11 | |||
12 | const Plugin = videojs.getPlugin('plugin') | ||
13 | class 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 | |||
182 | videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin) | ||
183 | export { 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 @@ | |||
1 | import { basename, dirname } from 'path' | ||
2 | |||
3 | class 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 | |||
40 | export { | ||
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 @@ | |||
1 | import { Segment } from '@peertube/p2p-media-loader-core' | ||
2 | import { RedundancyUrlManager } from './redundancy-url-manager' | ||
3 | |||
4 | function 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 | |||
15 | export { | ||
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 @@ | |||
1 | import { wait } from '@root-helpers/utils' | ||
2 | import { Segment } from '@peertube/p2p-media-loader-core' | ||
3 | import { basename } from 'path' | ||
4 | |||
5 | type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } } | ||
6 | |||
7 | const maxRetries = 3 | ||
8 | |||
9 | function 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 | |||
64 | export { | ||
65 | segmentValidatorFactory | ||
66 | } | ||
67 | |||
68 | // --------------------------------------------------------------------------- | ||
69 | |||
70 | function 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 | |||
79 | async 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 | ||
94 | function 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 @@ | |||
1 | import debug from 'debug' | ||
2 | import videojs from 'video.js' | ||
3 | import { isMobile } from '@root-helpers/web-browser' | ||
4 | import { timeToInt } from '@shared/core-utils' | ||
5 | import { | ||
6 | getStoredLastSubtitle, | ||
7 | getStoredMute, | ||
8 | getStoredVolume, | ||
9 | saveLastSubtitle, | ||
10 | saveMuteInStore, | ||
11 | saveVideoWatchHistory, | ||
12 | saveVolumeInStore | ||
13 | } from '../../peertube-player-local-storage' | ||
14 | import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types' | ||
15 | import { SettingsButton } from '../settings/settings-menu-button' | ||
16 | |||
17 | const logger = debug('peertube:player:peertube') | ||
18 | |||
19 | const Plugin = videojs.getPlugin('plugin') | ||
20 | |||
21 | class 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 | |||
301 | videojs.registerPlugin('peertube', PeerTubePlugin) | ||
302 | export { 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 @@ | |||
1 | export * from './playlist-button' | ||
2 | export * from './playlist-menu-item' | ||
3 | export * from './playlist-menu' | ||
4 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { PlaylistPluginOptions } from '../../types' | ||
3 | import { PlaylistMenu } from './playlist-menu' | ||
4 | |||
5 | const ClickableComponent = videojs.getComponent('ClickableComponent') | ||
6 | |||
7 | class 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 | |||
59 | videojs.registerComponent('PlaylistButton', PlaylistButton) | ||
60 | |||
61 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { secondsToTime } from '@shared/core-utils' | ||
3 | import { VideoPlaylistElement } from '@shared/models' | ||
4 | import { PlaylistItemOptions } from '../../types' | ||
5 | |||
6 | const Component = videojs.getComponent('Component') | ||
7 | |||
8 | class 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 | |||
134 | Component.registerComponent('PlaylistMenuItem', PlaylistMenuItem) | ||
135 | |||
136 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { VideoPlaylistElement } from '@shared/models' | ||
3 | import { PlaylistPluginOptions } from '../../types' | ||
4 | import { PlaylistMenuItem } from './playlist-menu-item' | ||
5 | |||
6 | const Component = videojs.getComponent('Component') | ||
7 | |||
8 | class 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 | |||
135 | Component.registerComponent('PlaylistMenu', PlaylistMenu) | ||
136 | |||
137 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { PlaylistPluginOptions } from '../../types' | ||
3 | import { PlaylistButton } from './playlist-button' | ||
4 | import { PlaylistMenu } from './playlist-menu' | ||
5 | |||
6 | const Plugin = videojs.getPlugin('plugin') | ||
7 | |||
8 | class 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 | |||
34 | videojs.registerPlugin('playlist', PlaylistPlugin) | ||
35 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { PeerTubeResolution } from '../../types' | ||
3 | |||
4 | const Plugin = videojs.getPlugin('plugin') | ||
5 | |||
6 | class 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 | |||
87 | videojs.registerPlugin('peertubeResolutions', PeerTubeResolutionsPlugin) | ||
88 | export { 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 @@ | |||
1 | export * from './resolution-menu-button' | ||
2 | export * from './resolution-menu-item' | ||
3 | export * from './settings-dialog' | ||
4 | export * from './settings-menu-button' | ||
5 | export * from './settings-menu-item' | ||
6 | export * from './settings-panel-child' | ||
7 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { ResolutionMenuItem } from './resolution-menu-item' | ||
3 | |||
4 | const Menu = videojs.getComponent('Menu') | ||
5 | const MenuButton = videojs.getComponent('MenuButton') | ||
6 | class 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 | |||
86 | videojs.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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const MenuItem = videojs.getComponent('MenuItem') | ||
4 | |||
5 | export interface ResolutionMenuItemOptions extends videojs.MenuItemOptions { | ||
6 | id: number | ||
7 | } | ||
8 | |||
9 | class 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 | } | ||
75 | videojs.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
76 | |||
77 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class 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 | |||
33 | Component.registerComponent('SettingsDialog', SettingsDialog) | ||
34 | |||
35 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { toTitleCase } from '../common' | ||
3 | import { SettingsDialog } from './settings-dialog' | ||
4 | import { SettingsMenuItem } from './settings-menu-item' | ||
5 | import { SettingsPanel } from './settings-panel' | ||
6 | import { SettingsPanelChild } from './settings-panel-child' | ||
7 | |||
8 | const Button = videojs.getComponent('Button') | ||
9 | const Menu = videojs.getComponent('Menu') | ||
10 | const Component = videojs.getComponent('Component') | ||
11 | |||
12 | export interface SettingsButtonOptions extends videojs.ComponentOptions { | ||
13 | entries: any[] | ||
14 | setup?: { | ||
15 | maxHeightOffset: number | ||
16 | } | ||
17 | } | ||
18 | |||
19 | class 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 | |||
275 | Component.registerComponent('SettingsButton', SettingsButton) | ||
276 | |||
277 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { toTitleCase } from '../common' | ||
3 | import { SettingsDialog } from './settings-dialog' | ||
4 | import { SettingsButton } from './settings-menu-button' | ||
5 | import { SettingsPanel } from './settings-panel' | ||
6 | import { SettingsPanelChild } from './settings-panel-child' | ||
7 | |||
8 | const MenuItem = videojs.getComponent('MenuItem') | ||
9 | const component = videojs.getComponent('Component') | ||
10 | |||
11 | export interface SettingsMenuItemOptions extends videojs.MenuItemOptions { | ||
12 | entry: string | ||
13 | menuButton: SettingsButton | ||
14 | } | ||
15 | |||
16 | class 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' | ||
375 | videojs.registerComponent('SettingsMenuItem', SettingsMenuItem) | ||
376 | |||
377 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class 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 | |||
16 | Component.registerComponent('SettingsPanelChild', SettingsPanelChild) | ||
17 | |||
18 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class 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 | |||
16 | Component.registerComponent('SettingsPanel', SettingsPanel) | ||
17 | |||
18 | export { 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 @@ | |||
1 | export * from './stats-card' | ||
2 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { secondsToTime } from '@shared/core-utils' | ||
3 | import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types' | ||
4 | import { bytes } from '../common' | ||
5 | |||
6 | interface StatsCardOptions extends videojs.ComponentOptions { | ||
7 | videoUUID: string | ||
8 | videoIsLive: boolean | ||
9 | mode: 'webtorrent' | 'p2p-media-loader' | ||
10 | p2pEnabled: boolean | ||
11 | } | ||
12 | |||
13 | interface 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 | |||
25 | const Component = videojs.getComponent('Component') | ||
26 | class 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} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` | ||
198 | : undefined | ||
199 | |||
200 | const totalTransferred = playerNetworkInfo.totalDownloaded | ||
201 | ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` | ||
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 | |||
266 | videojs.registerComponent('StatsCard', StatsCard) | ||
267 | |||
268 | export { | ||
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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { StatsCard, StatsCardOptions } from './stats-card' | ||
3 | |||
4 | const Plugin = videojs.getPlugin('plugin') | ||
5 | |||
6 | class 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 | |||
30 | videojs.registerPlugin('stats', StatsForNerdsPlugin) | ||
31 | export { 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | function 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 | |||
26 | export 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 | |||
37 | const Component = videojs.getComponent('Component') | ||
38 | class 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 | |||
157 | videojs.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 @@ | |||
1 | export * from './end-card' | ||
2 | export * 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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { EndCardOptions } from './end-card' | ||
3 | |||
4 | const Plugin = videojs.getPlugin('plugin') | ||
5 | |||
6 | class 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 | |||
30 | videojs.registerPlugin('upnext', UpNextPlugin) | ||
31 | export { 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 | |||
5 | import { EventEmitter } from 'events' | ||
6 | import Dexie from 'dexie' | ||
7 | |||
8 | class 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 | |||
20 | class 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 | |||
32 | export 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 | |||
3 | const MediaElementWrapper = require('mediasource') | ||
4 | import { extname } from 'path' | ||
5 | const Videostream = require('videostream') | ||
6 | |||
7 | const VIDEOSTREAM_EXTS = [ | ||
8 | '.m4a', | ||
9 | '.m4v', | ||
10 | '.mp4' | ||
11 | ] | ||
12 | |||
13 | type RenderMediaOptions = { | ||
14 | controls: boolean | ||
15 | autoplay: boolean | ||
16 | } | ||
17 | |||
18 | function 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 | |||
29 | function 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 | |||
104 | function 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 | |||
116 | function 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 | |||
131 | export { | ||
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 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import * as WebTorrent from 'webtorrent' | ||
3 | import { isIOS } from '@root-helpers/web-browser' | ||
4 | import { timeToInt } from '@shared/core-utils' | ||
5 | import { VideoFile } from '@shared/models' | ||
6 | import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' | ||
7 | import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' | ||
8 | import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common' | ||
9 | import { PeertubeChunkStore } from './peertube-chunk-store' | ||
10 | import { renderVideo } from './video-renderer' | ||
11 | |||
12 | const CacheChunkStore = require('cache-chunk-store') | ||
13 | |||
14 | type PlayOptions = { | ||
15 | forcePlay?: boolean | ||
16 | seek?: number | ||
17 | delay?: number | ||
18 | } | ||
19 | |||
20 | const Plugin = videojs.getPlugin('plugin') | ||
21 | |||
22 | class 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 | |||
640 | videojs.registerPlugin('webtorrent', WebTorrentPlugin) | ||
641 | export { WebTorrentPlugin } | ||