diff options
author | Chocobozzz <me@florianbigard.com> | 2018-03-30 17:40:00 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-04-03 14:02:10 +0200 |
commit | c6352f2c64f3c1ad54f8500f493587cdce3d33c9 (patch) | |
tree | 642a5b29b4d68ed8915e5e800232eab069303f79 /client/src/assets/player | |
parent | 6b9af1293621a81564296ead6f12f5e70eafbca2 (diff) | |
download | PeerTube-c6352f2c64f3c1ad54f8500f493587cdce3d33c9.tar.gz PeerTube-c6352f2c64f3c1ad54f8500f493587cdce3d33c9.tar.zst PeerTube-c6352f2c64f3c1ad54f8500f493587cdce3d33c9.zip |
Improve player
Add a settings dialog based on the work of Yanko Shterev (@yshterev):
https://github.com/yshterev/videojs-settings-menu. Thanks!
Diffstat (limited to 'client/src/assets/player')
-rw-r--r-- | client/src/assets/player/images/settings.svg | 14 | ||||
-rw-r--r-- | client/src/assets/player/images/tick.svg | 12 | ||||
-rw-r--r-- | client/src/assets/player/peertube-link-button.ts | 20 | ||||
-rw-r--r-- | client/src/assets/player/peertube-player.ts | 96 | ||||
-rw-r--r-- | client/src/assets/player/peertube-videojs-plugin.ts | 288 | ||||
-rw-r--r-- | client/src/assets/player/peertube-videojs-typings.ts | 33 | ||||
-rw-r--r-- | client/src/assets/player/resolution-menu-button.ts | 68 | ||||
-rw-r--r-- | client/src/assets/player/resolution-menu-item.ts | 31 | ||||
-rw-r--r-- | client/src/assets/player/settings-menu-button.ts | 285 | ||||
-rw-r--r-- | client/src/assets/player/settings-menu-item.ts | 313 | ||||
-rw-r--r-- | client/src/assets/player/utils.ts | 72 | ||||
-rw-r--r-- | client/src/assets/player/webtorrent-info-button.ts | 101 |
12 files changed, 1100 insertions, 233 deletions
diff --git a/client/src/assets/player/images/settings.svg b/client/src/assets/player/images/settings.svg new file mode 100644 index 000000000..c663087b7 --- /dev/null +++ b/client/src/assets/player/images/settings.svg | |||
@@ -0,0 +1,14 @@ | |||
1 | <?xml version="1.0" encoding="UTF-8"?> | ||
2 | <svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | ||
3 | <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch --> | ||
4 | <title>settings</title> | ||
5 | <desc>Created with Sketch.</desc> | ||
6 | <defs></defs> | ||
7 | <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round"> | ||
8 | <g id="Artboard-4" transform="translate(-796.000000, -159.000000)" stroke="#fff" stroke-width="2"> | ||
9 | <g id="38" transform="translate(796.000000, 159.000000)"> | ||
10 | <path d="M7.20852293,4.3800958 C8.05442158,3.84706631 8.99528987,3.45099725 10,3.22301642 L10,1.99980749 C10,1.44762906 10.4433532,1 11.0093689,1 L12.9906311,1 C13.5480902,1 14,1.44371665 14,1.99980749 L14,3.22301642 C15.0047101,3.45099725 15.9455784,3.84706631 16.7914771,4.3800958 L17.6569904,3.5145825 C18.0474395,3.12413339 18.6774591,3.12110988 19.0776926,3.52134344 L20.4786566,4.92230738 C20.8728396,5.31649045 20.8786331,5.94979402 20.4854175,6.34300963 L19.6199042,7.20852293 C20.1529337,8.05442158 20.5490027,8.99528987 20.7769836,10 L22.0001925,10 C22.5523709,10 23,10.4433532 23,11.0093689 L23,12.9906311 C23,13.5480902 22.5562834,14 22.0001925,14 L20.7769836,14 C20.5490027,15.0047101 20.1529337,15.9455784 19.6199042,16.7914771 L20.4854175,17.6569904 C20.8758666,18.0474395 20.8788901,18.6774591 20.4786566,19.0776926 L19.0776926,20.4786566 C18.6835095,20.8728396 18.050206,20.8786331 17.6569904,20.4854175 L16.7914771,19.6199042 C15.9455784,20.1529337 15.0047101,20.5490027 14,20.7769836 L14,22.0001925 C14,22.5523709 13.5566468,23 12.9906311,23 L11.0093689,23 C10.4519098,23 10,22.5562834 10,22.0001925 L10,20.7769836 C8.99528987,20.5490027 8.05442158,20.1529337 7.20852293,19.6199042 L6.34300963,20.4854175 C5.95256051,20.8758666 5.32254093,20.8788901 4.92230738,20.4786566 L3.52134344,19.0776926 C3.12716036,18.6835095 3.12136689,18.050206 3.5145825,17.6569904 L4.3800958,16.7914771 C3.84706631,15.9455784 3.45099725,15.0047101 3.22301642,14 L1.99980749,14 C1.44762906,14 1,13.5566468 1,12.9906311 L1,11.0093689 C1,10.4519098 1.44371665,10 1.99980749,10 L3.22301642,10 C3.45099725,8.99528987 3.84706631,8.05442158 4.3800958,7.20852293 L3.5145825,6.34300963 C3.12413339,5.95256051 3.12110988,5.32254093 3.52134344,4.92230738 L4.92230738,3.52134344 C5.31649045,3.12716036 5.94979402,3.12136689 6.34300963,3.5145825 L7.20852293,4.3800958 Z M12,16 C14.209139,16 16,14.209139 16,12 C16,9.790861 14.209139,8 12,8 C9.790861,8 8,9.790861 8,12 C8,14.209139 9.790861,16 12,16 Z" id="Combined-Shape"></path> | ||
11 | </g> | ||
12 | </g> | ||
13 | </g> | ||
14 | </svg> | ||
diff --git a/client/src/assets/player/images/tick.svg b/client/src/assets/player/images/tick.svg new file mode 100644 index 000000000..d329e6bfb --- /dev/null +++ b/client/src/assets/player/images/tick.svg | |||
@@ -0,0 +1,12 @@ | |||
1 | <?xml version="1.0" encoding="UTF-8"?> | ||
2 | <svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | ||
3 | <defs></defs> | ||
4 | <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round"> | ||
5 | <g id="Artboard-4" transform="translate(-356.000000, -115.000000)" stroke="#fff" stroke-width="2"> | ||
6 | <g id="8" transform="translate(356.000000, 115.000000)"> | ||
7 | <path d="M21,6 L9,18" id="Path-14"></path> | ||
8 | <path d="M9,13 L4,18" id="Path-14" transform="translate(6.500000, 15.500000) scale(-1, 1) translate(-6.500000, -15.500000) "></path> | ||
9 | </g> | ||
10 | </g> | ||
11 | </g> | ||
12 | </svg> | ||
diff --git a/client/src/assets/player/peertube-link-button.ts b/client/src/assets/player/peertube-link-button.ts new file mode 100644 index 000000000..6ead78c00 --- /dev/null +++ b/client/src/assets/player/peertube-link-button.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
2 | |||
3 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
4 | class PeerTubeLinkButton extends Button { | ||
5 | |||
6 | createEl () { | ||
7 | return videojsUntyped.dom.createEl('a', { | ||
8 | href: window.location.href.replace('embed', 'watch'), | ||
9 | innerHTML: 'PeerTube', | ||
10 | title: 'Go to the video page', | ||
11 | className: 'vjs-peertube-link', | ||
12 | target: '_blank' | ||
13 | }) | ||
14 | } | ||
15 | |||
16 | handleClick () { | ||
17 | this.player_.pause() | ||
18 | } | ||
19 | } | ||
20 | Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) | ||
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts new file mode 100644 index 000000000..4ae3e71bd --- /dev/null +++ b/client/src/assets/player/peertube-player.ts | |||
@@ -0,0 +1,96 @@ | |||
1 | import { VideoFile } from '../../../../shared/models/videos' | ||
2 | |||
3 | import 'videojs-hotkeys' | ||
4 | import 'videojs-dock/dist/videojs-dock.es.js' | ||
5 | import './peertube-link-button' | ||
6 | import './resolution-menu-button' | ||
7 | import './settings-menu-button' | ||
8 | import './webtorrent-info-button' | ||
9 | import './peertube-videojs-plugin' | ||
10 | import { videojsUntyped } from './peertube-videojs-typings' | ||
11 | |||
12 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
13 | videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' | ||
14 | |||
15 | function getVideojsOptions (options: { | ||
16 | autoplay: boolean, | ||
17 | playerElement: HTMLVideoElement, | ||
18 | videoViewUrl: string, | ||
19 | videoDuration: number, | ||
20 | videoFiles: VideoFile[], | ||
21 | enableHotkeys: boolean, | ||
22 | inactivityTimeout: number, | ||
23 | peertubeLink: boolean | ||
24 | }) { | ||
25 | const videojsOptions = { | ||
26 | controls: true, | ||
27 | autoplay: options.autoplay, | ||
28 | inactivityTimeout: options.inactivityTimeout, | ||
29 | playbackRates: [ 0.5, 1, 1.5, 2 ], | ||
30 | plugins: { | ||
31 | peertube: { | ||
32 | videoFiles: options.videoFiles, | ||
33 | playerElement: options.playerElement, | ||
34 | videoViewUrl: options.videoViewUrl, | ||
35 | videoDuration: options.videoDuration | ||
36 | } | ||
37 | }, | ||
38 | controlBar: { | ||
39 | children: getControlBarChildren(options) | ||
40 | } | ||
41 | } | ||
42 | |||
43 | if (options.enableHotkeys === true) { | ||
44 | Object.assign(videojsOptions.plugins, { | ||
45 | hotkeys: { | ||
46 | enableVolumeScroll: false | ||
47 | } | ||
48 | }) | ||
49 | } | ||
50 | |||
51 | return videojsOptions | ||
52 | } | ||
53 | |||
54 | function getControlBarChildren (options: { | ||
55 | peertubeLink: boolean | ||
56 | }) { | ||
57 | const children = { | ||
58 | 'playToggle': {}, | ||
59 | 'currentTimeDisplay': {}, | ||
60 | 'timeDivider': {}, | ||
61 | 'durationDisplay': {}, | ||
62 | 'liveDisplay': {}, | ||
63 | |||
64 | 'flexibleWidthSpacer': {}, | ||
65 | 'progressControl': {}, | ||
66 | |||
67 | 'webTorrentButton': {}, | ||
68 | |||
69 | 'muteToggle': {}, | ||
70 | 'volumeControl': {}, | ||
71 | |||
72 | 'settingsButton': { | ||
73 | setup: { | ||
74 | maxHeightOffset: 40 | ||
75 | }, | ||
76 | entries: [ | ||
77 | 'resolutionMenuButton', | ||
78 | 'playbackRateMenuButton' | ||
79 | ] | ||
80 | } | ||
81 | } | ||
82 | |||
83 | if (options.peertubeLink === true) { | ||
84 | Object.assign(children, { | ||
85 | 'peerTubeLinkButton': {} | ||
86 | }) | ||
87 | } | ||
88 | |||
89 | Object.assign(children, { | ||
90 | 'fullscreenToggle': {} | ||
91 | }) | ||
92 | |||
93 | return children | ||
94 | } | ||
95 | |||
96 | export { getVideojsOptions } | ||
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 22cb27da3..c35ce12cb 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts | |||
@@ -1,49 +1,11 @@ | |||
1 | // Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher | ||
2 | |||
3 | import * as videojs from 'video.js' | 1 | import * as videojs from 'video.js' |
4 | import * as WebTorrent from 'webtorrent' | 2 | import * as WebTorrent from 'webtorrent' |
5 | import { VideoConstant, VideoResolution } from '../../../../shared/models/videos' | ||
6 | import { VideoFile } from '../../../../shared/models/videos/video.model' | 3 | import { VideoFile } from '../../../../shared/models/videos/video.model' |
7 | import { renderVideo } from './video-renderer' | 4 | import { renderVideo } from './video-renderer' |
5 | import './settings-menu-button' | ||
6 | import { PeertubePluginOptions, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
7 | import { getStoredMute, getStoredVolume, saveMuteInStore, saveVolumeInStore } from './utils' | ||
8 | 8 | ||
9 | declare module 'video.js' { | ||
10 | interface Player { | ||
11 | peertube (): PeerTubePlugin | ||
12 | } | ||
13 | } | ||
14 | |||
15 | interface VideoJSComponentInterface { | ||
16 | _player: videojs.Player | ||
17 | |||
18 | new (player: videojs.Player, options?: any) | ||
19 | |||
20 | registerComponent (name: string, obj: any) | ||
21 | } | ||
22 | |||
23 | type PeertubePluginOptions = { | ||
24 | videoFiles: VideoFile[] | ||
25 | playerElement: HTMLVideoElement | ||
26 | videoViewUrl: string | ||
27 | videoDuration: number | ||
28 | } | ||
29 | |||
30 | // https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts | ||
31 | // Don't import all Angular stuff, just copy the code with shame | ||
32 | const dictionaryBytes: Array<{max: number, type: string}> = [ | ||
33 | { max: 1024, type: 'B' }, | ||
34 | { max: 1048576, type: 'KB' }, | ||
35 | { max: 1073741824, type: 'MB' }, | ||
36 | { max: 1.0995116e12, type: 'GB' } | ||
37 | ] | ||
38 | function bytes (value) { | ||
39 | const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] | ||
40 | const calc = Math.floor(value / (format.max / 1024)).toString() | ||
41 | |||
42 | return [ calc, format.type ] | ||
43 | } | ||
44 | |||
45 | // videojs typings don't have some method we need | ||
46 | const videojsUntyped = videojs as any | ||
47 | const webtorrent = new WebTorrent({ | 9 | const webtorrent = new WebTorrent({ |
48 | tracker: { | 10 | tracker: { |
49 | rtcConfig: { | 11 | rtcConfig: { |
@@ -60,199 +22,19 @@ const webtorrent = new WebTorrent({ | |||
60 | dht: false | 22 | dht: false |
61 | }) | 23 | }) |
62 | 24 | ||
63 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
64 | class ResolutionMenuItem extends MenuItem { | ||
65 | |||
66 | constructor (player: videojs.Player, options) { | ||
67 | options.selectable = true | ||
68 | super(player, options) | ||
69 | |||
70 | const currentResolutionId = this.player_.peertube().getCurrentResolutionId() | ||
71 | this.selected(this.options_.id === currentResolutionId) | ||
72 | } | ||
73 | |||
74 | handleClick (event) { | ||
75 | super.handleClick(event) | ||
76 | |||
77 | this.player_.peertube().updateResolution(this.options_.id) | ||
78 | } | ||
79 | } | ||
80 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
81 | |||
82 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | ||
83 | class ResolutionMenuButton extends MenuButton { | ||
84 | label: HTMLElement | ||
85 | |||
86 | constructor (player: videojs.Player, options) { | ||
87 | options.label = 'Quality' | ||
88 | super(player, options) | ||
89 | |||
90 | this.label = document.createElement('span') | ||
91 | |||
92 | this.el().setAttribute('aria-label', 'Quality') | ||
93 | this.controlText('Quality') | ||
94 | |||
95 | videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') | ||
96 | this.el().appendChild(this.label) | ||
97 | |||
98 | player.peertube().on('videoFileUpdate', () => this.update()) | ||
99 | } | ||
100 | |||
101 | createItems () { | ||
102 | const menuItems = [] | ||
103 | for (const videoFile of this.player_.peertube().videoFiles) { | ||
104 | menuItems.push(new ResolutionMenuItem( | ||
105 | this.player_, | ||
106 | { | ||
107 | id: videoFile.resolution.id, | ||
108 | label: videoFile.resolution.label, | ||
109 | src: videoFile.magnetUri, | ||
110 | selected: videoFile.resolution.id === this.currentSelectionId | ||
111 | }) | ||
112 | ) | ||
113 | } | ||
114 | |||
115 | return menuItems | ||
116 | } | ||
117 | |||
118 | update () { | ||
119 | if (!this.label) return | ||
120 | |||
121 | this.label.innerHTML = this.player_.peertube().getCurrentResolutionLabel() | ||
122 | this.hide() | ||
123 | return super.update() | ||
124 | } | ||
125 | |||
126 | buildCSSClass () { | ||
127 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
128 | } | ||
129 | |||
130 | buildWrapperCSSClass () { | ||
131 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
132 | } | ||
133 | } | ||
134 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
135 | |||
136 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
137 | class PeerTubeLinkButton extends Button { | ||
138 | |||
139 | createEl () { | ||
140 | const link = document.createElement('a') | ||
141 | link.href = window.location.href.replace('embed', 'watch') | ||
142 | link.innerHTML = 'PeerTube' | ||
143 | link.title = 'Go to the video page' | ||
144 | link.className = 'vjs-peertube-link' | ||
145 | link.target = '_blank' | ||
146 | |||
147 | return link | ||
148 | } | ||
149 | |||
150 | handleClick () { | ||
151 | this.player_.pause() | ||
152 | } | ||
153 | } | ||
154 | Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) | ||
155 | |||
156 | class WebTorrentButton extends Button { | ||
157 | createEl () { | ||
158 | const div = document.createElement('div') | ||
159 | const subDivWebtorrent = document.createElement('div') | ||
160 | div.appendChild(subDivWebtorrent) | ||
161 | |||
162 | const downloadIcon = document.createElement('span') | ||
163 | downloadIcon.classList.add('icon', 'icon-download') | ||
164 | subDivWebtorrent.appendChild(downloadIcon) | ||
165 | |||
166 | const downloadSpeedText = document.createElement('span') | ||
167 | downloadSpeedText.classList.add('download-speed-text') | ||
168 | const downloadSpeedNumber = document.createElement('span') | ||
169 | downloadSpeedNumber.classList.add('download-speed-number') | ||
170 | const downloadSpeedUnit = document.createElement('span') | ||
171 | downloadSpeedText.appendChild(downloadSpeedNumber) | ||
172 | downloadSpeedText.appendChild(downloadSpeedUnit) | ||
173 | subDivWebtorrent.appendChild(downloadSpeedText) | ||
174 | |||
175 | const uploadIcon = document.createElement('span') | ||
176 | uploadIcon.classList.add('icon', 'icon-upload') | ||
177 | subDivWebtorrent.appendChild(uploadIcon) | ||
178 | |||
179 | const uploadSpeedText = document.createElement('span') | ||
180 | uploadSpeedText.classList.add('upload-speed-text') | ||
181 | const uploadSpeedNumber = document.createElement('span') | ||
182 | uploadSpeedNumber.classList.add('upload-speed-number') | ||
183 | const uploadSpeedUnit = document.createElement('span') | ||
184 | uploadSpeedText.appendChild(uploadSpeedNumber) | ||
185 | uploadSpeedText.appendChild(uploadSpeedUnit) | ||
186 | subDivWebtorrent.appendChild(uploadSpeedText) | ||
187 | |||
188 | const peersText = document.createElement('span') | ||
189 | peersText.classList.add('peers-text') | ||
190 | const peersNumber = document.createElement('span') | ||
191 | peersNumber.classList.add('peers-number') | ||
192 | subDivWebtorrent.appendChild(peersNumber) | ||
193 | subDivWebtorrent.appendChild(peersText) | ||
194 | |||
195 | div.className = 'vjs-peertube' | ||
196 | // Hide the stats before we get the info | ||
197 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
198 | |||
199 | const subDivHttp = document.createElement('div') | ||
200 | subDivHttp.className = 'vjs-peertube-hidden' | ||
201 | const subDivHttpText = document.createElement('span') | ||
202 | subDivHttpText.classList.add('peers-number') | ||
203 | subDivHttpText.textContent = 'HTTP' | ||
204 | const subDivFallbackText = document.createElement('span') | ||
205 | subDivFallbackText.classList.add('peers-text') | ||
206 | subDivFallbackText.textContent = ' fallback' | ||
207 | |||
208 | subDivHttp.appendChild(subDivHttpText) | ||
209 | subDivHttp.appendChild(subDivFallbackText) | ||
210 | div.appendChild(subDivHttp) | ||
211 | |||
212 | this.player_.peertube().on('torrentInfo', (event, data) => { | ||
213 | // We are in HTTP fallback | ||
214 | if (!data) { | ||
215 | subDivHttp.className = 'vjs-peertube-displayed' | ||
216 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
217 | |||
218 | return | ||
219 | } | ||
220 | |||
221 | const downloadSpeed = bytes(data.downloadSpeed) | ||
222 | const uploadSpeed = bytes(data.uploadSpeed) | ||
223 | const numPeers = data.numPeers | ||
224 | |||
225 | downloadSpeedNumber.textContent = downloadSpeed[ 0 ] | ||
226 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] | ||
227 | |||
228 | uploadSpeedNumber.textContent = uploadSpeed[ 0 ] | ||
229 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | ||
230 | |||
231 | peersNumber.textContent = numPeers | ||
232 | peersText.textContent = ' peers' | ||
233 | |||
234 | subDivHttp.className = 'vjs-peertube-hidden' | ||
235 | subDivWebtorrent.className = 'vjs-peertube-displayed' | ||
236 | }) | ||
237 | |||
238 | return div | ||
239 | } | ||
240 | } | ||
241 | Button.registerComponent('WebTorrentButton', WebTorrentButton) | ||
242 | |||
243 | const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin') | 25 | const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin') |
244 | class PeerTubePlugin extends Plugin { | 26 | class PeerTubePlugin extends Plugin { |
27 | private readonly playerElement: HTMLVideoElement | ||
28 | private readonly autoplay: boolean = false | ||
29 | private readonly savePlayerSrcFunction: Function | ||
245 | private player: any | 30 | private player: any |
246 | private currentVideoFile: VideoFile | 31 | private currentVideoFile: VideoFile |
247 | private playerElement: HTMLVideoElement | ||
248 | private videoFiles: VideoFile[] | 32 | private videoFiles: VideoFile[] |
249 | private torrent: WebTorrent.Torrent | 33 | private torrent: WebTorrent.Torrent |
250 | private autoplay = false | ||
251 | private videoViewUrl: string | 34 | private videoViewUrl: string |
252 | private videoDuration: number | 35 | private videoDuration: number |
253 | private videoViewInterval | 36 | private videoViewInterval |
254 | private torrentInfoInterval | 37 | private torrentInfoInterval |
255 | private savePlayerSrcFunction: Function | ||
256 | 38 | ||
257 | constructor (player: videojs.Player, options: PeertubePluginOptions) { | 39 | constructor (player: videojs.Player, options: PeertubePluginOptions) { |
258 | super(player, options) | 40 | super(player, options) |
@@ -274,10 +56,20 @@ class PeerTubePlugin extends Plugin { | |||
274 | this.playerElement = options.playerElement | 56 | this.playerElement = options.playerElement |
275 | 57 | ||
276 | this.player.ready(() => { | 58 | this.player.ready(() => { |
59 | const volume = getStoredVolume() | ||
60 | if (volume !== undefined) this.player.volume(volume) | ||
61 | const muted = getStoredMute() | ||
62 | if (muted !== undefined) this.player.muted(muted) | ||
63 | |||
277 | this.initializePlayer() | 64 | this.initializePlayer() |
278 | this.runTorrentInfoScheduler() | 65 | this.runTorrentInfoScheduler() |
279 | this.runViewAdd() | 66 | this.runViewAdd() |
280 | }) | 67 | }) |
68 | |||
69 | this.player.on('volumechange', () => { | ||
70 | saveVolumeInStore(this.player.volume()) | ||
71 | saveMuteInStore(this.player.muted()) | ||
72 | }) | ||
281 | } | 73 | } |
282 | 74 | ||
283 | dispose () { | 75 | dispose () { |
@@ -311,16 +103,19 @@ class PeerTubePlugin extends Plugin { | |||
311 | return | 103 | return |
312 | } | 104 | } |
313 | 105 | ||
314 | // Do not display error to user because we will have multiple fallbacks | 106 | // Do not display error to user because we will have multiple fallback |
315 | this.disableErrorDisplay() | 107 | this.disableErrorDisplay() |
316 | 108 | ||
317 | this.player.src = () => true | 109 | this.player.src = () => true |
318 | this.player.playbackRate(1) | 110 | const oldPlaybackRate = this.player.playbackRate() |
319 | 111 | ||
320 | const previousVideoFile = this.currentVideoFile | 112 | const previousVideoFile = this.currentVideoFile |
321 | this.currentVideoFile = videoFile | 113 | this.currentVideoFile = videoFile |
322 | 114 | ||
323 | this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, done) | 115 | this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, () => { |
116 | this.player.playbackRate(oldPlaybackRate) | ||
117 | return done() | ||
118 | }) | ||
324 | 119 | ||
325 | this.trigger('videoFileUpdate') | 120 | this.trigger('videoFileUpdate') |
326 | } | 121 | } |
@@ -337,7 +132,7 @@ class PeerTubePlugin extends Plugin { | |||
337 | renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => { | 132 | renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => { |
338 | this.renderer = renderer | 133 | this.renderer = renderer |
339 | 134 | ||
340 | if (err) return this.fallbackToHttp() | 135 | if (err) return this.fallbackToHttp(done) |
341 | 136 | ||
342 | if (!this.player.paused()) { | 137 | if (!this.player.paused()) { |
343 | const playPromise = this.player.play() | 138 | const playPromise = this.player.play() |
@@ -414,13 +209,17 @@ class PeerTubePlugin extends Plugin { | |||
414 | private initializePlayer () { | 209 | private initializePlayer () { |
415 | this.initSmoothProgressBar() | 210 | this.initSmoothProgressBar() |
416 | 211 | ||
212 | this.alterInactivity() | ||
213 | |||
417 | if (this.autoplay === true) { | 214 | if (this.autoplay === true) { |
418 | this.updateVideoFile(undefined, () => this.player.play()) | 215 | this.updateVideoFile(undefined, () => this.player.play()) |
419 | } else { | 216 | } else { |
420 | this.player.one('play', () => { | 217 | // Proxify first play |
421 | this.player.pause() | 218 | const oldPlay = this.player.play.bind(this.player) |
422 | this.updateVideoFile(undefined, () => this.player.play()) | 219 | this.player.play = () => { |
423 | }) | 220 | this.updateVideoFile(undefined, () => oldPlay) |
221 | this.player.play = oldPlay | ||
222 | } | ||
424 | } | 223 | } |
425 | } | 224 | } |
426 | 225 | ||
@@ -473,7 +272,7 @@ class PeerTubePlugin extends Plugin { | |||
473 | return fetch(this.videoViewUrl, { method: 'POST' }) | 272 | return fetch(this.videoViewUrl, { method: 'POST' }) |
474 | } | 273 | } |
475 | 274 | ||
476 | private fallbackToHttp () { | 275 | private fallbackToHttp (done: Function) { |
477 | this.flushVideoFile(this.currentVideoFile, true) | 276 | this.flushVideoFile(this.currentVideoFile, true) |
478 | this.torrent = null | 277 | this.torrent = null |
479 | 278 | ||
@@ -484,6 +283,8 @@ class PeerTubePlugin extends Plugin { | |||
484 | this.player.src = this.savePlayerSrcFunction | 283 | this.player.src = this.savePlayerSrcFunction |
485 | this.player.src(httpUrl) | 284 | this.player.src(httpUrl) |
486 | this.player.play() | 285 | this.player.play() |
286 | |||
287 | return done() | ||
487 | } | 288 | } |
488 | 289 | ||
489 | private handleError (err: Error | string) { | 290 | private handleError (err: Error | string) { |
@@ -498,6 +299,25 @@ class PeerTubePlugin extends Plugin { | |||
498 | this.player.removeClass('vjs-error-display-enabled') | 299 | this.player.removeClass('vjs-error-display-enabled') |
499 | } | 300 | } |
500 | 301 | ||
302 | private alterInactivity () { | ||
303 | let saveInactivityTimeout: number | ||
304 | |||
305 | const disableInactivity = () => { | ||
306 | saveInactivityTimeout = this.player.options_.inactivityTimeout | ||
307 | this.player.options_.inactivityTimeout = 0 | ||
308 | } | ||
309 | const enableInactivity = () => { | ||
310 | // this.player.options_.inactivityTimeout = saveInactivityTimeout | ||
311 | } | ||
312 | |||
313 | const settingsDialog = this.player.children_.find(c => c.name_ === 'SettingsDialog') | ||
314 | |||
315 | this.player.controlBar.on('mouseenter', () => disableInactivity()) | ||
316 | settingsDialog.on('mouseenter', () => disableInactivity()) | ||
317 | this.player.controlBar.on('mouseleave', () => enableInactivity()) | ||
318 | settingsDialog.on('mouseleave', () => enableInactivity()) | ||
319 | } | ||
320 | |||
501 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | 321 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 |
502 | private initSmoothProgressBar () { | 322 | private initSmoothProgressBar () { |
503 | const SeekBar = videojsUntyped.getComponent('SeekBar') | 323 | const SeekBar = videojsUntyped.getComponent('SeekBar') |
@@ -520,4 +340,6 @@ class PeerTubePlugin extends Plugin { | |||
520 | } | 340 | } |
521 | } | 341 | } |
522 | } | 342 | } |
343 | |||
523 | videojsUntyped.registerPlugin('peertube', PeerTubePlugin) | 344 | videojsUntyped.registerPlugin('peertube', PeerTubePlugin) |
345 | export { PeerTubePlugin } | ||
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts new file mode 100644 index 000000000..a58fa6505 --- /dev/null +++ b/client/src/assets/player/peertube-videojs-typings.ts | |||
@@ -0,0 +1,33 @@ | |||
1 | import * as videojs from 'video.js' | ||
2 | import { VideoFile } from '../../../../shared/models/videos/video.model' | ||
3 | import { PeerTubePlugin } from './peertube-videojs-plugin' | ||
4 | |||
5 | declare module 'video.js' { | ||
6 | interface Player { | ||
7 | peertube (): PeerTubePlugin | ||
8 | } | ||
9 | } | ||
10 | |||
11 | interface VideoJSComponentInterface { | ||
12 | _player: videojs.Player | ||
13 | |||
14 | new (player: videojs.Player, options?: any) | ||
15 | |||
16 | registerComponent (name: string, obj: any) | ||
17 | } | ||
18 | |||
19 | type PeertubePluginOptions = { | ||
20 | videoFiles: VideoFile[] | ||
21 | playerElement: HTMLVideoElement | ||
22 | videoViewUrl: string | ||
23 | videoDuration: number | ||
24 | } | ||
25 | |||
26 | // videojs typings don't have some method we need | ||
27 | const videojsUntyped = videojs as any | ||
28 | |||
29 | export { | ||
30 | VideoJSComponentInterface, | ||
31 | PeertubePluginOptions, | ||
32 | videojsUntyped | ||
33 | } | ||
diff --git a/client/src/assets/player/resolution-menu-button.ts b/client/src/assets/player/resolution-menu-button.ts new file mode 100644 index 000000000..c927b084d --- /dev/null +++ b/client/src/assets/player/resolution-menu-button.ts | |||
@@ -0,0 +1,68 @@ | |||
1 | import * as videojs from 'video.js' | ||
2 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
3 | import { ResolutionMenuItem } from './resolution-menu-item' | ||
4 | |||
5 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
6 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') | ||
7 | class ResolutionMenuButton extends MenuButton { | ||
8 | label: HTMLElement | ||
9 | |||
10 | constructor (player: videojs.Player, options) { | ||
11 | options.label = 'Quality' | ||
12 | super(player, options) | ||
13 | |||
14 | this.controlText_ = 'Quality' | ||
15 | this.player = player | ||
16 | |||
17 | player.peertube().on('videoFileUpdate', () => this.updateLabel()) | ||
18 | } | ||
19 | |||
20 | createEl () { | ||
21 | const el = super.createEl() | ||
22 | |||
23 | this.labelEl_ = videojsUntyped.dom.createEl('div', { | ||
24 | className: 'vjs-resolution-value', | ||
25 | innerHTML: this.player_.peertube().getCurrentResolutionLabel() | ||
26 | }) | ||
27 | |||
28 | el.appendChild(this.labelEl_) | ||
29 | |||
30 | return el | ||
31 | } | ||
32 | |||
33 | updateARIAAttributes () { | ||
34 | this.el().setAttribute('aria-label', 'Quality') | ||
35 | } | ||
36 | |||
37 | createMenu () { | ||
38 | const menu = new Menu(this.player()) | ||
39 | |||
40 | for (const videoFile of this.player_.peertube().videoFiles) { | ||
41 | menu.addChild(new ResolutionMenuItem( | ||
42 | this.player_, | ||
43 | { | ||
44 | id: videoFile.resolution.id, | ||
45 | label: videoFile.resolution.label, | ||
46 | src: videoFile.magnetUri | ||
47 | }) | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | return menu | ||
52 | } | ||
53 | |||
54 | updateLabel () { | ||
55 | if (!this.labelEl_) return | ||
56 | |||
57 | this.labelEl_.innerHTML = this.player_.peertube().getCurrentResolutionLabel() | ||
58 | } | ||
59 | |||
60 | buildCSSClass () { | ||
61 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
62 | } | ||
63 | |||
64 | buildWrapperCSSClass () { | ||
65 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
66 | } | ||
67 | } | ||
68 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
diff --git a/client/src/assets/player/resolution-menu-item.ts b/client/src/assets/player/resolution-menu-item.ts new file mode 100644 index 000000000..95e0ed1f8 --- /dev/null +++ b/client/src/assets/player/resolution-menu-item.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
2 | |||
3 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
4 | class ResolutionMenuItem extends MenuItem { | ||
5 | |||
6 | constructor (player: videojs.Player, options) { | ||
7 | const currentResolutionId = player.peertube().getCurrentResolutionId() | ||
8 | options.selectable = true | ||
9 | options.selected = options.id === currentResolutionId | ||
10 | |||
11 | super(player, options) | ||
12 | |||
13 | this.label = options.label | ||
14 | this.id = options.id | ||
15 | |||
16 | player.peertube().on('videoFileUpdate', () => this.update()) | ||
17 | } | ||
18 | |||
19 | handleClick (event) { | ||
20 | super.handleClick(event) | ||
21 | |||
22 | this.player_.peertube().updateResolution(this.id) | ||
23 | } | ||
24 | |||
25 | update () { | ||
26 | this.selected(this.player_.peertube().getCurrentResolutionId() === this.id) | ||
27 | } | ||
28 | } | ||
29 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
30 | |||
31 | export { ResolutionMenuItem } | ||
diff --git a/client/src/assets/player/settings-menu-button.ts b/client/src/assets/player/settings-menu-button.ts new file mode 100644 index 000000000..c48e1382c --- /dev/null +++ b/client/src/assets/player/settings-menu-button.ts | |||
@@ -0,0 +1,285 @@ | |||
1 | // Author: Yanko Shterev | ||
2 | // Thanks https://github.com/yshterev/videojs-settings-menu | ||
3 | |||
4 | import * as videojs from 'video.js' | ||
5 | import { SettingsMenuItem } from './settings-menu-item' | ||
6 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
7 | import { toTitleCase } from './utils' | ||
8 | |||
9 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
10 | const Menu: VideoJSComponentInterface = videojsUntyped.getComponent('Menu') | ||
11 | const Component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | ||
12 | |||
13 | class SettingsButton extends Button { | ||
14 | constructor (player: videojs.Player, options) { | ||
15 | super(player, options) | ||
16 | |||
17 | this.playerComponent = player | ||
18 | this.dialog = this.playerComponent.addChild('settingsDialog') | ||
19 | this.dialogEl = this.dialog.el_ | ||
20 | this.menu = null | ||
21 | this.panel = this.dialog.addChild('settingsPanel') | ||
22 | this.panelChild = this.panel.addChild('settingsPanelChild') | ||
23 | |||
24 | this.addClass('vjs-settings') | ||
25 | this.el_.setAttribute('aria-label', 'Settings Button') | ||
26 | |||
27 | // Event handlers | ||
28 | this.addSettingsItemHandler = this.onAddSettingsItem.bind(this) | ||
29 | this.disposeSettingsItemHandler = this.onDisposeSettingsItem.bind(this) | ||
30 | this.playerClickHandler = this.onPlayerClick.bind(this) | ||
31 | this.userInactiveHandler = this.onUserInactive.bind(this) | ||
32 | |||
33 | this.buildMenu() | ||
34 | this.bindEvents() | ||
35 | |||
36 | // Prepare dialog | ||
37 | this.player().one('play', () => this.hideDialog()) | ||
38 | } | ||
39 | |||
40 | onPlayerClick (event: MouseEvent) { | ||
41 | const element = event.target as HTMLElement | ||
42 | if (element.classList.contains('vjs-settings') || element.parentElement.classList.contains('vjs-settings')) { | ||
43 | return | ||
44 | } | ||
45 | |||
46 | if (!this.dialog.hasClass('vjs-hidden')) { | ||
47 | this.hideDialog() | ||
48 | } | ||
49 | } | ||
50 | |||
51 | onDisposeSettingsItem (event, name: string) { | ||
52 | if (name === undefined) { | ||
53 | let children = this.menu.children() | ||
54 | |||
55 | while (children.length > 0) { | ||
56 | children[0].dispose() | ||
57 | this.menu.removeChild(children[0]) | ||
58 | } | ||
59 | |||
60 | this.addClass('vjs-hidden') | ||
61 | } else { | ||
62 | let item = this.menu.getChild(name) | ||
63 | |||
64 | if (item) { | ||
65 | item.dispose() | ||
66 | this.menu.removeChild(item) | ||
67 | } | ||
68 | } | ||
69 | |||
70 | this.hideDialog() | ||
71 | |||
72 | if (this.options_.entries.length === 0) { | ||
73 | this.addClass('vjs-hidden') | ||
74 | } | ||
75 | } | ||
76 | |||
77 | onAddSettingsItem (event, data) { | ||
78 | const [ entry, options ] = data | ||
79 | |||
80 | this.addMenuItem(entry, options) | ||
81 | this.removeClass('vjs-hidden') | ||
82 | } | ||
83 | |||
84 | onUserInactive () { | ||
85 | if (!this.dialog.hasClass('vjs-hidden')) { | ||
86 | this.hideDialog() | ||
87 | } | ||
88 | } | ||
89 | |||
90 | bindEvents () { | ||
91 | this.playerComponent.on('click', this.playerClickHandler) | ||
92 | this.playerComponent.on('addsettingsitem', this.addSettingsItemHandler) | ||
93 | this.playerComponent.on('disposesettingsitem', this.disposeSettingsItemHandler) | ||
94 | this.playerComponent.on('userinactive', this.userInactiveHandler) | ||
95 | } | ||
96 | |||
97 | buildCSSClass () { | ||
98 | return `vjs-icon-settings ${super.buildCSSClass()}` | ||
99 | } | ||
100 | |||
101 | handleClick () { | ||
102 | if (this.dialog.hasClass('vjs-hidden')) { | ||
103 | this.showDialog() | ||
104 | } else { | ||
105 | this.hideDialog() | ||
106 | } | ||
107 | } | ||
108 | |||
109 | showDialog () { | ||
110 | this.menu.el_.style.opacity = '1' | ||
111 | this.dialog.show() | ||
112 | |||
113 | this.setDialogSize(this.getComponentSize(this.menu)) | ||
114 | } | ||
115 | |||
116 | hideDialog () { | ||
117 | this.dialog.hide() | ||
118 | this.setDialogSize(this.getComponentSize(this.menu)) | ||
119 | this.menu.el_.style.opacity = '1' | ||
120 | this.resetChildren() | ||
121 | } | ||
122 | |||
123 | getComponentSize (element) { | ||
124 | let width: number = null | ||
125 | let height: number = null | ||
126 | |||
127 | // Could be component or just DOM element | ||
128 | if (element instanceof Component) { | ||
129 | width = element.el_.offsetWidth | ||
130 | height = element.el_.offsetHeight | ||
131 | |||
132 | // keep width/height as properties for direct use | ||
133 | element.width = width | ||
134 | element.height = height | ||
135 | } else { | ||
136 | width = element.offsetWidth | ||
137 | height = element.offsetHeight | ||
138 | } | ||
139 | |||
140 | return [ width, height ] | ||
141 | } | ||
142 | |||
143 | setDialogSize ([ width, height ]: number[]) { | ||
144 | if (typeof height !== 'number') { | ||
145 | return | ||
146 | } | ||
147 | |||
148 | let offset = this.options_.setup.maxHeightOffset | ||
149 | let maxHeight = this.playerComponent.el_.offsetHeight - offset | ||
150 | |||
151 | if (height > maxHeight) { | ||
152 | height = maxHeight | ||
153 | width += 17 | ||
154 | this.panel.el_.style.maxHeight = `${height}px` | ||
155 | } else if (this.panel.el_.style.maxHeight !== '') { | ||
156 | this.panel.el_.style.maxHeight = '' | ||
157 | } | ||
158 | |||
159 | this.dialogEl.style.width = `${width}px` | ||
160 | this.dialogEl.style.height = `${height}px` | ||
161 | } | ||
162 | |||
163 | buildMenu () { | ||
164 | this.menu = new Menu(this.player()) | ||
165 | this.menu.addClass('vjs-main-menu') | ||
166 | let entries = this.options_.entries | ||
167 | |||
168 | if (entries.length === 0) { | ||
169 | this.addClass('vjs-hidden') | ||
170 | this.panelChild.addChild(this.menu) | ||
171 | return | ||
172 | } | ||
173 | |||
174 | for (let entry of entries) { | ||
175 | this.addMenuItem(entry, this.options_) | ||
176 | } | ||
177 | |||
178 | this.panelChild.addChild(this.menu) | ||
179 | } | ||
180 | |||
181 | addMenuItem (entry, options) { | ||
182 | const openSubMenu = function () { | ||
183 | if (videojsUntyped.dom.hasClass(this.el_, 'open')) { | ||
184 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
185 | } else { | ||
186 | videojsUntyped.dom.addClass(this.el_, 'open') | ||
187 | } | ||
188 | } | ||
189 | |||
190 | options.name = toTitleCase(entry) | ||
191 | let settingsMenuItem = new SettingsMenuItem(this.player(), options, entry, this as any) | ||
192 | |||
193 | this.menu.addChild(settingsMenuItem) | ||
194 | |||
195 | // Hide children to avoid sub menus stacking on top of each other | ||
196 | // or having multiple menus open | ||
197 | settingsMenuItem.on('click', videojs.bind(this, this.hideChildren)) | ||
198 | |||
199 | // Whether to add or remove selected class on the settings sub menu element | ||
200 | settingsMenuItem.on('click', openSubMenu) | ||
201 | } | ||
202 | |||
203 | resetChildren () { | ||
204 | for (let menuChild of this.menu.children()) { | ||
205 | menuChild.reset() | ||
206 | } | ||
207 | } | ||
208 | |||
209 | /** | ||
210 | * Hide all the sub menus | ||
211 | */ | ||
212 | hideChildren () { | ||
213 | for (let menuChild of this.menu.children()) { | ||
214 | menuChild.hideSubMenu() | ||
215 | } | ||
216 | } | ||
217 | |||
218 | } | ||
219 | |||
220 | class SettingsPanel extends Component { | ||
221 | constructor (player: videojs.Player, options) { | ||
222 | super(player, options) | ||
223 | } | ||
224 | |||
225 | createEl () { | ||
226 | return super.createEl('div', { | ||
227 | className: 'vjs-settings-panel', | ||
228 | innerHTML: '', | ||
229 | tabIndex: -1 | ||
230 | }) | ||
231 | } | ||
232 | } | ||
233 | |||
234 | class SettingsPanelChild extends Component { | ||
235 | constructor (player: videojs.Player, options) { | ||
236 | super(player, options) | ||
237 | } | ||
238 | |||
239 | createEl () { | ||
240 | return super.createEl('div', { | ||
241 | className: 'vjs-settings-panel-child', | ||
242 | innerHTML: '', | ||
243 | tabIndex: -1 | ||
244 | }) | ||
245 | } | ||
246 | } | ||
247 | |||
248 | class SettingsDialog extends Component { | ||
249 | constructor (player: videojs.Player, options) { | ||
250 | super(player, options) | ||
251 | this.hide() | ||
252 | } | ||
253 | |||
254 | /** | ||
255 | * Create the component's DOM element | ||
256 | * | ||
257 | * @return {Element} | ||
258 | * @method createEl | ||
259 | */ | ||
260 | createEl () { | ||
261 | const uniqueId = this.id_ | ||
262 | const dialogLabelId = 'TTsettingsDialogLabel-' + uniqueId | ||
263 | const dialogDescriptionId = 'TTsettingsDialogDescription-' + uniqueId | ||
264 | |||
265 | return super.createEl('div', { | ||
266 | className: 'vjs-settings-dialog vjs-modal-overlay', | ||
267 | innerHTML: '', | ||
268 | tabIndex: -1 | ||
269 | }, { | ||
270 | 'role': 'dialog', | ||
271 | 'aria-labelledby': dialogLabelId, | ||
272 | 'aria-describedby': dialogDescriptionId | ||
273 | }) | ||
274 | } | ||
275 | |||
276 | } | ||
277 | |||
278 | SettingsButton.prototype.controlText_ = 'Settings Button' | ||
279 | |||
280 | Component.registerComponent('SettingsButton', SettingsButton) | ||
281 | Component.registerComponent('SettingsDialog', SettingsDialog) | ||
282 | Component.registerComponent('SettingsPanel', SettingsPanel) | ||
283 | Component.registerComponent('SettingsPanelChild', SettingsPanelChild) | ||
284 | |||
285 | export { SettingsButton, SettingsDialog, SettingsPanel, SettingsPanelChild } | ||
diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts new file mode 100644 index 000000000..e979ae088 --- /dev/null +++ b/client/src/assets/player/settings-menu-item.ts | |||
@@ -0,0 +1,313 @@ | |||
1 | // Author: Yanko Shterev | ||
2 | // Thanks https://github.com/yshterev/videojs-settings-menu | ||
3 | |||
4 | import { toTitleCase } from './utils' | ||
5 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
6 | |||
7 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') | ||
8 | const component: VideoJSComponentInterface = videojsUntyped.getComponent('Component') | ||
9 | |||
10 | class SettingsMenuItem extends MenuItem { | ||
11 | |||
12 | constructor (player: videojs.Player, options, entry: string, menuButton: VideoJSComponentInterface) { | ||
13 | super(player, options) | ||
14 | |||
15 | this.settingsButton = menuButton | ||
16 | this.dialog = this.settingsButton.dialog | ||
17 | this.mainMenu = this.settingsButton.menu | ||
18 | this.panel = this.dialog.getChild('settingsPanel') | ||
19 | this.panelChild = this.panel.getChild('settingsPanelChild') | ||
20 | this.panelChildEl = this.panelChild.el_ | ||
21 | |||
22 | this.size = null | ||
23 | |||
24 | // keep state of what menu type is loading next | ||
25 | this.menuToLoad = 'mainmenu' | ||
26 | |||
27 | const subMenuName = toTitleCase(entry) | ||
28 | const SubMenuComponent = videojsUntyped.getComponent(subMenuName) | ||
29 | |||
30 | if (!SubMenuComponent) { | ||
31 | throw new Error(`Component ${subMenuName} does not exist`) | ||
32 | } | ||
33 | this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) | ||
34 | |||
35 | this.eventHandlers() | ||
36 | |||
37 | player.ready(() => { | ||
38 | this.build() | ||
39 | this.reset() | ||
40 | }) | ||
41 | } | ||
42 | |||
43 | eventHandlers () { | ||
44 | this.submenuClickHandler = this.onSubmenuClick.bind(this) | ||
45 | this.transitionEndHandler = this.onTransitionEnd.bind(this) | ||
46 | } | ||
47 | |||
48 | onSubmenuClick (event) { | ||
49 | let target = null | ||
50 | |||
51 | if (event.type === 'tap') { | ||
52 | target = event.target | ||
53 | } else { | ||
54 | target = event.currentTarget | ||
55 | } | ||
56 | |||
57 | if (target.classList.contains('vjs-back-button')) { | ||
58 | this.loadMainMenu() | ||
59 | return | ||
60 | } | ||
61 | |||
62 | // To update the sub menu value on click, setTimeout is needed because | ||
63 | // updating the value is not instant | ||
64 | setTimeout(() => this.update(event), 0) | ||
65 | } | ||
66 | |||
67 | /** | ||
68 | * Create the component's DOM element | ||
69 | * | ||
70 | * @return {Element} | ||
71 | * @method createEl | ||
72 | */ | ||
73 | createEl () { | ||
74 | const el = videojsUntyped.dom.createEl('li', { | ||
75 | className: 'vjs-menu-item' | ||
76 | }) | ||
77 | |||
78 | this.settingsSubMenuTitleEl_ = videojsUntyped.dom.createEl('div', { | ||
79 | className: 'vjs-settings-sub-menu-title' | ||
80 | }) | ||
81 | |||
82 | el.appendChild(this.settingsSubMenuTitleEl_) | ||
83 | |||
84 | this.settingsSubMenuValueEl_ = videojsUntyped.dom.createEl('div', { | ||
85 | className: 'vjs-settings-sub-menu-value' | ||
86 | }) | ||
87 | |||
88 | el.appendChild(this.settingsSubMenuValueEl_) | ||
89 | |||
90 | this.settingsSubMenuEl_ = videojsUntyped.dom.createEl('div', { | ||
91 | className: 'vjs-settings-sub-menu' | ||
92 | }) | ||
93 | |||
94 | return el | ||
95 | } | ||
96 | |||
97 | /** | ||
98 | * Handle click on menu item | ||
99 | * | ||
100 | * @method handleClick | ||
101 | */ | ||
102 | handleClick () { | ||
103 | this.menuToLoad = 'submenu' | ||
104 | // Remove open class to ensure only the open submenu gets this class | ||
105 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
106 | |||
107 | super.handleClick() | ||
108 | |||
109 | this.mainMenu.el_.style.opacity = '0' | ||
110 | // Whether to add or remove vjs-hidden class on the settingsSubMenuEl element | ||
111 | if (videojsUntyped.dom.hasClass(this.settingsSubMenuEl_, 'vjs-hidden')) { | ||
112 | videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
113 | |||
114 | // animation not played without timeout | ||
115 | setTimeout(() => { | ||
116 | this.settingsSubMenuEl_.style.opacity = '1' | ||
117 | this.settingsSubMenuEl_.style.marginRight = '0px' | ||
118 | }, 0) | ||
119 | |||
120 | this.settingsButton.setDialogSize(this.size) | ||
121 | } else { | ||
122 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
123 | } | ||
124 | } | ||
125 | |||
126 | /** | ||
127 | * Create back button | ||
128 | * | ||
129 | * @method createBackButton | ||
130 | */ | ||
131 | createBackButton () { | ||
132 | const button = this.subMenu.menu.addChild('MenuItem', {}, 0) | ||
133 | button.name_ = 'BackButton' | ||
134 | button.addClass('vjs-back-button') | ||
135 | button.el_.innerHTML = this.subMenu.controlText_ | ||
136 | } | ||
137 | |||
138 | /** | ||
139 | * Add/remove prefixed event listener for CSS Transition | ||
140 | * | ||
141 | * @method PrefixedEvent | ||
142 | */ | ||
143 | PrefixedEvent (element, type, callback, action = 'addEvent') { | ||
144 | let prefix = ['webkit', 'moz', 'MS', 'o', ''] | ||
145 | |||
146 | for (let p = 0; p < prefix.length; p++) { | ||
147 | if (!prefix[p]) { | ||
148 | type = type.toLowerCase() | ||
149 | } | ||
150 | |||
151 | if (action === 'addEvent') { | ||
152 | element.addEventListener(prefix[p] + type, callback, false) | ||
153 | } else if (action === 'removeEvent') { | ||
154 | element.removeEventListener(prefix[p] + type, callback, false) | ||
155 | } | ||
156 | } | ||
157 | } | ||
158 | |||
159 | onTransitionEnd (event) { | ||
160 | if (event.propertyName !== 'margin-right') { | ||
161 | return | ||
162 | } | ||
163 | |||
164 | if (this.menuToLoad === 'mainmenu') { | ||
165 | // hide submenu | ||
166 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
167 | |||
168 | // reset opacity to 0 | ||
169 | this.settingsSubMenuEl_.style.opacity = '0' | ||
170 | } | ||
171 | } | ||
172 | |||
173 | reset () { | ||
174 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
175 | this.settingsSubMenuEl_.style.opacity = '0' | ||
176 | this.setMargin() | ||
177 | } | ||
178 | |||
179 | loadMainMenu () { | ||
180 | this.menuToLoad = 'mainmenu' | ||
181 | this.mainMenu.show() | ||
182 | this.mainMenu.el_.style.opacity = '0' | ||
183 | |||
184 | // back button will always take you to main menu, so set dialog sizes | ||
185 | this.settingsButton.setDialogSize([this.mainMenu.width, this.mainMenu.height]) | ||
186 | |||
187 | // animation not triggered without timeout (some async stuff ?!?) | ||
188 | setTimeout(() => { | ||
189 | // animate margin and opacity before hiding the submenu | ||
190 | // this triggers CSS Transition event | ||
191 | this.setMargin() | ||
192 | this.mainMenu.el_.style.opacity = '1' | ||
193 | }, 0) | ||
194 | } | ||
195 | |||
196 | build () { | ||
197 | const saveUpdateLabel = this.subMenu.updateLabel | ||
198 | this.subMenu.updateLabel = () => { | ||
199 | this.update() | ||
200 | |||
201 | saveUpdateLabel.call(this.subMenu) | ||
202 | } | ||
203 | |||
204 | this.settingsSubMenuTitleEl_.innerHTML = this.subMenu.controlText_ | ||
205 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_) | ||
206 | this.panelChildEl.appendChild(this.settingsSubMenuEl_) | ||
207 | this.update() | ||
208 | |||
209 | this.createBackButton() | ||
210 | this.getSize() | ||
211 | this.bindClickEvents() | ||
212 | |||
213 | // prefixed event listeners for CSS TransitionEnd | ||
214 | this.PrefixedEvent( | ||
215 | this.settingsSubMenuEl_, | ||
216 | 'TransitionEnd', | ||
217 | this.transitionEndHandler, | ||
218 | 'addEvent' | ||
219 | ) | ||
220 | } | ||
221 | |||
222 | update (event?: Event) { | ||
223 | let target = null | ||
224 | let subMenu = this.subMenu.name() | ||
225 | |||
226 | if (event && event.type === 'tap') { | ||
227 | target = event.target | ||
228 | } else if (event) { | ||
229 | target = event.currentTarget | ||
230 | } | ||
231 | |||
232 | // Playback rate menu button doesn't get a vjs-selected class | ||
233 | // or sets options_['selected'] on the selected playback rate. | ||
234 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton | ||
235 | if (subMenu === 'PlaybackRateMenuButton') { | ||
236 | setTimeout(() => this.settingsSubMenuValueEl_.innerHTML = this.subMenu.labelEl_.innerHTML, 250) | ||
237 | } else { | ||
238 | // Loop trough the submenu items to find the selected child | ||
239 | for (let subMenuItem of this.subMenu.menu.children_) { | ||
240 | if (!(subMenuItem instanceof component)) { | ||
241 | continue | ||
242 | } | ||
243 | |||
244 | switch (subMenu) { | ||
245 | case 'SubtitlesButton': | ||
246 | case 'CaptionsButton': | ||
247 | // subtitlesButton entering default check twice and overwriting | ||
248 | // selected label in main manu | ||
249 | if (subMenuItem.hasClass('vjs-selected')) { | ||
250 | this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label | ||
251 | } | ||
252 | break | ||
253 | |||
254 | default: | ||
255 | // Set submenu value based on what item is selected | ||
256 | if (subMenuItem.options_.selected || subMenuItem.hasClass('vjs-selected')) { | ||
257 | this.settingsSubMenuValueEl_.innerHTML = subMenuItem.options_.label | ||
258 | } | ||
259 | } | ||
260 | } | ||
261 | } | ||
262 | |||
263 | if (target && !target.classList.contains('vjs-back-button')) { | ||
264 | this.settingsButton.hideDialog() | ||
265 | } | ||
266 | } | ||
267 | |||
268 | bindClickEvents () { | ||
269 | for (let item of this.subMenu.menu.children()) { | ||
270 | if (!(item instanceof component)) { | ||
271 | continue | ||
272 | } | ||
273 | item.on(['tap', 'click'], this.submenuClickHandler) | ||
274 | } | ||
275 | } | ||
276 | |||
277 | // save size of submenus on first init | ||
278 | // if number of submenu items change dynamically more logic will be needed | ||
279 | getSize () { | ||
280 | this.dialog.removeClass('vjs-hidden') | ||
281 | this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_) | ||
282 | this.setMargin() | ||
283 | this.dialog.addClass('vjs-hidden') | ||
284 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
285 | } | ||
286 | |||
287 | setMargin () { | ||
288 | let [width] = this.size | ||
289 | |||
290 | this.settingsSubMenuEl_.style.marginRight = `-${width}px` | ||
291 | } | ||
292 | |||
293 | /** | ||
294 | * Hide the sub menu | ||
295 | */ | ||
296 | hideSubMenu () { | ||
297 | // after removing settings item this.el_ === null | ||
298 | if (!this.el_) { | ||
299 | return | ||
300 | } | ||
301 | |||
302 | if (videojsUntyped.dom.hasClass(this.el_, 'open')) { | ||
303 | videojsUntyped.dom.addClass(this.settingsSubMenuEl_, 'vjs-hidden') | ||
304 | videojsUntyped.dom.removeClass(this.el_, 'open') | ||
305 | } | ||
306 | } | ||
307 | |||
308 | } | ||
309 | |||
310 | SettingsMenuItem.prototype.contentElType = 'button' | ||
311 | videojsUntyped.registerComponent('SettingsMenuItem', SettingsMenuItem) | ||
312 | |||
313 | export { SettingsMenuItem } | ||
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts new file mode 100644 index 000000000..7a99dba1a --- /dev/null +++ b/client/src/assets/player/utils.ts | |||
@@ -0,0 +1,72 @@ | |||
1 | function toTitleCase (str: string) { | ||
2 | return str.charAt(0).toUpperCase() + str.slice(1) | ||
3 | } | ||
4 | |||
5 | // https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts | ||
6 | // Don't import all Angular stuff, just copy the code with shame | ||
7 | const dictionaryBytes: Array<{max: number, type: string}> = [ | ||
8 | { max: 1024, type: 'B' }, | ||
9 | { max: 1048576, type: 'KB' }, | ||
10 | { max: 1073741824, type: 'MB' }, | ||
11 | { max: 1.0995116e12, type: 'GB' } | ||
12 | ] | ||
13 | function bytes (value) { | ||
14 | const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] | ||
15 | const calc = Math.floor(value / (format.max / 1024)).toString() | ||
16 | |||
17 | return [ calc, format.type ] | ||
18 | } | ||
19 | |||
20 | function getStoredVolume () { | ||
21 | const value = getLocalStorage('volume') | ||
22 | if (value !== null && value !== undefined) { | ||
23 | const valueNumber = parseFloat(value) | ||
24 | if (isNaN(valueNumber)) return undefined | ||
25 | |||
26 | return valueNumber | ||
27 | } | ||
28 | |||
29 | return undefined | ||
30 | } | ||
31 | |||
32 | function getStoredMute () { | ||
33 | const value = getLocalStorage('mute') | ||
34 | if (value !== null && value !== undefined) return value === 'true' | ||
35 | |||
36 | return undefined | ||
37 | } | ||
38 | |||
39 | function saveVolumeInStore (value: number) { | ||
40 | return setLocalStorage('volume', value.toString()) | ||
41 | } | ||
42 | |||
43 | function saveMuteInStore (value: boolean) { | ||
44 | return setLocalStorage('mute', value.toString()) | ||
45 | } | ||
46 | |||
47 | export { | ||
48 | toTitleCase, | ||
49 | getStoredVolume, | ||
50 | saveVolumeInStore, | ||
51 | saveMuteInStore, | ||
52 | getStoredMute, | ||
53 | bytes | ||
54 | } | ||
55 | |||
56 | // --------------------------------------------------------------------------- | ||
57 | |||
58 | const KEY_PREFIX = 'peertube-videojs-' | ||
59 | |||
60 | function getLocalStorage (key: string) { | ||
61 | try { | ||
62 | return localStorage.getItem(KEY_PREFIX + key) | ||
63 | } catch { | ||
64 | return undefined | ||
65 | } | ||
66 | } | ||
67 | |||
68 | function setLocalStorage (key: string, value: string) { | ||
69 | try { | ||
70 | localStorage.setItem(KEY_PREFIX + key, value) | ||
71 | } catch { /* empty */ } | ||
72 | } | ||
diff --git a/client/src/assets/player/webtorrent-info-button.ts b/client/src/assets/player/webtorrent-info-button.ts new file mode 100644 index 000000000..8a79e0e50 --- /dev/null +++ b/client/src/assets/player/webtorrent-info-button.ts | |||
@@ -0,0 +1,101 @@ | |||
1 | import { VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings' | ||
2 | import { bytes } from './utils' | ||
3 | |||
4 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') | ||
5 | class WebtorrentInfoButton extends Button { | ||
6 | createEl () { | ||
7 | const div = videojsUntyped.dom.createEl('div', { | ||
8 | className: 'vjs-peertube' | ||
9 | }) | ||
10 | const subDivWebtorrent = videojsUntyped.dom.createEl('div', { | ||
11 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info | ||
12 | }) | ||
13 | div.appendChild(subDivWebtorrent) | ||
14 | |||
15 | const downloadIcon = videojsUntyped.dom.createEl('span', { | ||
16 | className: 'icon icon-download' | ||
17 | }) | ||
18 | subDivWebtorrent.appendChild(downloadIcon) | ||
19 | |||
20 | const downloadSpeedText = videojsUntyped.dom.createEl('span', { | ||
21 | className: 'download-speed-text' | ||
22 | }) | ||
23 | const downloadSpeedNumber = videojsUntyped.dom.createEl('span', { | ||
24 | className: 'download-speed-number' | ||
25 | }) | ||
26 | const downloadSpeedUnit = videojsUntyped.dom.createEl('span') | ||
27 | downloadSpeedText.appendChild(downloadSpeedNumber) | ||
28 | downloadSpeedText.appendChild(downloadSpeedUnit) | ||
29 | subDivWebtorrent.appendChild(downloadSpeedText) | ||
30 | |||
31 | const uploadIcon = videojsUntyped.dom.createEl('span', { | ||
32 | className: 'icon icon-upload' | ||
33 | }) | ||
34 | subDivWebtorrent.appendChild(uploadIcon) | ||
35 | |||
36 | const uploadSpeedText = videojsUntyped.dom.createEl('span', { | ||
37 | className: 'upload-speed-text' | ||
38 | }) | ||
39 | const uploadSpeedNumber = videojsUntyped.dom.createEl('span', { | ||
40 | className: 'upload-speed-number' | ||
41 | }) | ||
42 | const uploadSpeedUnit = videojsUntyped.dom.createEl('span') | ||
43 | uploadSpeedText.appendChild(uploadSpeedNumber) | ||
44 | uploadSpeedText.appendChild(uploadSpeedUnit) | ||
45 | subDivWebtorrent.appendChild(uploadSpeedText) | ||
46 | |||
47 | const peersText = videojsUntyped.dom.createEl('span', { | ||
48 | className: 'peers-text' | ||
49 | }) | ||
50 | const peersNumber = videojsUntyped.dom.createEl('span', { | ||
51 | className: 'peers-number' | ||
52 | }) | ||
53 | subDivWebtorrent.appendChild(peersNumber) | ||
54 | subDivWebtorrent.appendChild(peersText) | ||
55 | |||
56 | const subDivHttp = videojsUntyped.dom.createEl('div', { | ||
57 | className: 'vjs-peertube-hidden' | ||
58 | }) | ||
59 | const subDivHttpText = videojsUntyped.dom.createEl('span', { | ||
60 | className: 'peers-number', | ||
61 | textContent: 'HTTP' | ||
62 | }) | ||
63 | const subDivFallbackText = videojsUntyped.dom.createEl('span', { | ||
64 | className: 'peers-text', | ||
65 | textContent: 'fallback' | ||
66 | }) | ||
67 | |||
68 | subDivHttp.appendChild(subDivHttpText) | ||
69 | subDivHttp.appendChild(subDivFallbackText) | ||
70 | div.appendChild(subDivHttp) | ||
71 | |||
72 | this.player_.peertube().on('torrentInfo', (event, data) => { | ||
73 | // We are in HTTP fallback | ||
74 | if (!data) { | ||
75 | subDivHttp.className = 'vjs-peertube-displayed' | ||
76 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
77 | |||
78 | return | ||
79 | } | ||
80 | |||
81 | const downloadSpeed = bytes(data.downloadSpeed) | ||
82 | const uploadSpeed = bytes(data.uploadSpeed) | ||
83 | const numPeers = data.numPeers | ||
84 | |||
85 | downloadSpeedNumber.textContent = downloadSpeed[ 0 ] | ||
86 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] | ||
87 | |||
88 | uploadSpeedNumber.textContent = uploadSpeed[ 0 ] | ||
89 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | ||
90 | |||
91 | peersNumber.textContent = numPeers | ||
92 | peersText.textContent = ' peers' | ||
93 | |||
94 | subDivHttp.className = 'vjs-peertube-hidden' | ||
95 | subDivWebtorrent.className = 'vjs-peertube-displayed' | ||
96 | }) | ||
97 | |||
98 | return div | ||
99 | } | ||
100 | } | ||
101 | Button.registerComponent('WebTorrentButton', WebtorrentInfoButton) | ||