diff options
author | Chocobozzz <me@florianbigard.com> | 2019-01-23 15:36:45 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2019-02-11 09:13:02 +0100 |
commit | 2adfc7ea9a1f858db874df9fe322e7ae833db77c (patch) | |
tree | e27c6ebe01b7c96ea0e053839a38fc1f824d1284 /client/src/assets/player/videojs-components | |
parent | 7eeb6a0ba4028d0e20847b846332dd0b7747c7f8 (diff) | |
download | PeerTube-2adfc7ea9a1f858db874df9fe322e7ae833db77c.tar.gz PeerTube-2adfc7ea9a1f858db874df9fe322e7ae833db77c.tar.zst PeerTube-2adfc7ea9a1f858db874df9fe322e7ae833db77c.zip |
Refractor videojs player
Add fake p2p-media-loader plugin
Diffstat (limited to 'client/src/assets/player/videojs-components')
8 files changed, 1018 insertions, 0 deletions
diff --git a/client/src/assets/player/videojs-components/p2p-info-button.ts b/client/src/assets/player/videojs-components/p2p-info-button.ts new file mode 100644 index 000000000..03a5d29f0 --- /dev/null +++ b/client/src/assets/player/videojs-components/p2p-info-button.ts | |||
@@ -0,0 +1,102 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
2 | import { bytes } from '../utils' | ||
3 | |||
4 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
5 | class P2pInfoButton extends Button { | ||
6 | |||
7 | createEl () { | ||
8 | const div = videojsUntyped.dom.createEl('div', { | ||
9 | className: 'vjs-peertube' | ||
10 | }) | ||
11 | const subDivWebtorrent = videojsUntyped.dom.createEl('div', { | ||
12 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info | ||
13 | }) | ||
14 | div.appendChild(subDivWebtorrent) | ||
15 | |||
16 | const downloadIcon = videojsUntyped.dom.createEl('span', { | ||
17 | className: 'icon icon-download' | ||
18 | }) | ||
19 | subDivWebtorrent.appendChild(downloadIcon) | ||
20 | |||
21 | const downloadSpeedText = videojsUntyped.dom.createEl('span', { | ||
22 | className: 'download-speed-text' | ||
23 | }) | ||
24 | const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { | ||
25 | className: 'download-speed-number' | ||
26 | }) | ||
27 | const downloadSpeedUnit = videojsUntyped.dom.createEl('span') | ||
28 | downloadSpeedText.appendChild(downloadSpeedNumber) | ||
29 | downloadSpeedText.appendChild(downloadSpeedUnit) | ||
30 | subDivWebtorrent.appendChild(downloadSpeedText) | ||
31 | |||
32 | const uploadIcon = videojsUntyped.dom.createEl('span', { | ||
33 | className: 'icon icon-upload' | ||
34 | }) | ||
35 | subDivWebtorrent.appendChild(uploadIcon) | ||
36 | |||
37 | const uploadSpeedText = videojsUntyped.dom.createEl('span', { | ||
38 | className: 'upload-speed-text' | ||
39 | }) | ||
40 | const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { | ||
41 | className: 'upload-speed-number' | ||
42 | }) | ||
43 | const uploadSpeedUnit = videojsUntyped.dom.createEl('span') | ||
44 | uploadSpeedText.appendChild(uploadSpeedNumber) | ||
45 | uploadSpeedText.appendChild(uploadSpeedUnit) | ||
46 | subDivWebtorrent.appendChild(uploadSpeedText) | ||
47 | |||
48 | const peersText = videojsUntyped.dom.createEl('span', { | ||
49 | className: 'peers-text' | ||
50 | }) | ||
51 | const peersNumber = videojsUntyped.dom.createEl('span', { | ||
52 | className: 'peers-number' | ||
53 | }) | ||
54 | subDivWebtorrent.appendChild(peersNumber) | ||
55 | subDivWebtorrent.appendChild(peersText) | ||
56 | |||
57 | const subDivHttp = videojsUntyped.dom.createEl('div', { | ||
58 | className: 'vjs-peertube-hidden' | ||
59 | }) | ||
60 | const subDivHttpText = videojsUntyped.dom.createEl('span', { | ||
61 | className: 'http-fallback', | ||
62 | textContent: 'HTTP' | ||
63 | }) | ||
64 | |||
65 | subDivHttp.appendChild(subDivHttpText) | ||
66 | div.appendChild(subDivHttp) | ||
67 | |||
68 | this.player_.on('p2pInfo', (event: any, data: any) => { | ||
69 | // We are in HTTP fallback | ||
70 | if (!data) { | ||
71 | subDivHttp.className = 'vjs-peertube-displayed' | ||
72 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
73 | |||
74 | return | ||
75 | } | ||
76 | |||
77 | const downloadSpeed = bytes(data.downloadSpeed) | ||
78 | const uploadSpeed = bytes(data.uploadSpeed) | ||
79 | const totalDownloaded = bytes(data.downloaded) | ||
80 | const totalUploaded = bytes(data.uploaded) | ||
81 | const numPeers = data.numPeers | ||
82 | |||
83 | subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' + | ||
84 | this.player_.localize('Total uploaded: ' + totalUploaded.join(' ')) | ||
85 | |||
86 | downloadSpeedNumber.textContent = downloadSpeed[ 0 ] | ||
87 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] | ||
88 | |||
89 | uploadSpeedNumber.textContent = uploadSpeed[ 0 ] | ||
90 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | ||
91 | |||
92 | peersNumber.textContent = numPeers | ||
93 | peersText.textContent = ' ' + this.player_.localize('peers') | ||
94 | |||
95 | subDivHttp.className = 'vjs-peertube-hidden' | ||
96 | subDivWebtorrent.className = 'vjs-peertube-displayed' | ||
97 | }) | ||
98 | |||
99 | return div | ||
100 | } | ||
101 | } | ||
102 | Button.registerComponent('P2PInfoButton', P2pInfoButton) | ||
diff --git a/client/src/assets/player/videojs-components/peertube-link-button.ts b/client/src/assets/player/videojs-components/peertube-link-button.ts new file mode 100644 index 000000000..fed8ea33e --- /dev/null +++ b/client/src/assets/player/videojs-components/peertube-link-button.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
2 | import { buildVideoLink } from '../utils' | ||
3 | // FIXME: something weird with our path definition in tsconfig and typings | ||
4 | // @ts-ignore | ||
5 | import { Player } from 'video.js' | ||
6 | |||
7 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
8 | class PeerTubeLinkButton extends Button { | ||
9 | |||
10 | constructor (player: Player, options: any) { | ||
11 | super(player, options) | ||
12 | } | ||
13 | |||
14 | createEl () { | ||
15 | return this.buildElement() | ||
16 | } | ||
17 | |||
18 | updateHref () { | ||
19 | this.el().setAttribute('href', buildVideoLink(this.player().currentTime())) | ||
20 | } | ||
21 | |||
22 | handleClick () { | ||
23 | this.player_.pause() | ||
24 | } | ||
25 | |||
26 | private buildElement () { | ||
27 | const el = videojsUntyped.dom.createEl('a', { | ||
28 | href: buildVideoLink(), | ||
29 | innerHTML: 'PeerTube', | ||
30 | title: this.player_.localize('Go to the video page'), | ||
31 | className: 'vjs-peertube-link', | ||
32 | target: '_blank' | ||
33 | }) | ||
34 | |||
35 | el.addEventListener('mouseenter', () => this.updateHref()) | ||
36 | |||
37 | return el | ||
38 | } | ||
39 | } | ||
40 | Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) | ||
diff --git a/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts new file mode 100644 index 000000000..9a0e3b550 --- /dev/null +++ b/client/src/assets/player/videojs-components/peertube-load-progress-bar.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
2 | // FIXME: something weird with our path definition in tsconfig and typings | ||
3 | // @ts-ignore | ||
4 | import { Player } from 'video.js' | ||
5 | |||
6 | const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | ||
7 | |||
8 | class PeerTubeLoadProgressBar extends Component { | ||
9 | |||
10 | constructor (player: Player, options: any) { | ||
11 | super(player, options) | ||
12 | this.partEls_ = [] | ||
13 | this.on(player, 'progress', this.update) | ||
14 | } | ||
15 | |||
16 | createEl () { | ||
17 | return super.createEl('div', { | ||
18 | className: 'vjs-load-progress', | ||
19 | innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>` | ||
20 | }) | ||
21 | } | ||
22 | |||
23 | dispose () { | ||
24 | this.partEls_ = null | ||
25 | |||
26 | super.dispose() | ||
27 | } | ||
28 | |||
29 | update () { | ||
30 | const torrent = this.player().webtorrent().getTorrent() | ||
31 | if (!torrent) return | ||
32 | |||
33 | this.el_.style.width = (torrent.progress * 100) + '%' | ||
34 | } | ||
35 | |||
36 | } | ||
37 | |||
38 | Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) | ||
diff --git a/client/src/assets/player/videojs-components/resolution-menu-button.ts b/client/src/assets/player/videojs-components/resolution-menu-button.ts new file mode 100644 index 000000000..2847de470 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-button.ts | |||
@@ -0,0 +1,84 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { LoadedQualityData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
6 | import { ResolutionMenuItem } from './resolution-menu-item' | ||
7 | |||
8 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
9 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | ||
10 | class ResolutionMenuButton extends MenuButton { | ||
11 | label: HTMLElement | ||
12 | |||
13 | constructor (player: Player, options: any) { | ||
14 | super(player, options) | ||
15 | this.player = player | ||
16 | |||
17 | player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data)) | ||
18 | |||
19 | if (player.webtorrent) { | ||
20 | player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0)) | ||
21 | } | ||
22 | } | ||
23 | |||
24 | createEl () { | ||
25 | const el = super.createEl() | ||
26 | |||
27 | this.labelEl_ = videojsUntyped.dom.createEl('div', { | ||
28 | className: 'vjs-resolution-value' | ||
29 | }) | ||
30 | |||
31 | el.appendChild(this.labelEl_) | ||
32 | |||
33 | return el | ||
34 | } | ||
35 | |||
36 | updateARIAAttributes () { | ||
37 | this.el().setAttribute('aria-label', 'Quality') | ||
38 | } | ||
39 | |||
40 | createMenu () { | ||
41 | return new Menu(this.player_) | ||
42 | } | ||
43 | |||
44 | buildCSSClass () { | ||
45 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
46 | } | ||
47 | |||
48 | buildWrapperCSSClass () { | ||
49 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
50 | } | ||
51 | |||
52 | private buildQualities (data: LoadedQualityData) { | ||
53 | // The automatic resolution item will need other labels | ||
54 | const labels: { [ id: number ]: string } = {} | ||
55 | |||
56 | for (const d of data.qualityData.video) { | ||
57 | this.menu.addChild(new ResolutionMenuItem( | ||
58 | this.player_, | ||
59 | { | ||
60 | id: d.id, | ||
61 | label: d.label, | ||
62 | selected: d.selected, | ||
63 | callback: data.qualitySwitchCallback | ||
64 | }) | ||
65 | ) | ||
66 | |||
67 | labels[d.id] = d.label | ||
68 | } | ||
69 | |||
70 | this.menu.addChild(new ResolutionMenuItem( | ||
71 | this.player_, | ||
72 | { | ||
73 | id: -1, | ||
74 | label: this.player_.localize('Auto'), | ||
75 | labels, | ||
76 | callback: data.qualitySwitchCallback, | ||
77 | selected: true // By default, in auto mode | ||
78 | } | ||
79 | )) | ||
80 | } | ||
81 | } | ||
82 | ResolutionMenuButton.prototype.controlText_ = 'Quality' | ||
83 | |||
84 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
diff --git a/client/src/assets/player/videojs-components/resolution-menu-item.ts b/client/src/assets/player/videojs-components/resolution-menu-item.ts new file mode 100644 index 000000000..cc1c79739 --- /dev/null +++ b/client/src/assets/player/videojs-components/resolution-menu-item.ts | |||
@@ -0,0 +1,87 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import { Player } from 'video.js' | ||
4 | |||
5 | import { AutoResolutionUpdateData, ResolutionUpdateData, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
6 | |||
7 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
8 | class ResolutionMenuItem extends MenuItem { | ||
9 | private readonly id: number | ||
10 | private readonly label: string | ||
11 | // Only used for the automatic item | ||
12 | private readonly labels: { [id: number]: string } | ||
13 | private readonly callback: Function | ||
14 | |||
15 | private autoResolutionPossible: boolean | ||
16 | private currentResolutionLabel: string | ||
17 | |||
18 | constructor (player: Player, options: any) { | ||
19 | options.selectable = true | ||
20 | |||
21 | super(player, options) | ||
22 | |||
23 | this.autoResolutionPossible = true | ||
24 | this.currentResolutionLabel = '' | ||
25 | |||
26 | this.label = options.label | ||
27 | this.labels = options.labels | ||
28 | this.id = options.id | ||
29 | this.callback = options.callback | ||
30 | |||
31 | if (player.webtorrent) { | ||
32 | player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data)) | ||
33 | |||
34 | // We only want to disable the "Auto" item | ||
35 | if (this.id === -1) { | ||
36 | player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data)) | ||
37 | } | ||
38 | } | ||
39 | |||
40 | // TODO: update on HLS change | ||
41 | } | ||
42 | |||
43 | handleClick (event: any) { | ||
44 | // Auto button disabled? | ||
45 | if (this.autoResolutionPossible === false && this.id === -1) return | ||
46 | |||
47 | super.handleClick(event) | ||
48 | |||
49 | this.callback(this.id) | ||
50 | } | ||
51 | |||
52 | updateSelection (data: ResolutionUpdateData) { | ||
53 | if (this.id === -1) { | ||
54 | this.currentResolutionLabel = this.labels[data.resolutionId] | ||
55 | } | ||
56 | |||
57 | // Automatic resolution only | ||
58 | if (data.auto === true) { | ||
59 | this.selected(this.id === -1) | ||
60 | return | ||
61 | } | ||
62 | |||
63 | this.selected(this.id === data.resolutionId) | ||
64 | } | ||
65 | |||
66 | updateAutoResolution (data: AutoResolutionUpdateData) { | ||
67 | // Check if the auto resolution is enabled or not | ||
68 | if (data.possible === false) { | ||
69 | this.addClass('disabled') | ||
70 | } else { | ||
71 | this.removeClass('disabled') | ||
72 | } | ||
73 | |||
74 | this.autoResolutionPossible = data.possible | ||
75 | } | ||
76 | |||
77 | getLabel () { | ||
78 | if (this.id === -1) { | ||
79 | return this.label + ' <small>' + this.currentResolutionLabel + '</small>' | ||
80 | } | ||
81 | |||
82 | return this.label | ||
83 | } | ||
84 | } | ||
85 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
86 | |||
87 | export { ResolutionMenuItem } | ||
diff --git a/client/src/assets/player/videojs-components/settings-menu-button.ts b/client/src/assets/player/videojs-components/settings-menu-button.ts new file mode 100644 index 000000000..14cb8ba43 --- /dev/null +++ b/client/src/assets/player/videojs-components/settings-menu-button.ts | |||
@@ -0,0 +1,288 @@ | |||
1 | // Author: Yanko Shterev | ||
2 | // Thanks https://github.com/yshterev/videojs-settings-menu | ||
3 | |||
4 | // FIXME: something weird with our path definition in tsconfig and typings | ||
5 | // @ts-ignore | ||
6 | import * as videojs from 'video.js' | ||
7 | |||
8 | import { SettingsMenuItem } from './settings-menu-item' | ||
9 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
10 | import { toTitleCase } from '../utils' | ||
11 | |||
12 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
13 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
14 | const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | ||
15 | |||
16 | class SettingsButton extends Button { | ||
17 | constructor (player: videojs.Player, options: any) { | ||
18 | super(player, options) | ||
19 | |||
20 | this.playerComponent = player | ||
21 | this.dialog = this.playerComponent.addChild('settingsDialog') | ||
22 | this.dialogEl = this.dialog.el_ | ||
23 | this.menu = null | ||
24 | this.panel = this.dialog.addChild('settingsPanel') | ||
25 | this.panelChild = this.panel.addChild('settingsPanelChild') | ||
26 | |||
27 | this.addClass('vjs-settings') | ||
28 | this.el_.setAttribute('aria-label', 'Settings Button') | ||
29 | |||
30 | // Event handlers | ||
31 | this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) | ||
32 | this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this) | ||
33 | this.playerClickHandler = this.onPlayerClick.bind(this) | ||
34 | this.userInactiveHandler = this.onUserInactive.bind(this) | ||
35 | |||
36 | this.buildMenu() | ||
37 | this.bindEvents() | ||
38 | |||
39 | // Prepare the dialog | ||
40 | this.player().one('play', () => this.hideDialog()) | ||
41 | } | ||
42 | |||
43 | onPlayerClick (event: MouseEvent) { | ||
44 | const element = event.target as HTMLElement | ||
45 | if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) { | ||
46 | return | ||
47 | } | ||
48 | |||
49 | if (!this.dialog.hasClass('vjs-hidden')) { | ||
50 | this.hideDialog() | ||
51 | } | ||
52 | } | ||
53 | |||
54 | onDisposeSettingsItem (event: any, name: string) { | ||
55 | if (name === undefined) { | ||
56 | let children = this.menu.children() | ||
57 | |||
58 | while (children.length > 0) { | ||
59 | children[0].dispose() | ||
60 | this.menu.removeChild(children[0]) | ||
61 | } | ||
62 | |||
63 | this.addClass('vjs-hidden') | ||
64 | } else { | ||
65 | let item = this.menu.getChild(name) | ||
66 | |||
67 | if (item) { | ||
68 | item.dispose() | ||
69 | this.menu.removeChild(item) | ||
70 | } | ||
71 | } | ||
72 | |||
73 | this.hideDialog() | ||
74 | |||
75 | if (this.options_.entries.length === 0) { | ||
76 | this.addClass('vjs-hidden') | ||
77 | } | ||
78 | } | ||
79 | |||
80 | onAddSettingsItem (event: any, data: any) { | ||
81 | const [ entry, options ] = data | ||
82 | |||
83 | this.addMenuItem(entry, options) | ||
84 | this.removeClass('vjs-hidden') | ||
85 | } | ||
86 | |||
87 | onUserInactive () { | ||
88 | if (!this.dialog.hasClass('vjs-hidden')) { | ||
89 | this.hideDialog() | ||
90 | } | ||
91 | } | ||
92 | |||
93 | bindEvents () { | ||
94 | this.playerComponent.on('click', this.playerClickHandler) | ||
95 | this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) | ||
96 | this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) | ||
97 | this.playerComponent.on('userinactive', this.userInactiveHandler) | ||
98 | } | ||
99 | |||
100 | buildCSSClass () { | ||
101 | return `vjs-icon-settings ${super.buildCSSClass()}` | ||
102 | } | ||
103 | |||
104 | handleClick () { | ||
105 | if (this.dialog.hasClass('vjs-hidden')) { | ||
106 | this.showDialog() | ||
107 | } else { | ||
108 | this.hideDialog() | ||
109 | } | ||
110 | } | ||
111 | |||
112 | showDialog () { | ||
113 | this.menu.el_.style.opacity = '1' | ||
114 | this.dialog.show() | ||
115 | |||
116 | this.setDialogSize(this.getComponentSize(this.menu)) | ||
117 | } | ||
118 | |||
119 | hideDialog () { | ||
120 | this.dialog.hide() | ||
121 | this.setDialogSize(this.getComponentSize(this.menu)) | ||
122 | this.menu.el_.style.opacity = '1' | ||
123 | this.resetChildren() | ||
124 | } | ||
125 | |||
126 | getComponentSize (element: any) { | ||
127 | let width: number = null | ||
128 | let height: number = null | ||
129 | |||
130 | // Could be component or just DOM element | ||
131 | if (element instanceof Component) { | ||
132 | width = element.el_.offsetWidth | ||
133 | height = element.el_.offsetHeight | ||
134 | |||
135 | // keep width/height as properties for direct use | ||
136 | element.width = width | ||
137 | element.height = height | ||
138 | } else { | ||
139 | width = element.offsetWidth | ||
140 | height = element.offsetHeight | ||
141 | } | ||
142 | |||
143 | return [ width, height ] | ||
144 | } | ||
145 | |||
146 | setDialogSize ([ width, height ]: number[]) { | ||
147 | if (typeof height !== 'number') { | ||
148 | return | ||
149 | } | ||
150 | |||
151 | let offset = this.options_.setup.maxHeightOffset | ||
152 | let maxHeight = this.playerComponent.el_.offsetHeight - offset | ||
153 | |||
154 | if (height > maxHeight) { | ||
155 | height = maxHeight | ||
156 | width += 17 | ||
157 | this.panel.el_.style.maxHeight = `${height}px` | ||
158 | } else if (this.panel.el_.style.maxHeight !== '') { | ||
159 | this.panel.el_.style.maxHeight = '' | ||
160 | } | ||
161 | |||
162 | this.dialogEl.style.width = `${width}px` | ||
163 | this.dialogEl.style.height = `${height}px` | ||
164 | } | ||
165 | |||
166 | buildMenu () { | ||
167 | this.menu = new Menu(this.player()) | ||
168 | this.menu.addClass('vjs-main-menu') | ||
169 | let entries = this.options_.entries | ||
170 | |||
171 | if (entries.length === 0) { | ||
172 | this.addClass('vjs-hidden') | ||
173 | this.panelChild.addChild(this.menu) | ||
174 | return | ||
175 | } | ||
176 | |||
177 | for (let entry of entries) { | ||
178 | this.addMenuItem(entry, this.options_) | ||
179 | } | ||
180 | |||
181 | this.panelChild.addChild(this.menu) | ||
182 | } | ||
183 | |||
184 | addMenuItem (entry: any, options: any) { | ||
185 | const openSubMenu = function (this: any) { | ||
186 | if (videojsUntyped.dom.hasClass(this.el_, 'open')) { | ||
187 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
188 | } else { | ||
189 | videojsUntyped.dom.addClass(this.el_, 'open') | ||
190 | } | ||
191 | } | ||
192 | |||
193 | options.name = toTitleCase(entry) | ||
194 | let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) | ||
195 | |||
196 | this.menu.addChild(settingsMenuItem) | ||
197 | |||
198 | // Hide children to avoid sub menus stacking on top of each other | ||
199 | // or having multiple menus open | ||
200 | settingsMenuItem.on('click', videojs.bind(this, this.hideChildren)) | ||
201 | |||
202 | // Whether to add or remove selected class on the settings sub menu element | ||
203 | settingsMenuItem.on('click', openSubMenu) | ||
204 | } | ||
205 | |||
206 | resetChildren () { | ||
207 | for (let menuChild of this.menu.children()) { | ||
208 | menuChild.reset() | ||
209 | } | ||
210 | } | ||
211 | |||
212 | /** | ||
213 | * Hide all the sub menus | ||
214 | */ | ||
215 | hideChildren () { | ||
216 | for (let menuChild of this.menu.children()) { | ||
217 | menuChild.hideSubMenu() | ||
218 | } | ||
219 | } | ||
220 | |||
221 | } | ||
222 | |||
223 | class SettingsPanel extends Component { | ||
224 | constructor (player: videojs.Player, options: any) { | ||
225 | super(player, options) | ||
226 | } | ||
227 | |||
228 | createEl () { | ||
229 | return super.createEl('div', { | ||
230 | className: 'vjs-settings-panel', | ||
231 | innerHTML: '', | ||
232 | tabIndex: -1 | ||
233 | }) | ||
234 | } | ||
235 | } | ||
236 | |||
237 | class SettingsPanelChild extends Component { | ||
238 | constructor (player: videojs.Player, options: any) { | ||
239 | super(player, options) | ||
240 | } | ||
241 | |||
242 | createEl () { | ||
243 | return super.createEl('div', { | ||
244 | className: 'vjs-settings-panel-child', | ||
245 | innerHTML: '', | ||
246 | tabIndex: -1 | ||
247 | }) | ||
248 | } | ||
249 | } | ||
250 | |||
251 | class SettingsDialog extends Component { | ||
252 | constructor (player: videojs.Player, options: any) { | ||
253 | super(player, options) | ||
254 | this.hide() | ||
255 | } | ||
256 | |||
257 | /** | ||
258 | * Create the component's DOM element | ||
259 | * | ||
260 | * @return {Element} | ||
261 | * @method createEl | ||
262 | */ | ||
263 | createEl () { | ||
264 | const uniqueId = this.id_ | ||
265 | const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId | ||
266 | const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId | ||
267 | |||
268 | return super.createEl('div', { | ||
269 | className: 'vjs-settings-dialog vjs-modal-overlay', | ||
270 | innerHTML: '', | ||
271 | tabIndex: -1 | ||
272 | }, { | ||
273 | 'role': 'dialog', | ||
274 | 'aria-labelledby': dialogLabelId, | ||
275 | 'aria-describedby': dialogDescriptionId | ||
276 | }) | ||
277 | } | ||
278 | |||
279 | } | ||
280 | |||
281 | SettingsButton.prototype.controlText_ = 'Settings' | ||
282 | |||
283 | Component.registerComponent('SettingsButton', SettingsButton) | ||
284 | Component.registerComponent('SettingsDialog', SettingsDialog) | ||
285 | Component.registerComponent('SettingsPanel', SettingsPanel) | ||
286 | Component.registerComponent('SettingsPanelChild', SettingsPanelChild) | ||
287 | |||
288 | export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } | ||
diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts new file mode 100644 index 000000000..b9a430290 --- /dev/null +++ b/client/src/assets/player/videojs-components/settings-menu-item.ts | |||
@@ -0,0 +1,329 @@ | |||
1 | // Author: Yanko Shterev | ||
2 | // Thanks https://github.com/yshterev/videojs-settings-menu | ||
3 | |||
4 | // FIXME: something weird with our path definition in tsconfig and typings | ||
5 | // @ts-ignore | ||
6 | import * as videojs from 'video.js' | ||
7 | |||
8 | import { toTitleCase } from '../utils' | ||
9 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
10 | |||
11 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
12 | const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | ||
13 | |||
14 | class SettingsMenuItem extends MenuItem { | ||
15 | |||
16 | constructor (player: videojs.Player, options: any, entry: string, menuButton: VideoJSComponentInterface) { | ||
17 | super(player, options) | ||
18 | |||
19 | this.settingsButton = menuButton | ||
20 | this.dialog = this.settingsButton.dialog | ||
21 | this.mainMenu = this.settingsButton.menu | ||
22 | this.panel = this.dialog.getChild('settingsPanel') | ||
23 | this.panelChild = this.panel.getChild('settingsPanelChild') | ||
24 | this.panelChildEl = this.panelChild.el_ | ||
25 | |||
26 | this.size = null | ||
27 | |||
28 | // keep state of what menu type is loading next | ||
29 | this.menuToLoad = 'mainmenu' | ||
30 | |||
31 | const subMenuName = toTitleCase(entry) | ||
32 | const SubMenuComponent = videojsUntyped.getComponent(subMenuName) | ||
33 | |||
34 | if (!SubMenuComponent) { | ||
35 | throw new Error(`Component ${subMenuName} does not exist`) | ||
36 | } | ||
37 | this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) | ||
38 | const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0] | ||
39 | this.settingsSubMenuEl_.className += ' ' + subMenuClass | ||
40 | |||
41 | this.eventHandlers() | ||
42 | |||
43 | player.ready(() => { | ||
44 | // Voodoo magic for IOS | ||
45 | setTimeout(() => { | ||
46 | this.build() | ||
47 | |||
48 | // Update on rate change | ||
49 | player.on('ratechange', this.submenuClickHandler) | ||
50 | |||
51 | if (subMenuName === 'CaptionsButton') { | ||
52 | // Hack to regenerate captions on HTTP fallback | ||
53 | player.on('captionsChanged', () => { | ||
54 | setTimeout(() => { | ||
55 | this.settingsSubMenuEl_.innerHTML = '' | ||
56 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) | ||
57 | this.update() | ||
58 | this.bindClickEvents() | ||
59 | |||
60 | }, 0) | ||
61 | }) | ||
62 | } | ||
63 | |||
64 | this.reset() | ||
65 | }, 0) | ||
66 | }) | ||
67 | } | ||
68 | |||
69 | eventHandlers () { | ||
70 | this.submenuClickHandler = this.onSubmenuClick.bind(this) | ||
71 | this.transitionEndHandler = this.onTransitionEnd.bind(this) | ||
72 | } | ||
73 | |||
74 | onSubmenuClick (event: any) { | ||
75 | let target = null | ||
76 | |||
77 | if (event.type === 'tap') { | ||
78 | target = event.target | ||
79 | } else { | ||
80 | target = event.currentTarget | ||
81 | } | ||
82 | |||
83 | if (target && target.classList.contains('vjs-back-button')) { | ||
84 | this.loadMainMenu() | ||
85 | return | ||
86 | } | ||
87 | |||
88 | // To update the sub menu value on click, setTimeout is needed because | ||
89 | // updating the value is not instant | ||
90 | setTimeout(() => this.update(event), 0) | ||
91 | } | ||
92 | |||
93 | /** | ||
94 | * Create the component's DOM element | ||
95 | * | ||
96 | * @return {Element} | ||
97 | * @method createEl | ||
98 | */ | ||
99 | createEl () { | ||
100 | const el = videojsUntyped.dom.createEl('li', { | ||
101 | className: 'vjs-menu-item' | ||
102 | }) | ||
103 | |||
104 | this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { | ||
105 | className: 'vjs-settings-sub-menu-title' | ||
106 | }) | ||
107 | |||
108 | el.appendChild(this.settingsSubMenuTitleEl_) | ||
109 | |||
110 | this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { | ||
111 | className: 'vjs-settings-sub-menu-value' | ||
112 | }) | ||
113 | |||
114 | el.appendChild(this.settingsSubMenuValueEl_) | ||
115 | |||
116 | this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { | ||
117 | className: 'vjs-settings-sub-menu' | ||
118 | }) | ||
119 | |||
120 | return el | ||
121 | } | ||
122 | |||
123 | /** | ||
124 | * Handle click on menu item | ||
125 | * | ||
126 | * @method handleClick | ||
127 | */ | ||
128 | handleClick () { | ||
129 | this.menuToLoad = 'submenu' | ||
130 | // Remove open class to ensure only the open submenu gets this class | ||
131 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
132 | |||
133 | super.handleClick() | ||
134 | |||
135 | this.mainMenu.el_.style.opacity = '0' | ||
136 | // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element | ||
137 | if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { | ||
138 | videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
139 | |||
140 | // animation not played without timeout | ||
141 | setTimeout(() => { | ||
142 | this.settingsSubMenuEl_.style.opacity = '1' | ||
143 | this.settingsSubMenuEl_.style.marginRight = '0px' | ||
144 | }, 0) | ||
145 | |||
146 | this.settingsButton.setDialogSize(this.size) | ||
147 | } else { | ||
148 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
149 | } | ||
150 | } | ||
151 | |||
152 | /** | ||
153 | * Create back button | ||
154 | * | ||
155 | * @method createBackButton | ||
156 | */ | ||
157 | createBackButton () { | ||
158 | const button = this.subMenu.menu.addChild('MenuItem', {}, 0) | ||
159 | button.name_ = 'BackButton' | ||
160 | button.addClass('vjs-back-button') | ||
161 | button.el_.innerHTML = this.player_.localize(this.subMenu.controlText_) | ||
162 | } | ||
163 | |||
164 | /** | ||
165 | * Add/remove prefixed event listener for CSS Transition | ||
166 | * | ||
167 | * @method PrefixedEvent | ||
168 | */ | ||
169 | PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { | ||
170 | let prefix = ['webkit', 'moz', 'MS', 'o', ''] | ||
171 | |||
172 | for (let p = 0; p < prefix.length; p++) { | ||
173 | if (!prefix[p]) { | ||
174 | type = type.toLowerCase() | ||
175 | } | ||
176 | |||
177 | if (action === 'addEvent') { | ||
178 | element.addEventListener(prefix[p] + type, callback, false) | ||
179 | } else if (action === 'removeEvent') { | ||
180 | element.removeEventListener(prefix[p] + type, callback, false) | ||
181 | } | ||
182 | } | ||
183 | } | ||
184 | |||
185 | onTransitionEnd (event: any) { | ||
186 | if (event.propertyName !== 'margin-right') { | ||
187 | return | ||
188 | } | ||
189 | |||
190 | if (this.menuToLoad === 'mainmenu') { | ||
191 | // hide submenu | ||
192 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
193 | |||
194 | // reset opacity to 0 | ||
195 | this.settingsSubMenuEl_.style.opacity = '0' | ||
196 | } | ||
197 | } | ||
198 | |||
199 | reset () { | ||
200 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
201 | this.settingsSubMenuEl_.style.opacity = '0' | ||
202 | this.setMargin() | ||
203 | } | ||
204 | |||
205 | loadMainMenu () { | ||
206 | this.menuToLoad = 'mainmenu' | ||
207 | this.mainMenu.show() | ||
208 | this.mainMenu.el_.style.opacity = '0' | ||
209 | |||
210 | // back button will always take you to main menu, so set dialog sizes | ||
211 | this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) | ||
212 | |||
213 | // animation not triggered without timeout (some async stuff ?!?) | ||
214 | setTimeout(() => { | ||
215 | // animate margin and opacity before hiding the submenu | ||
216 | // this triggers CSS Transition event | ||
217 | this.setMargin() | ||
218 | this.mainMenu.el_.style.opacity = '1' | ||
219 | }, 0) | ||
220 | } | ||
221 | |||
222 | build () { | ||
223 | this.subMenu.on('updateLabel', () => { | ||
224 | this.update() | ||
225 | }) | ||
226 | |||
227 | this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_) | ||
228 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) | ||
229 | this.panelChildEl.appendChild(this.settingsSubMenuEl_) | ||
230 | this.update() | ||
231 | |||
232 | this.createBackButton() | ||
233 | this.getSize() | ||
234 | this.bindClickEvents() | ||
235 | |||
236 | // prefixed event listeners for CSS TransitionEnd | ||
237 | this.PrefixedEvent( | ||
238 | this.settingsSubMenuEl_, | ||
239 | 'TransitionEnd', | ||
240 | this.transitionEndHandler, | ||
241 | 'addEvent' | ||
242 | ) | ||
243 | } | ||
244 | |||
245 | update (event?: any) { | ||
246 | let target: HTMLElement = null | ||
247 | let subMenu = this.subMenu.name() | ||
248 | |||
249 | if (event && event.type === 'tap') { | ||
250 | target = event.target | ||
251 | } else if (event) { | ||
252 | target = event.currentTarget | ||
253 | } | ||
254 | |||
255 | // Playback rate menu button doesn't get a vjs-selected class | ||
256 | // or sets options_['selected'] on the selected playback rate. | ||
257 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton | ||
258 | if (subMenu === 'PlaybackRateMenuButton') { | ||
259 | setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) | ||
260 | } else { | ||
261 | // Loop trough the submenu items to find the selected child | ||
262 | for (let subMenuItem of this.subMenu.menu.children_) { | ||
263 | if (!(subMenuItem instanceof component)) { | ||
264 | continue | ||
265 | } | ||
266 | |||
267 | if (subMenuItem.hasClass('vjs-selected')) { | ||
268 | // Prefer to use the function | ||
269 | if (typeof subMenuItem.getLabel === 'function') { | ||
270 | this.settingsSubMenuValueEl_.innerHTML = subMenuItem.getLabel() | ||
271 | break | ||
272 | } | ||
273 | |||
274 | this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label | ||
275 | } | ||
276 | } | ||
277 | } | ||
278 | |||
279 | if (target && !target.classList.contains('vjs-back-button')) { | ||
280 | this.settingsButton.hideDialog() | ||
281 | } | ||
282 | } | ||
283 | |||
284 | bindClickEvents () { | ||
285 | for (let item of this.subMenu.menu.children()) { | ||
286 | if (!(item instanceof component)) { | ||
287 | continue | ||
288 | } | ||
289 | item.on(['tap', 'click'], this.submenuClickHandler) | ||
290 | } | ||
291 | } | ||
292 | |||
293 | // save size of submenus on first init | ||
294 | // if number of submenu items change dynamically more logic will be needed | ||
295 | getSize () { | ||
296 | this.dialog.removeClass('vjs-hidden') | ||
297 | this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) | ||
298 | this.setMargin() | ||
299 | this.dialog.addClass('vjs-hidden') | ||
300 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
301 | } | ||
302 | |||
303 | setMargin () { | ||
304 | let [width] = this.size | ||
305 | |||
306 | this.settingsSubMenuEl_.style.marginRight = `-${width}px` | ||
307 | } | ||
308 | |||
309 | /** | ||
310 | * Hide the sub menu | ||
311 | */ | ||
312 | hideSubMenu () { | ||
313 | // after removing settings item this.el_ === null | ||
314 | if (!this.el_) { | ||
315 | return | ||
316 | } | ||
317 | |||
318 | if (videojsUntyped.dom.hasClass(this.el_, 'open')) { | ||
319 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
320 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
321 | } | ||
322 | } | ||
323 | |||
324 | } | ||
325 | |||
326 | SettingsMenuItem.prototype.contentElType = 'button' | ||
327 | videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) | ||
328 | |||
329 | export { SettingsMenuItem } | ||
diff --git a/client/src/assets/player/videojs-components/theater-button.ts b/client/src/assets/player/videojs-components/theater-button.ts new file mode 100644 index 000000000..1e11a9546 --- /dev/null +++ b/client/src/assets/player/videojs-components/theater-button.ts | |||
@@ -0,0 +1,50 @@ | |||
1 | // FIXME: something weird with our path definition in tsconfig and typings | ||
2 | // @ts-ignore | ||
3 | import * as videojs from 'video.js' | ||
4 | |||
5 | import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings' | ||
6 | import { saveTheaterInStore, getStoredTheater } from '../peertube-player-local-storage' | ||
7 | |||
8 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
9 | class TheaterButton extends Button { | ||
10 | |||
11 | private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' | ||
12 | |||
13 | constructor (player: videojs.Player, options: any) { | ||
14 | super(player, options) | ||
15 | |||
16 | const enabled = getStoredTheater() | ||
17 | if (enabled === true) { | ||
18 | this.player_.addClass(TheaterButton.THEATER_MODE_CLASS) | ||
19 | this.handleTheaterChange() | ||
20 | } | ||
21 | } | ||
22 | |||
23 | buildCSSClass () { | ||
24 | return `vjs-theater-control ${super.buildCSSClass()}` | ||
25 | } | ||
26 | |||
27 | handleTheaterChange () { | ||
28 | if (this.isTheaterEnabled()) { | ||
29 | this.controlText('Normal mode') | ||
30 | } else { | ||
31 | this.controlText('Theater mode') | ||
32 | } | ||
33 | |||
34 | saveTheaterInStore(this.isTheaterEnabled()) | ||
35 | } | ||
36 | |||
37 | handleClick () { | ||
38 | this.player_.toggleClass(TheaterButton.THEATER_MODE_CLASS) | ||
39 | |||
40 | this.handleTheaterChange() | ||
41 | } | ||
42 | |||
43 | private isTheaterEnabled () { | ||
44 | return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) | ||
45 | } | ||
46 | } | ||
47 | |||
48 | TheaterButton.prototype.controlText_ = 'Theater mode' | ||
49 | |||
50 | TheaterButton.registerComponent('TheaterButton', TheaterButton) | ||