diff options
Diffstat (limited to 'client/src/assets')
52 files changed, 1989 insertions, 2509 deletions
diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts index 9b87afc4a..d34188ea7 100644 --- a/client/src/assets/player/index.ts +++ b/client/src/assets/player/index.ts | |||
@@ -1,2 +1,2 @@ | |||
1 | export * from './peertube-player-manager' | 1 | export * from './peertube-player' |
2 | export * from './types' | 2 | export * from './types' |
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts deleted file mode 100644 index 66d9c7298..000000000 --- a/client/src/assets/player/peertube-player-manager.ts +++ /dev/null | |||
@@ -1,277 +0,0 @@ | |||
1 | import '@peertube/videojs-contextmenu' | ||
2 | import './shared/upnext/end-card' | ||
3 | import './shared/upnext/upnext-plugin' | ||
4 | import './shared/stats/stats-card' | ||
5 | import './shared/stats/stats-plugin' | ||
6 | import './shared/bezels/bezels-plugin' | ||
7 | import './shared/peertube/peertube-plugin' | ||
8 | import './shared/resolutions/peertube-resolutions-plugin' | ||
9 | import './shared/control-bar/storyboard-plugin' | ||
10 | import './shared/control-bar/next-previous-video-button' | ||
11 | import './shared/control-bar/p2p-info-button' | ||
12 | import './shared/control-bar/peertube-link-button' | ||
13 | import './shared/control-bar/peertube-load-progress-bar' | ||
14 | import './shared/control-bar/theater-button' | ||
15 | import './shared/control-bar/peertube-live-display' | ||
16 | import './shared/settings/resolution-menu-button' | ||
17 | import './shared/settings/resolution-menu-item' | ||
18 | import './shared/settings/settings-dialog' | ||
19 | import './shared/settings/settings-menu-button' | ||
20 | import './shared/settings/settings-menu-item' | ||
21 | import './shared/settings/settings-panel' | ||
22 | import './shared/settings/settings-panel-child' | ||
23 | import './shared/playlist/playlist-plugin' | ||
24 | import './shared/mobile/peertube-mobile-plugin' | ||
25 | import './shared/mobile/peertube-mobile-buttons' | ||
26 | import './shared/hotkeys/peertube-hotkeys-plugin' | ||
27 | import './shared/metrics/metrics-plugin' | ||
28 | import videojs from 'video.js' | ||
29 | import { logger } from '@root-helpers/logger' | ||
30 | import { PluginsManager } from '@root-helpers/plugins-manager' | ||
31 | import { isMobile } from '@root-helpers/web-browser' | ||
32 | import { saveAverageBandwidth } from './peertube-player-local-storage' | ||
33 | import { ManagerOptionsBuilder } from './shared/manager-options' | ||
34 | import { TranslationsManager } from './translations-manager' | ||
35 | import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types' | ||
36 | |||
37 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
38 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' | ||
39 | |||
40 | const CaptionsButton = videojs.getComponent('CaptionsButton') as any | ||
41 | // Change Captions to Subtitles/CC | ||
42 | CaptionsButton.prototype.controlText_ = 'Subtitles/CC' | ||
43 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
44 | CaptionsButton.prototype.label_ = ' ' | ||
45 | |||
46 | // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged | ||
47 | const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any | ||
48 | if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { | ||
49 | PlayProgressBar.prototype.options_.children.push('timeTooltip') | ||
50 | } | ||
51 | |||
52 | export class PeertubePlayerManager { | ||
53 | private static playerElementClassName: string | ||
54 | private static playerElementAttributes: { name: string, value: string }[] = [] | ||
55 | |||
56 | private static onPlayerChange: (player: videojs.Player) => void | ||
57 | private static alreadyPlayed = false | ||
58 | private static pluginsManager: PluginsManager | ||
59 | |||
60 | private static videojsDecodeErrors = 0 | ||
61 | |||
62 | private static p2pMediaLoaderModule: any | ||
63 | |||
64 | static initState () { | ||
65 | this.alreadyPlayed = false | ||
66 | } | ||
67 | |||
68 | static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { | ||
69 | this.pluginsManager = options.pluginsManager | ||
70 | |||
71 | this.onPlayerChange = onPlayerChange | ||
72 | |||
73 | this.playerElementClassName = options.common.playerElement.className | ||
74 | |||
75 | for (const name of options.common.playerElement.getAttributeNames()) { | ||
76 | this.playerElementAttributes.push({ name, value: options.common.playerElement.getAttribute(name) }) | ||
77 | } | ||
78 | |||
79 | if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin') | ||
80 | if (mode === 'p2p-media-loader') { | ||
81 | const [ p2pMediaLoaderModule ] = await Promise.all([ | ||
82 | import('@peertube/p2p-media-loader-hlsjs'), | ||
83 | import('./shared/p2p-media-loader/p2p-media-loader-plugin') | ||
84 | ]) | ||
85 | |||
86 | this.p2pMediaLoaderModule = p2pMediaLoaderModule | ||
87 | } | ||
88 | |||
89 | await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) | ||
90 | |||
91 | return this.buildPlayer(mode, options) | ||
92 | } | ||
93 | |||
94 | private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> { | ||
95 | const videojsOptionsBuilder = new ManagerOptionsBuilder(mode, options, this.p2pMediaLoaderModule) | ||
96 | |||
97 | const videojsOptions = await this.pluginsManager.runHook( | ||
98 | 'filter:internal.player.videojs.options.result', | ||
99 | videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed) | ||
100 | ) | ||
101 | |||
102 | const self = this | ||
103 | return new Promise(res => { | ||
104 | videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { | ||
105 | const player = this | ||
106 | |||
107 | if (!isNaN(+options.common.playbackRate)) { | ||
108 | player.playbackRate(+options.common.playbackRate) | ||
109 | } | ||
110 | |||
111 | let alreadyFallback = false | ||
112 | |||
113 | const handleError = () => { | ||
114 | if (alreadyFallback) return | ||
115 | alreadyFallback = true | ||
116 | |||
117 | if (mode === 'p2p-media-loader') { | ||
118 | self.tryToRecoverHLSError(player.error(), player, options) | ||
119 | } else { | ||
120 | self.maybeFallbackToWebTorrent(mode, player, options) | ||
121 | } | ||
122 | } | ||
123 | |||
124 | player.one('error', () => handleError()) | ||
125 | |||
126 | player.one('play', () => { | ||
127 | self.alreadyPlayed = true | ||
128 | }) | ||
129 | |||
130 | self.addContextMenu(videojsOptionsBuilder, player, options.common) | ||
131 | |||
132 | if (isMobile()) player.peertubeMobile() | ||
133 | if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive }) | ||
134 | if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') | ||
135 | |||
136 | player.bezels() | ||
137 | |||
138 | player.stats({ | ||
139 | videoUUID: options.common.videoUUID, | ||
140 | videoIsLive: options.common.isLive, | ||
141 | mode, | ||
142 | p2pEnabled: options.common.p2pEnabled | ||
143 | }) | ||
144 | |||
145 | if (options.common.storyboard) { | ||
146 | player.storyboard(options.common.storyboard) | ||
147 | } | ||
148 | |||
149 | player.on('p2pInfo', (_, data: PlayerNetworkInfo) => { | ||
150 | if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return | ||
151 | |||
152 | saveAverageBandwidth(data.bandwidthEstimate) | ||
153 | }) | ||
154 | |||
155 | const offlineNotificationElem = document.createElement('div') | ||
156 | offlineNotificationElem.classList.add('vjs-peertube-offline-notification') | ||
157 | offlineNotificationElem.innerText = player.localize('You seem to be offline and the video may not work') | ||
158 | |||
159 | let offlineNotificationElemAdded = false | ||
160 | |||
161 | const handleOnline = () => { | ||
162 | if (!offlineNotificationElemAdded) return | ||
163 | |||
164 | player.el().removeChild(offlineNotificationElem) | ||
165 | offlineNotificationElemAdded = false | ||
166 | |||
167 | logger.info('The browser is online') | ||
168 | } | ||
169 | |||
170 | const handleOffline = () => { | ||
171 | if (offlineNotificationElemAdded) return | ||
172 | |||
173 | player.el().appendChild(offlineNotificationElem) | ||
174 | offlineNotificationElemAdded = true | ||
175 | |||
176 | logger.info('The browser is offline') | ||
177 | } | ||
178 | |||
179 | window.addEventListener('online', handleOnline) | ||
180 | window.addEventListener('offline', handleOffline) | ||
181 | |||
182 | player.on('dispose', () => { | ||
183 | window.removeEventListener('online', handleOnline) | ||
184 | window.removeEventListener('offline', handleOffline) | ||
185 | }) | ||
186 | |||
187 | return res(player) | ||
188 | }) | ||
189 | }) | ||
190 | } | ||
191 | |||
192 | private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) { | ||
193 | if (err.code === MediaError.MEDIA_ERR_DECODE) { | ||
194 | |||
195 | // Display a notification to user | ||
196 | if (this.videojsDecodeErrors === 0) { | ||
197 | options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.')) | ||
198 | } | ||
199 | |||
200 | if (this.videojsDecodeErrors === 20) { | ||
201 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) | ||
202 | return | ||
203 | } | ||
204 | |||
205 | logger.info('Fast forwarding HLS to recover from an error.') | ||
206 | |||
207 | this.videojsDecodeErrors++ | ||
208 | |||
209 | options.common.startTime = currentPlayer.currentTime() + 2 | ||
210 | options.common.autoplay = true | ||
211 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) | ||
212 | |||
213 | const newPlayer = await this.buildPlayer('p2p-media-loader', options) | ||
214 | this.onPlayerChange(newPlayer) | ||
215 | } else { | ||
216 | this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options) | ||
217 | } | ||
218 | } | ||
219 | |||
220 | private static async maybeFallbackToWebTorrent ( | ||
221 | currentMode: PlayerMode, | ||
222 | currentPlayer: videojs.Player, | ||
223 | options: PeertubePlayerManagerOptions | ||
224 | ) { | ||
225 | if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') { | ||
226 | currentPlayer.peertube().displayFatalError() | ||
227 | return | ||
228 | } | ||
229 | |||
230 | logger.info('Fallback to webtorrent.') | ||
231 | |||
232 | this.rebuildAndUpdateVideoElement(currentPlayer, options.common) | ||
233 | |||
234 | await import('./shared/webtorrent/webtorrent-plugin') | ||
235 | |||
236 | const newPlayer = await this.buildPlayer('webtorrent', options) | ||
237 | this.onPlayerChange(newPlayer) | ||
238 | } | ||
239 | |||
240 | private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) { | ||
241 | const newVideoElement = document.createElement('video') | ||
242 | |||
243 | // Reset class | ||
244 | newVideoElement.className = this.playerElementClassName | ||
245 | |||
246 | // Reapply attributes | ||
247 | for (const { name, value } of this.playerElementAttributes) { | ||
248 | newVideoElement.setAttribute(name, value) | ||
249 | } | ||
250 | |||
251 | // VideoJS wraps our video element inside a div | ||
252 | let currentParentPlayerElement = commonOptions.playerElement.parentNode | ||
253 | // Fix on IOS, don't ask me why | ||
254 | if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode | ||
255 | |||
256 | currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement) | ||
257 | |||
258 | commonOptions.playerElement = newVideoElement | ||
259 | commonOptions.onPlayerElementChange(newVideoElement) | ||
260 | |||
261 | player.dispose() | ||
262 | |||
263 | return newVideoElement | ||
264 | } | ||
265 | |||
266 | private static addContextMenu (optionsBuilder: ManagerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) { | ||
267 | const options = optionsBuilder.getContextMenuOptions(player, commonOptions) | ||
268 | |||
269 | player.contextmenuUI(options) | ||
270 | } | ||
271 | } | ||
272 | |||
273 | // ############################################################################ | ||
274 | |||
275 | export { | ||
276 | videojs | ||
277 | } | ||
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts new file mode 100644 index 000000000..a7a2b4065 --- /dev/null +++ b/client/src/assets/player/peertube-player.ts | |||
@@ -0,0 +1,522 @@ | |||
1 | import '@peertube/videojs-contextmenu' | ||
2 | import './shared/upnext/end-card' | ||
3 | import './shared/upnext/upnext-plugin' | ||
4 | import './shared/stats/stats-card' | ||
5 | import './shared/stats/stats-plugin' | ||
6 | import './shared/bezels/bezels-plugin' | ||
7 | import './shared/peertube/peertube-plugin' | ||
8 | import './shared/resolutions/peertube-resolutions-plugin' | ||
9 | import './shared/control-bar/storyboard-plugin' | ||
10 | import './shared/control-bar/next-previous-video-button' | ||
11 | import './shared/control-bar/p2p-info-button' | ||
12 | import './shared/control-bar/peertube-link-button' | ||
13 | import './shared/control-bar/theater-button' | ||
14 | import './shared/control-bar/peertube-live-display' | ||
15 | import './shared/settings/resolution-menu-button' | ||
16 | import './shared/settings/resolution-menu-item' | ||
17 | import './shared/settings/settings-dialog' | ||
18 | import './shared/settings/settings-menu-button' | ||
19 | import './shared/settings/settings-menu-item' | ||
20 | import './shared/settings/settings-panel' | ||
21 | import './shared/settings/settings-panel-child' | ||
22 | import './shared/playlist/playlist-plugin' | ||
23 | import './shared/mobile/peertube-mobile-plugin' | ||
24 | import './shared/mobile/peertube-mobile-buttons' | ||
25 | import './shared/hotkeys/peertube-hotkeys-plugin' | ||
26 | import './shared/metrics/metrics-plugin' | ||
27 | import videojs, { VideoJsPlayer } from 'video.js' | ||
28 | import { logger } from '@root-helpers/logger' | ||
29 | import { PluginsManager } from '@root-helpers/plugins-manager' | ||
30 | import { copyToClipboard } from '@root-helpers/utils' | ||
31 | import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' | ||
32 | import { isMobile } from '@root-helpers/web-browser' | ||
33 | import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@shared/core-utils' | ||
34 | import { saveAverageBandwidth } from './peertube-player-local-storage' | ||
35 | import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder' | ||
36 | import { TranslationsManager } from './translations-manager' | ||
37 | import { PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types' | ||
38 | |||
39 | // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) | ||
40 | (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' | ||
41 | |||
42 | const CaptionsButton = videojs.getComponent('CaptionsButton') as any | ||
43 | // Change Captions to Subtitles/CC | ||
44 | CaptionsButton.prototype.controlText_ = 'Subtitles/CC' | ||
45 | // We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know) | ||
46 | CaptionsButton.prototype.label_ = ' ' | ||
47 | |||
48 | // TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged | ||
49 | const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any | ||
50 | if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) { | ||
51 | PlayProgressBar.prototype.options_.children.push('timeTooltip') | ||
52 | } | ||
53 | |||
54 | export class PeerTubePlayer { | ||
55 | private pluginsManager: PluginsManager | ||
56 | |||
57 | private videojsDecodeErrors = 0 | ||
58 | |||
59 | private p2pMediaLoaderModule: any | ||
60 | |||
61 | private player: VideoJsPlayer | ||
62 | |||
63 | private currentLoadOptions: PeerTubePlayerLoadOptions | ||
64 | |||
65 | private moduleLoaded = { | ||
66 | webVideo: false, | ||
67 | p2pMediaLoader: false | ||
68 | } | ||
69 | |||
70 | constructor (private options: PeerTubePlayerContructorOptions) { | ||
71 | this.pluginsManager = options.pluginsManager | ||
72 | } | ||
73 | |||
74 | unload () { | ||
75 | if (!this.player) return | ||
76 | |||
77 | this.disposeDynamicPluginsIfNeeded() | ||
78 | |||
79 | this.player.reset() | ||
80 | } | ||
81 | |||
82 | async load (loadOptions: PeerTubePlayerLoadOptions) { | ||
83 | this.currentLoadOptions = loadOptions | ||
84 | |||
85 | this.setPoster('') | ||
86 | |||
87 | this.disposeDynamicPluginsIfNeeded() | ||
88 | |||
89 | await this.lazyLoadModulesIfNeeded() | ||
90 | await this.buildPlayerIfNeeded() | ||
91 | |||
92 | if (this.currentLoadOptions.mode === 'p2p-media-loader') { | ||
93 | await this.loadP2PMediaLoader() | ||
94 | } else { | ||
95 | this.loadWebVideo() | ||
96 | } | ||
97 | |||
98 | this.loadDynamicPlugins() | ||
99 | |||
100 | if (this.options.controlBar === false) this.player.controlBar.hide() | ||
101 | else this.player.controlBar.show() | ||
102 | |||
103 | this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay)) | ||
104 | |||
105 | this.player.trigger('video-change') | ||
106 | } | ||
107 | |||
108 | getPlayer () { | ||
109 | return this.player | ||
110 | } | ||
111 | |||
112 | destroy () { | ||
113 | if (this.player) this.player.dispose() | ||
114 | } | ||
115 | |||
116 | setPoster (url: string) { | ||
117 | this.player?.poster(url) | ||
118 | this.options.playerElement().poster = url | ||
119 | } | ||
120 | |||
121 | enable () { | ||
122 | if (!this.player) return | ||
123 | |||
124 | (this.player.el() as HTMLElement).style.pointerEvents = 'auto' | ||
125 | } | ||
126 | |||
127 | disable () { | ||
128 | if (!this.player) return | ||
129 | |||
130 | if (this.player.isFullscreen()) { | ||
131 | this.player.exitFullscreen() | ||
132 | } | ||
133 | |||
134 | // Disable player | ||
135 | this.player.hasStarted(false) | ||
136 | this.player.removeClass('vjs-has-autoplay') | ||
137 | this.player.bigPlayButton.hide(); | ||
138 | |||
139 | (this.player.el() as HTMLElement).style.pointerEvents = 'none' | ||
140 | } | ||
141 | |||
142 | private async loadP2PMediaLoader () { | ||
143 | const hlsOptionsBuilder = new HLSOptionsBuilder({ | ||
144 | ...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]), | ||
145 | ...pick(this.currentLoadOptions, [ | ||
146 | 'videoPassword', | ||
147 | 'requiresUserAuth', | ||
148 | 'videoFileToken', | ||
149 | 'requiresPassword', | ||
150 | 'isLive', | ||
151 | 'p2pEnabled', | ||
152 | 'liveOptions', | ||
153 | 'hls' | ||
154 | ]) | ||
155 | }, this.p2pMediaLoaderModule) | ||
156 | |||
157 | const { hlsjs, p2pMediaLoader } = await hlsOptionsBuilder.getPluginOptions() | ||
158 | |||
159 | this.player.hlsjs(hlsjs) | ||
160 | this.player.p2pMediaLoader(p2pMediaLoader) | ||
161 | } | ||
162 | |||
163 | private loadWebVideo () { | ||
164 | const webVideoOptionsBuilder = new WebVideoOptionsBuilder(pick(this.currentLoadOptions, [ | ||
165 | 'videoFileToken', | ||
166 | 'webVideo', | ||
167 | 'hls', | ||
168 | 'startTime' | ||
169 | ])) | ||
170 | |||
171 | this.player.webVideo(webVideoOptionsBuilder.getPluginOptions()) | ||
172 | } | ||
173 | |||
174 | private async buildPlayerIfNeeded () { | ||
175 | if (this.player) return | ||
176 | |||
177 | await TranslationsManager.loadLocaleInVideoJS(this.options.serverUrl, this.options.language, videojs) | ||
178 | |||
179 | const videojsOptions = await this.pluginsManager.runHook( | ||
180 | 'filter:internal.player.videojs.options.result', | ||
181 | this.getVideojsOptions() | ||
182 | ) | ||
183 | |||
184 | this.player = videojs(this.options.playerElement(), videojsOptions) | ||
185 | |||
186 | this.player.ready(() => { | ||
187 | if (!isNaN(+this.options.playbackRate)) { | ||
188 | this.player.playbackRate(+this.options.playbackRate) | ||
189 | } | ||
190 | |||
191 | let alreadyFallback = false | ||
192 | |||
193 | const handleError = () => { | ||
194 | if (alreadyFallback) return | ||
195 | alreadyFallback = true | ||
196 | |||
197 | if (this.currentLoadOptions.mode === 'p2p-media-loader') { | ||
198 | this.tryToRecoverHLSError(this.player.error()) | ||
199 | } else { | ||
200 | this.maybeFallbackToWebVideo() | ||
201 | } | ||
202 | } | ||
203 | |||
204 | this.player.one('error', () => handleError()) | ||
205 | |||
206 | this.player.on('p2p-info', (_, data: PlayerNetworkInfo) => { | ||
207 | if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return | ||
208 | |||
209 | saveAverageBandwidth(data.bandwidthEstimate) | ||
210 | }) | ||
211 | |||
212 | this.player.contextmenuUI(this.getContextMenuOptions()) | ||
213 | |||
214 | this.displayNotificationWhenOffline() | ||
215 | }) | ||
216 | } | ||
217 | |||
218 | private disposeDynamicPluginsIfNeeded () { | ||
219 | if (!this.player) return | ||
220 | |||
221 | if (this.player.usingPlugin('peertubeMobile')) this.player.peertubeMobile().dispose() | ||
222 | if (this.player.usingPlugin('peerTubeHotkeysPlugin')) this.player.peerTubeHotkeysPlugin().dispose() | ||
223 | if (this.player.usingPlugin('playlist')) this.player.playlist().dispose() | ||
224 | if (this.player.usingPlugin('bezels')) this.player.bezels().dispose() | ||
225 | if (this.player.usingPlugin('upnext')) this.player.upnext().dispose() | ||
226 | if (this.player.usingPlugin('stats')) this.player.stats().dispose() | ||
227 | if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose() | ||
228 | |||
229 | if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose() | ||
230 | |||
231 | if (this.player.usingPlugin('p2pMediaLoader')) this.player.p2pMediaLoader().dispose() | ||
232 | if (this.player.usingPlugin('hlsjs')) this.player.hlsjs().dispose() | ||
233 | |||
234 | if (this.player.usingPlugin('webVideo')) this.player.webVideo().dispose() | ||
235 | } | ||
236 | |||
237 | private loadDynamicPlugins () { | ||
238 | if (isMobile()) this.player.peertubeMobile() | ||
239 | |||
240 | this.player.bezels() | ||
241 | |||
242 | this.player.stats({ | ||
243 | videoUUID: this.currentLoadOptions.videoUUID, | ||
244 | videoIsLive: this.currentLoadOptions.isLive, | ||
245 | mode: this.currentLoadOptions.mode, | ||
246 | p2pEnabled: this.currentLoadOptions.p2pEnabled | ||
247 | }) | ||
248 | |||
249 | if (this.options.enableHotkeys === true) { | ||
250 | this.player.peerTubeHotkeysPlugin({ isLive: this.currentLoadOptions.isLive }) | ||
251 | } | ||
252 | |||
253 | if (this.currentLoadOptions.playlist) { | ||
254 | this.player.playlist(this.currentLoadOptions.playlist) | ||
255 | } | ||
256 | |||
257 | if (this.currentLoadOptions.upnext) { | ||
258 | this.player.upnext({ | ||
259 | timeout: this.currentLoadOptions.upnext.timeout, | ||
260 | |||
261 | getTitle: () => this.currentLoadOptions.nextVideo.getVideoTitle(), | ||
262 | |||
263 | next: () => this.currentLoadOptions.nextVideo.handler(), | ||
264 | isDisplayed: () => this.currentLoadOptions.nextVideo.enabled && this.currentLoadOptions.upnext.isEnabled(), | ||
265 | |||
266 | isSuspended: () => this.currentLoadOptions.upnext.isSuspended(this.player) | ||
267 | }) | ||
268 | } | ||
269 | |||
270 | if (this.currentLoadOptions.storyboard) { | ||
271 | this.player.storyboard(this.currentLoadOptions.storyboard) | ||
272 | } | ||
273 | |||
274 | if (this.currentLoadOptions.dock) { | ||
275 | this.player.peertubeDock(this.currentLoadOptions.dock) | ||
276 | } | ||
277 | } | ||
278 | |||
279 | private async lazyLoadModulesIfNeeded () { | ||
280 | if (this.currentLoadOptions.mode === 'web-video' && this.moduleLoaded.webVideo !== true) { | ||
281 | await import('./shared/web-video/web-video-plugin') | ||
282 | } | ||
283 | |||
284 | if (this.currentLoadOptions.mode === 'p2p-media-loader' && this.moduleLoaded.p2pMediaLoader !== true) { | ||
285 | const [ p2pMediaLoaderModule ] = await Promise.all([ | ||
286 | import('@peertube/p2p-media-loader-hlsjs'), | ||
287 | import('./shared/p2p-media-loader/hls-plugin'), | ||
288 | import('./shared/p2p-media-loader/p2p-media-loader-plugin') | ||
289 | ]) | ||
290 | |||
291 | this.p2pMediaLoaderModule = p2pMediaLoaderModule | ||
292 | } | ||
293 | } | ||
294 | |||
295 | private async tryToRecoverHLSError (err: any) { | ||
296 | if (err.code === MediaError.MEDIA_ERR_DECODE) { | ||
297 | |||
298 | // Display a notification to user | ||
299 | if (this.videojsDecodeErrors === 0) { | ||
300 | this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.')) | ||
301 | } | ||
302 | |||
303 | if (this.videojsDecodeErrors === 20) { | ||
304 | this.maybeFallbackToWebVideo() | ||
305 | return | ||
306 | } | ||
307 | |||
308 | logger.info('Fast forwarding HLS to recover from an error.') | ||
309 | |||
310 | this.videojsDecodeErrors++ | ||
311 | |||
312 | await this.load({ | ||
313 | ...this.currentLoadOptions, | ||
314 | |||
315 | mode: 'p2p-media-loader', | ||
316 | startTime: this.player.currentTime() + 2, | ||
317 | autoplay: true | ||
318 | }) | ||
319 | } else { | ||
320 | this.maybeFallbackToWebVideo() | ||
321 | } | ||
322 | } | ||
323 | |||
324 | private async maybeFallbackToWebVideo () { | ||
325 | if (this.currentLoadOptions.webVideo.videoFiles.length === 0 || this.currentLoadOptions.mode === 'web-video') { | ||
326 | this.player.peertube().displayFatalError() | ||
327 | return | ||
328 | } | ||
329 | |||
330 | logger.info('Fallback to web-video.') | ||
331 | |||
332 | await this.load({ | ||
333 | ...this.currentLoadOptions, | ||
334 | |||
335 | mode: 'web-video', | ||
336 | startTime: this.player.currentTime(), | ||
337 | autoplay: true | ||
338 | }) | ||
339 | } | ||
340 | |||
341 | getVideojsOptions (): videojs.PlayerOptions { | ||
342 | const html5 = { | ||
343 | preloadTextTracks: false | ||
344 | } | ||
345 | |||
346 | const plugins: VideoJSPluginOptions = { | ||
347 | peertube: { | ||
348 | hasAutoplay: () => this.getAutoPlayValue(this.currentLoadOptions.autoplay), | ||
349 | |||
350 | videoViewUrl: () => this.currentLoadOptions.videoViewUrl, | ||
351 | videoViewIntervalMs: this.options.videoViewIntervalMs, | ||
352 | |||
353 | authorizationHeader: this.options.authorizationHeader, | ||
354 | |||
355 | videoDuration: () => this.currentLoadOptions.duration, | ||
356 | |||
357 | startTime: () => this.currentLoadOptions.startTime, | ||
358 | stopTime: () => this.currentLoadOptions.stopTime, | ||
359 | |||
360 | videoCaptions: () => this.currentLoadOptions.videoCaptions, | ||
361 | isLive: () => this.currentLoadOptions.isLive, | ||
362 | videoUUID: () => this.currentLoadOptions.videoUUID, | ||
363 | subtitle: () => this.currentLoadOptions.subtitle | ||
364 | }, | ||
365 | metrics: { | ||
366 | mode: () => this.currentLoadOptions.mode, | ||
367 | |||
368 | metricsUrl: () => this.options.metricsUrl, | ||
369 | videoUUID: () => this.currentLoadOptions.videoUUID | ||
370 | } | ||
371 | } | ||
372 | |||
373 | const controlBarOptionsBuilder = new ControlBarOptionsBuilder({ | ||
374 | ...this.options, | ||
375 | |||
376 | videoShortUUID: () => this.currentLoadOptions.videoShortUUID, | ||
377 | p2pEnabled: () => this.currentLoadOptions.p2pEnabled, | ||
378 | |||
379 | nextVideo: () => this.currentLoadOptions.nextVideo, | ||
380 | previousVideo: () => this.currentLoadOptions.previousVideo | ||
381 | }) | ||
382 | |||
383 | const videojsOptions = { | ||
384 | html5, | ||
385 | |||
386 | // We don't use text track settings for now | ||
387 | textTrackSettings: false as any, // FIXME: typings | ||
388 | controls: this.options.controls !== undefined ? this.options.controls : true, | ||
389 | loop: this.options.loop !== undefined ? this.options.loop : false, | ||
390 | |||
391 | muted: this.options.muted !== undefined | ||
392 | ? this.options.muted | ||
393 | : undefined, // Undefined so the player knows it has to check the local storage | ||
394 | |||
395 | autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay), | ||
396 | |||
397 | poster: this.currentLoadOptions.poster, | ||
398 | inactivityTimeout: this.options.inactivityTimeout, | ||
399 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], | ||
400 | |||
401 | plugins, | ||
402 | |||
403 | controlBar: { | ||
404 | children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings | ||
405 | }, | ||
406 | |||
407 | language: this.options.language && !isDefaultLocale(this.options.language) | ||
408 | ? this.options.language | ||
409 | : undefined | ||
410 | } | ||
411 | |||
412 | return videojsOptions | ||
413 | } | ||
414 | |||
415 | private getAutoPlayValue (autoplay: boolean): videojs.Autoplay { | ||
416 | if (autoplay !== true) return false | ||
417 | |||
418 | return this.currentLoadOptions.forceAutoplay | ||
419 | ? 'any' | ||
420 | : 'play' | ||
421 | } | ||
422 | |||
423 | private displayNotificationWhenOffline () { | ||
424 | const offlineNotificationElem = document.createElement('div') | ||
425 | offlineNotificationElem.classList.add('vjs-peertube-offline-notification') | ||
426 | offlineNotificationElem.innerText = this.player.localize('You seem to be offline and the video may not work') | ||
427 | |||
428 | let offlineNotificationElemAdded = false | ||
429 | |||
430 | const handleOnline = () => { | ||
431 | if (!offlineNotificationElemAdded) return | ||
432 | |||
433 | this.player.el().removeChild(offlineNotificationElem) | ||
434 | offlineNotificationElemAdded = false | ||
435 | |||
436 | logger.info('The browser is online') | ||
437 | } | ||
438 | |||
439 | const handleOffline = () => { | ||
440 | if (offlineNotificationElemAdded) return | ||
441 | |||
442 | this.player.el().appendChild(offlineNotificationElem) | ||
443 | offlineNotificationElemAdded = true | ||
444 | |||
445 | logger.info('The browser is offline') | ||
446 | } | ||
447 | |||
448 | window.addEventListener('online', handleOnline) | ||
449 | window.addEventListener('offline', handleOffline) | ||
450 | |||
451 | this.player.on('dispose', () => { | ||
452 | window.removeEventListener('online', handleOnline) | ||
453 | window.removeEventListener('offline', handleOffline) | ||
454 | }) | ||
455 | } | ||
456 | |||
457 | private getContextMenuOptions () { | ||
458 | |||
459 | const content = () => { | ||
460 | const self = this | ||
461 | const player = this.player | ||
462 | |||
463 | const shortUUID = self.currentLoadOptions.videoShortUUID | ||
464 | const isLoopEnabled = player.options_['loop'] | ||
465 | |||
466 | const items = [ | ||
467 | { | ||
468 | icon: 'repeat', | ||
469 | label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''), | ||
470 | listener: function () { | ||
471 | player.options_['loop'] = !isLoopEnabled | ||
472 | } | ||
473 | }, | ||
474 | { | ||
475 | label: player.localize('Copy the video URL'), | ||
476 | listener: function () { | ||
477 | copyToClipboard(buildVideoLink({ shortUUID })) | ||
478 | } | ||
479 | }, | ||
480 | { | ||
481 | label: player.localize('Copy the video URL at the current time'), | ||
482 | listener: function () { | ||
483 | const url = buildVideoLink({ shortUUID }) | ||
484 | |||
485 | copyToClipboard(decorateVideoLink({ url, startTime: player.currentTime() })) | ||
486 | } | ||
487 | }, | ||
488 | { | ||
489 | icon: 'code', | ||
490 | label: player.localize('Copy embed code'), | ||
491 | listener: () => { | ||
492 | copyToClipboard(buildVideoOrPlaylistEmbed({ | ||
493 | embedUrl: self.currentLoadOptions.embedUrl, | ||
494 | embedTitle: self.currentLoadOptions.embedTitle | ||
495 | })) | ||
496 | } | ||
497 | } | ||
498 | ] | ||
499 | |||
500 | items.push({ | ||
501 | icon: 'info', | ||
502 | label: player.localize('Stats for nerds'), | ||
503 | listener: () => { | ||
504 | player.stats().show() | ||
505 | } | ||
506 | }) | ||
507 | |||
508 | return items.map(i => ({ | ||
509 | ...i, | ||
510 | label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label | ||
511 | })) | ||
512 | } | ||
513 | |||
514 | return { content } | ||
515 | } | ||
516 | } | ||
517 | |||
518 | // ############################################################################ | ||
519 | |||
520 | export { | ||
521 | videojs | ||
522 | } | ||
diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts index ca88bc1f9..6afb2c6a3 100644 --- a/client/src/assets/player/shared/bezels/bezels-plugin.ts +++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import './pause-bezel' | 2 | import { PauseBezel } from './pause-bezel' |
3 | 3 | ||
4 | const Plugin = videojs.getPlugin('plugin') | 4 | const Plugin = videojs.getPlugin('plugin') |
5 | 5 | ||
@@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin { | |||
12 | player.addClass('vjs-bezels') | 12 | player.addClass('vjs-bezels') |
13 | }) | 13 | }) |
14 | 14 | ||
15 | player.addChild('PauseBezel', options) | 15 | player.addChild(new PauseBezel(player, options)) |
16 | } | 16 | } |
17 | } | 17 | } |
18 | 18 | ||
diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts index e35c39a5f..d364ad0dd 100644 --- a/client/src/assets/player/shared/bezels/pause-bezel.ts +++ b/client/src/assets/player/shared/bezels/pause-bezel.ts | |||
@@ -32,26 +32,61 @@ function getPlayBezel () { | |||
32 | } | 32 | } |
33 | 33 | ||
34 | const Component = videojs.getComponent('Component') | 34 | const Component = videojs.getComponent('Component') |
35 | class PauseBezel extends Component { | 35 | export class PauseBezel extends Component { |
36 | container: HTMLDivElement | 36 | container: HTMLDivElement |
37 | 37 | ||
38 | private firstPlayDone = false | ||
39 | private paused = false | ||
40 | |||
41 | private playerPauseHandler: () => void | ||
42 | private playerPlayHandler: () => void | ||
43 | private videoChangeHandler: () => void | ||
44 | |||
38 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { | 45 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { |
39 | super(player, options) | 46 | super(player, options) |
40 | 47 | ||
41 | // Hide bezels on mobile since we already have our mobile overlay | 48 | // Hide bezels on mobile since we already have our mobile overlay |
42 | if (isMobile()) return | 49 | if (isMobile()) return |
43 | 50 | ||
44 | player.on('pause', (_: any) => { | 51 | this.playerPauseHandler = () => { |
45 | if (player.seeking() || player.ended()) return | 52 | if (player.seeking()) return |
53 | |||
54 | this.paused = true | ||
55 | |||
56 | if (player.ended()) return | ||
57 | |||
46 | this.container.innerHTML = getPauseBezel() | 58 | this.container.innerHTML = getPauseBezel() |
47 | this.showBezel() | 59 | this.showBezel() |
48 | }) | 60 | } |
61 | |||
62 | this.playerPlayHandler = () => { | ||
63 | if (player.seeking() || !this.firstPlayDone || !this.paused) { | ||
64 | this.firstPlayDone = true | ||
65 | return | ||
66 | } | ||
67 | |||
68 | this.paused = false | ||
69 | this.firstPlayDone = true | ||
49 | 70 | ||
50 | player.on('play', (_: any) => { | ||
51 | if (player.seeking()) return | ||
52 | this.container.innerHTML = getPlayBezel() | 71 | this.container.innerHTML = getPlayBezel() |
53 | this.showBezel() | 72 | this.showBezel() |
54 | }) | 73 | } |
74 | |||
75 | this.videoChangeHandler = () => { | ||
76 | this.firstPlayDone = false | ||
77 | } | ||
78 | |||
79 | player.on('video-change', () => this.videoChangeHandler) | ||
80 | player.on('pause', this.playerPauseHandler) | ||
81 | player.on('play', this.playerPlayHandler) | ||
82 | } | ||
83 | |||
84 | dispose () { | ||
85 | if (this.playerPauseHandler) this.player().off('pause', this.playerPauseHandler) | ||
86 | if (this.playerPlayHandler) this.player().off('play', this.playerPlayHandler) | ||
87 | if (this.videoChangeHandler) this.player().off('video-change', this.videoChangeHandler) | ||
88 | |||
89 | super.dispose() | ||
55 | } | 90 | } |
56 | 91 | ||
57 | createEl () { | 92 | createEl () { |
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index 24877c267..9307027f6 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts | |||
@@ -2,6 +2,5 @@ export * from './next-previous-video-button' | |||
2 | export * from './p2p-info-button' | 2 | export * from './p2p-info-button' |
3 | export * from './peertube-link-button' | 3 | export * from './peertube-link-button' |
4 | export * from './peertube-live-display' | 4 | export * from './peertube-live-display' |
5 | export * from './peertube-load-progress-bar' | ||
6 | export * from './storyboard-plugin' | 5 | export * from './storyboard-plugin' |
7 | export * from './theater-button' | 6 | export * from './theater-button' |
diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts index b7b986806..18a107f52 100644 --- a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts +++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts | |||
@@ -4,14 +4,18 @@ import { NextPreviousVideoButtonOptions } from '../../types' | |||
4 | const Button = videojs.getComponent('Button') | 4 | const Button = videojs.getComponent('Button') |
5 | 5 | ||
6 | class NextPreviousVideoButton extends Button { | 6 | class NextPreviousVideoButton extends Button { |
7 | private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions | 7 | options_: NextPreviousVideoButtonOptions & videojs.ComponentOptions |
8 | 8 | ||
9 | constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { | 9 | constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions & videojs.ComponentOptions) { |
10 | super(player, options as any) | 10 | super(player, options) |
11 | 11 | ||
12 | this.nextPreviousVideoButtonOptions = options | 12 | this.player().on('video-change', () => { |
13 | this.updateDisabled() | ||
14 | this.updateShowing() | ||
15 | }) | ||
13 | 16 | ||
14 | this.update() | 17 | this.updateDisabled() |
18 | this.updateShowing() | ||
15 | } | 19 | } |
16 | 20 | ||
17 | createEl () { | 21 | createEl () { |
@@ -35,15 +39,20 @@ class NextPreviousVideoButton extends Button { | |||
35 | } | 39 | } |
36 | 40 | ||
37 | handleClick () { | 41 | handleClick () { |
38 | this.nextPreviousVideoButtonOptions.handler() | 42 | this.options_.handler() |
39 | } | 43 | } |
40 | 44 | ||
41 | update () { | 45 | updateDisabled () { |
42 | const disabled = this.nextPreviousVideoButtonOptions.isDisabled() | 46 | const disabled = this.options_.isDisabled() |
43 | 47 | ||
44 | if (disabled) this.addClass('vjs-disabled') | 48 | if (disabled) this.addClass('vjs-disabled') |
45 | else this.removeClass('vjs-disabled') | 49 | else this.removeClass('vjs-disabled') |
46 | } | 50 | } |
51 | |||
52 | updateShowing () { | ||
53 | if (this.options_.isDisplayed()) this.show() | ||
54 | else this.hide() | ||
55 | } | ||
47 | } | 56 | } |
48 | 57 | ||
49 | videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) | 58 | videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) |
diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts index 1979654ad..4177b3280 100644 --- a/client/src/assets/player/shared/control-bar/p2p-info-button.ts +++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts | |||
@@ -1,71 +1,44 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' | 2 | import { PlayerNetworkInfo } from '../../types' |
3 | import { bytes } from '../common' | 3 | import { bytes } from '../common' |
4 | 4 | ||
5 | const Button = videojs.getComponent('Button') | 5 | const Button = videojs.getComponent('Button') |
6 | class P2pInfoButton extends Button { | 6 | class P2PInfoButton extends Button { |
7 | 7 | el_: HTMLElement | |
8 | constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) { | ||
9 | super(player, options as any) | ||
10 | } | ||
11 | 8 | ||
12 | createEl () { | 9 | createEl () { |
13 | const div = videojs.dom.createEl('div', { | 10 | const div = videojs.dom.createEl('div', { className: 'vjs-peertube' }) |
14 | className: 'vjs-peertube' | 11 | const subDivP2P = videojs.dom.createEl('div', { |
15 | }) | ||
16 | const subDivWebtorrent = videojs.dom.createEl('div', { | ||
17 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info | 12 | className: 'vjs-peertube-hidden' // Hide the stats before we get the info |
18 | }) as HTMLDivElement | 13 | }) as HTMLDivElement |
19 | div.appendChild(subDivWebtorrent) | 14 | div.appendChild(subDivP2P) |
20 | 15 | ||
21 | // Stop here if P2P is not enabled | 16 | const downloadIcon = videojs.dom.createEl('span', { className: 'icon icon-download' }) |
22 | const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled | 17 | subDivP2P.appendChild(downloadIcon) |
23 | if (!p2pEnabled) return div as HTMLButtonElement | ||
24 | 18 | ||
25 | const downloadIcon = videojs.dom.createEl('span', { | 19 | const downloadSpeedText = videojs.dom.createEl('span', { className: 'download-speed-text' }) |
26 | className: 'icon icon-download' | 20 | const downloadSpeedNumber = videojs.dom.createEl('span', { className: 'download-speed-number' }) |
27 | }) | ||
28 | subDivWebtorrent.appendChild(downloadIcon) | ||
29 | |||
30 | const downloadSpeedText = videojs.dom.createEl('span', { | ||
31 | className: 'download-speed-text' | ||
32 | }) | ||
33 | const downloadSpeedNumber = videojs.dom.createEl('span', { | ||
34 | className: 'download-speed-number' | ||
35 | }) | ||
36 | const downloadSpeedUnit = videojs.dom.createEl('span') | 21 | const downloadSpeedUnit = videojs.dom.createEl('span') |
37 | downloadSpeedText.appendChild(downloadSpeedNumber) | 22 | downloadSpeedText.appendChild(downloadSpeedNumber) |
38 | downloadSpeedText.appendChild(downloadSpeedUnit) | 23 | downloadSpeedText.appendChild(downloadSpeedUnit) |
39 | subDivWebtorrent.appendChild(downloadSpeedText) | 24 | subDivP2P.appendChild(downloadSpeedText) |
40 | 25 | ||
41 | const uploadIcon = videojs.dom.createEl('span', { | 26 | const uploadIcon = videojs.dom.createEl('span', { className: 'icon icon-upload' }) |
42 | className: 'icon icon-upload' | 27 | subDivP2P.appendChild(uploadIcon) |
43 | }) | ||
44 | subDivWebtorrent.appendChild(uploadIcon) | ||
45 | 28 | ||
46 | const uploadSpeedText = videojs.dom.createEl('span', { | 29 | const uploadSpeedText = videojs.dom.createEl('span', { className: 'upload-speed-text' }) |
47 | className: 'upload-speed-text' | 30 | const uploadSpeedNumber = videojs.dom.createEl('span', { className: 'upload-speed-number' }) |
48 | }) | ||
49 | const uploadSpeedNumber = videojs.dom.createEl('span', { | ||
50 | className: 'upload-speed-number' | ||
51 | }) | ||
52 | const uploadSpeedUnit = videojs.dom.createEl('span') | 31 | const uploadSpeedUnit = videojs.dom.createEl('span') |
53 | uploadSpeedText.appendChild(uploadSpeedNumber) | 32 | uploadSpeedText.appendChild(uploadSpeedNumber) |
54 | uploadSpeedText.appendChild(uploadSpeedUnit) | 33 | uploadSpeedText.appendChild(uploadSpeedUnit) |
55 | subDivWebtorrent.appendChild(uploadSpeedText) | 34 | subDivP2P.appendChild(uploadSpeedText) |
56 | 35 | ||
57 | const peersText = videojs.dom.createEl('span', { | 36 | const peersText = videojs.dom.createEl('span', { className: 'peers-text' }) |
58 | className: 'peers-text' | 37 | const peersNumber = videojs.dom.createEl('span', { className: 'peers-number' }) |
59 | }) | 38 | subDivP2P.appendChild(peersNumber) |
60 | const peersNumber = videojs.dom.createEl('span', { | 39 | subDivP2P.appendChild(peersText) |
61 | className: 'peers-number' | ||
62 | }) | ||
63 | subDivWebtorrent.appendChild(peersNumber) | ||
64 | subDivWebtorrent.appendChild(peersText) | ||
65 | 40 | ||
66 | const subDivHttp = videojs.dom.createEl('div', { | 41 | const subDivHttp = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' }) as HTMLElement |
67 | className: 'vjs-peertube-hidden' | ||
68 | }) | ||
69 | const subDivHttpText = videojs.dom.createEl('span', { | 42 | const subDivHttpText = videojs.dom.createEl('span', { |
70 | className: 'http-fallback', | 43 | className: 'http-fallback', |
71 | textContent: 'HTTP' | 44 | textContent: 'HTTP' |
@@ -74,14 +47,9 @@ class P2pInfoButton extends Button { | |||
74 | subDivHttp.appendChild(subDivHttpText) | 47 | subDivHttp.appendChild(subDivHttpText) |
75 | div.appendChild(subDivHttp) | 48 | div.appendChild(subDivHttp) |
76 | 49 | ||
77 | this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { | 50 | this.player_.on('p2p-info', (_event: any, data: PlayerNetworkInfo) => { |
78 | // We are in HTTP fallback | 51 | subDivP2P.className = 'vjs-peertube-displayed' |
79 | if (!data) { | 52 | subDivHttp.className = 'vjs-peertube-hidden' |
80 | subDivHttp.className = 'vjs-peertube-displayed' | ||
81 | subDivWebtorrent.className = 'vjs-peertube-hidden' | ||
82 | |||
83 | return | ||
84 | } | ||
85 | 53 | ||
86 | const p2pStats = data.p2p | 54 | const p2pStats = data.p2p |
87 | const httpStats = data.http | 55 | const httpStats = data.http |
@@ -92,17 +60,17 @@ class P2pInfoButton extends Button { | |||
92 | const totalUploaded = bytes(p2pStats.uploaded) | 60 | const totalUploaded = bytes(p2pStats.uploaded) |
93 | const numPeers = p2pStats.numPeers | 61 | const numPeers = p2pStats.numPeers |
94 | 62 | ||
95 | subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' | 63 | subDivP2P.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' |
96 | 64 | ||
97 | if (data.source === 'p2p-media-loader') { | 65 | if (data.source === 'p2p-media-loader') { |
98 | const downloadedFromServer = bytes(httpStats.downloaded).join(' ') | 66 | const downloadedFromServer = bytes(httpStats.downloaded).join(' ') |
99 | const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') | 67 | const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') |
100 | 68 | ||
101 | subDivWebtorrent.title += | 69 | subDivP2P.title += |
102 | ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + | 70 | ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + |
103 | ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' | 71 | ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' |
104 | } | 72 | } |
105 | subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') | 73 | subDivP2P.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') |
106 | 74 | ||
107 | downloadSpeedNumber.textContent = downloadSpeed[0] | 75 | downloadSpeedNumber.textContent = downloadSpeed[0] |
108 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] | 76 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] |
@@ -114,11 +82,24 @@ class P2pInfoButton extends Button { | |||
114 | peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) | 82 | peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) |
115 | 83 | ||
116 | subDivHttp.className = 'vjs-peertube-hidden' | 84 | subDivHttp.className = 'vjs-peertube-hidden' |
117 | subDivWebtorrent.className = 'vjs-peertube-displayed' | 85 | subDivP2P.className = 'vjs-peertube-displayed' |
86 | }) | ||
87 | |||
88 | this.player_.on('http-info', (_event, data: PlayerNetworkInfo) => { | ||
89 | // We are in HTTP fallback | ||
90 | subDivHttp.className = 'vjs-peertube-displayed' | ||
91 | subDivP2P.className = 'vjs-peertube-hidden' | ||
92 | |||
93 | subDivHttp.title = this.player().localize('Total downloaded: ') + bytes(data.http.downloaded).join(' ') | ||
94 | }) | ||
95 | |||
96 | this.player_.on('video-change', () => { | ||
97 | subDivP2P.className = 'vjs-peertube-hidden' | ||
98 | subDivHttp.className = 'vjs-peertube-hidden' | ||
118 | }) | 99 | }) |
119 | 100 | ||
120 | return div as HTMLButtonElement | 101 | return div as HTMLButtonElement |
121 | } | 102 | } |
122 | } | 103 | } |
123 | 104 | ||
124 | videojs.registerComponent('P2PInfoButton', P2pInfoButton) | 105 | videojs.registerComponent('P2PInfoButton', P2PInfoButton) |
diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts index 45d7ac42f..8242b9cea 100644 --- a/client/src/assets/player/shared/control-bar/peertube-link-button.ts +++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts | |||
@@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' | |||
3 | import { PeerTubeLinkButtonOptions } from '../../types' | 3 | import { PeerTubeLinkButtonOptions } from '../../types' |
4 | 4 | ||
5 | const Component = videojs.getComponent('Component') | 5 | const Component = videojs.getComponent('Component') |
6 | |||
6 | class PeerTubeLinkButton extends Component { | 7 | class PeerTubeLinkButton extends Component { |
8 | private mouseEnterHandler: () => void | ||
9 | private clickHandler: () => void | ||
7 | 10 | ||
8 | constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { | 11 | options_: PeerTubeLinkButtonOptions & videojs.ComponentOptions |
9 | super(player, options as any) | ||
10 | } | ||
11 | 12 | ||
12 | createEl () { | 13 | constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions & videojs.ComponentOptions) { |
13 | return this.buildElement() | 14 | super(player, options) |
15 | |||
16 | this.updateShowing() | ||
17 | this.player().on('video-change', () => this.updateShowing()) | ||
14 | } | 18 | } |
15 | 19 | ||
16 | updateHref () { | 20 | dispose () { |
17 | this.el().setAttribute('href', this.buildLink()) | 21 | if (this.el()) return |
22 | |||
23 | this.el().removeEventListener('mouseenter', this.mouseEnterHandler) | ||
24 | this.el().removeEventListener('click', this.clickHandler) | ||
25 | |||
26 | super.dispose() | ||
18 | } | 27 | } |
19 | 28 | ||
20 | private buildElement () { | 29 | createEl () { |
21 | const el = videojs.dom.createEl('a', { | 30 | const el = videojs.dom.createEl('a', { |
22 | href: this.buildLink(), | 31 | href: this.buildLink(), |
23 | innerHTML: (this.options_ as PeerTubeLinkButtonOptions).instanceName, | 32 | innerHTML: this.options_.instanceName, |
24 | title: this.player().localize('Video page (new window)'), | 33 | title: this.player().localize('Video page (new window)'), |
25 | className: 'vjs-peertube-link', | 34 | className: 'vjs-peertube-link', |
26 | target: '_blank' | 35 | target: '_blank' |
27 | }) | 36 | }) |
28 | 37 | ||
29 | el.addEventListener('mouseenter', () => this.updateHref()) | 38 | this.mouseEnterHandler = () => this.updateHref() |
30 | el.addEventListener('click', () => this.player().pause()) | 39 | this.clickHandler = () => this.player().pause() |
40 | |||
41 | el.addEventListener('mouseenter', this.mouseEnterHandler) | ||
42 | el.addEventListener('click', this.clickHandler) | ||
43 | |||
44 | return el | ||
45 | } | ||
46 | |||
47 | updateShowing () { | ||
48 | if (this.options_.isDisplayed()) this.show() | ||
49 | else this.hide() | ||
50 | } | ||
31 | 51 | ||
32 | return el as HTMLButtonElement | 52 | updateHref () { |
53 | this.el().setAttribute('href', this.buildLink()) | ||
33 | } | 54 | } |
34 | 55 | ||
35 | private buildLink () { | 56 | private buildLink () { |
36 | const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) | 57 | const url = buildVideoLink({ shortUUID: this.options_.shortUUID() }) |
37 | 58 | ||
38 | return decorateVideoLink({ url, startTime: this.player().currentTime() }) | 59 | return decorateVideoLink({ url, startTime: this.player().currentTime() }) |
39 | } | 60 | } |
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts index 649eb0b00..f9f6bf12f 100644 --- a/client/src/assets/player/shared/control-bar/peertube-live-display.ts +++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts | |||
@@ -13,7 +13,6 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
13 | 13 | ||
14 | this.interval = this.setInterval(() => this.updateClass(), 1000) | 14 | this.interval = this.setInterval(() => this.updateClass(), 1000) |
15 | 15 | ||
16 | this.show() | ||
17 | this.updateSync(true) | 16 | this.updateSync(true) |
18 | } | 17 | } |
19 | 18 | ||
@@ -30,7 +29,7 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
30 | 29 | ||
31 | createEl () { | 30 | createEl () { |
32 | const el = super.createEl('div', { | 31 | const el = super.createEl('div', { |
33 | className: 'vjs-live-control vjs-control' | 32 | className: 'vjs-pt-live-control vjs-control' |
34 | }) | 33 | }) |
35 | 34 | ||
36 | this.contentEl_ = videojs.dom.createEl('div', { | 35 | this.contentEl_ = videojs.dom.createEl('div', { |
@@ -83,10 +82,9 @@ class PeerTubeLiveDisplay extends ClickableComponent { | |||
83 | } | 82 | } |
84 | 83 | ||
85 | private getHLSJS () { | 84 | private getHLSJS () { |
86 | const p2pMediaLoader = this.player()?.p2pMediaLoader | 85 | if (!this.player()?.usingPlugin('p2pMediaLoader')) return |
87 | if (!p2pMediaLoader) return undefined | ||
88 | 86 | ||
89 | return p2pMediaLoader().getHLSJS() | 87 | return this.player().p2pMediaLoader().getHLSJS() |
90 | } | 88 | } |
91 | } | 89 | } |
92 | 90 | ||
diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts deleted file mode 100644 index 623e70eb2..000000000 --- a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts +++ /dev/null | |||
@@ -1,33 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | |||
3 | const Component = videojs.getComponent('Component') | ||
4 | |||
5 | class PeerTubeLoadProgressBar extends Component { | ||
6 | |||
7 | constructor (player: videojs.Player, options?: videojs.ComponentOptions) { | ||
8 | super(player, options) | ||
9 | |||
10 | this.on(player, 'progress', this.update) | ||
11 | } | ||
12 | |||
13 | createEl () { | ||
14 | return super.createEl('div', { | ||
15 | className: 'vjs-load-progress', | ||
16 | innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>` | ||
17 | }) | ||
18 | } | ||
19 | |||
20 | dispose () { | ||
21 | super.dispose() | ||
22 | } | ||
23 | |||
24 | update () { | ||
25 | const torrent = this.player().webtorrent().getTorrent() | ||
26 | if (!torrent) return | ||
27 | |||
28 | (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%' | ||
29 | } | ||
30 | |||
31 | } | ||
32 | |||
33 | Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar) | ||
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts index 81ab60842..80c69b5f2 100644 --- a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts | |||
@@ -24,6 +24,8 @@ class StoryboardPlugin extends Plugin { | |||
24 | 24 | ||
25 | private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip | 25 | private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip |
26 | 26 | ||
27 | private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void | ||
28 | |||
27 | constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { | 29 | constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) { |
28 | super(player, options) | 30 | super(player, options) |
29 | 31 | ||
@@ -54,7 +56,7 @@ class StoryboardPlugin extends Plugin { | |||
54 | this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement | 56 | this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement |
55 | this.seekBar?.el()?.appendChild(this.spritePlaceholder) | 57 | this.seekBar?.el()?.appendChild(this.spritePlaceholder) |
56 | 58 | ||
57 | this.player.on([ 'ready', 'loadstart' ], event => { | 59 | this.onReadyOrLoadstartHandler = event => { |
58 | if (event.type !== 'ready') { | 60 | if (event.type !== 'ready') { |
59 | const spriteSource = this.player.currentSources().find(source => { | 61 | const spriteSource = this.player.currentSources().find(source => { |
60 | return Object.prototype.hasOwnProperty.call(source, 'storyboard') | 62 | return Object.prototype.hasOwnProperty.call(source, 'storyboard') |
@@ -72,7 +74,18 @@ class StoryboardPlugin extends Plugin { | |||
72 | this.cached = !!this.sprites[this.url] | 74 | this.cached = !!this.sprites[this.url] |
73 | 75 | ||
74 | this.load() | 76 | this.load() |
75 | }) | 77 | } |
78 | |||
79 | this.player.on([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) | ||
80 | } | ||
81 | |||
82 | dispose () { | ||
83 | if (this.onReadyOrLoadstartHandler) this.player.off([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler) | ||
84 | if (this.progress) this.progress.off([ 'mousemove', 'touchmove' ], this.boundedHijackMouseTooltip) | ||
85 | |||
86 | this.seekBar?.el()?.removeChild(this.spritePlaceholder) | ||
87 | |||
88 | super.dispose() | ||
76 | } | 89 | } |
77 | 90 | ||
78 | private load () { | 91 | private load () { |
diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts index 56c349d6b..a5feb56ee 100644 --- a/client/src/assets/player/shared/control-bar/theater-button.ts +++ b/client/src/assets/player/shared/control-bar/theater-button.ts | |||
@@ -1,14 +1,19 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' | 2 | import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' |
3 | import { TheaterButtonOptions } from '../../types' | ||
3 | 4 | ||
4 | const Button = videojs.getComponent('Button') | 5 | const Button = videojs.getComponent('Button') |
5 | class TheaterButton extends Button { | 6 | class TheaterButton extends Button { |
6 | 7 | ||
7 | private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' | 8 | private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' |
8 | 9 | ||
9 | constructor (player: videojs.Player, options: videojs.ComponentOptions) { | 10 | private theaterButtonOptions: TheaterButtonOptions |
11 | |||
12 | constructor (player: videojs.Player, options: TheaterButtonOptions & videojs.ComponentOptions) { | ||
10 | super(player, options) | 13 | super(player, options) |
11 | 14 | ||
15 | this.theaterButtonOptions = options | ||
16 | |||
12 | const enabled = getStoredTheater() | 17 | const enabled = getStoredTheater() |
13 | if (enabled === true) { | 18 | if (enabled === true) { |
14 | this.player().addClass(TheaterButton.THEATER_MODE_CLASS) | 19 | this.player().addClass(TheaterButton.THEATER_MODE_CLASS) |
@@ -19,6 +24,9 @@ class TheaterButton extends Button { | |||
19 | this.controlText('Theater mode') | 24 | this.controlText('Theater mode') |
20 | 25 | ||
21 | this.player().theaterEnabled = enabled | 26 | this.player().theaterEnabled = enabled |
27 | |||
28 | this.updateShowing() | ||
29 | this.player().on('video-change', () => this.updateShowing()) | ||
22 | } | 30 | } |
23 | 31 | ||
24 | buildCSSClass () { | 32 | buildCSSClass () { |
@@ -36,7 +44,7 @@ class TheaterButton extends Button { | |||
36 | 44 | ||
37 | saveTheaterInStore(theaterEnabled) | 45 | saveTheaterInStore(theaterEnabled) |
38 | 46 | ||
39 | this.player_.trigger('theaterChange', theaterEnabled) | 47 | this.player_.trigger('theater-change', theaterEnabled) |
40 | } | 48 | } |
41 | 49 | ||
42 | handleClick () { | 50 | handleClick () { |
@@ -48,6 +56,11 @@ class TheaterButton extends Button { | |||
48 | private isTheaterEnabled () { | 56 | private isTheaterEnabled () { |
49 | return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) | 57 | return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) |
50 | } | 58 | } |
59 | |||
60 | private updateShowing () { | ||
61 | if (this.theaterButtonOptions.isDisplayed()) this.show() | ||
62 | else this.hide() | ||
63 | } | ||
51 | } | 64 | } |
52 | 65 | ||
53 | videojs.registerComponent('TheaterButton', TheaterButton) | 66 | videojs.registerComponent('TheaterButton', TheaterButton) |
diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts index 183c7a00f..c13ca647b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-component.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts | |||
@@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = { | |||
10 | 10 | ||
11 | class PeerTubeDockComponent extends Component { | 11 | class PeerTubeDockComponent extends Component { |
12 | 12 | ||
13 | createEl () { | 13 | options_: videojs.ComponentOptions & PeerTubeDockComponentOptions |
14 | const options = this.options_ as PeerTubeDockComponentOptions | ||
15 | 14 | ||
16 | const el = super.createEl('div', { | 15 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor |
17 | className: 'peertube-dock' | 16 | constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeDockComponentOptions) { |
18 | }) | 17 | super(player, options) |
18 | } | ||
19 | |||
20 | createEl () { | ||
21 | const el = super.createEl('div', { className: 'peertube-dock' }) | ||
19 | 22 | ||
20 | if (options.avatarUrl) { | 23 | if (this.options_.avatarUrl) { |
21 | const avatar = videojs.dom.createEl('img', { | 24 | const avatar = videojs.dom.createEl('img', { |
22 | className: 'peertube-dock-avatar', | 25 | className: 'peertube-dock-avatar', |
23 | src: options.avatarUrl | 26 | src: this.options_.avatarUrl |
24 | }) | 27 | }) |
25 | 28 | ||
26 | el.appendChild(avatar) | 29 | el.appendChild(avatar) |
@@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component { | |||
30 | className: 'peertube-dock-title-description' | 33 | className: 'peertube-dock-title-description' |
31 | }) | 34 | }) |
32 | 35 | ||
33 | if (options.title) { | 36 | if (this.options_.title) { |
34 | const title = videojs.dom.createEl('div', { | 37 | const title = videojs.dom.createEl('div', { |
35 | className: 'peertube-dock-title', | 38 | className: 'peertube-dock-title', |
36 | title: options.title, | 39 | title: this.options_.title, |
37 | innerHTML: options.title | 40 | innerHTML: this.options_.title |
38 | }) | 41 | }) |
39 | 42 | ||
40 | elWrapperTitleDescription.appendChild(title) | 43 | elWrapperTitleDescription.appendChild(title) |
41 | } | 44 | } |
42 | 45 | ||
43 | if (options.description) { | 46 | if (this.options_.description) { |
44 | const description = videojs.dom.createEl('div', { | 47 | const description = videojs.dom.createEl('div', { |
45 | className: 'peertube-dock-description', | 48 | className: 'peertube-dock-description', |
46 | title: options.description, | 49 | title: this.options_.description, |
47 | innerHTML: options.description | 50 | innerHTML: this.options_.description |
48 | }) | 51 | }) |
49 | 52 | ||
50 | elWrapperTitleDescription.appendChild(description) | 53 | elWrapperTitleDescription.appendChild(description) |
51 | } | 54 | } |
52 | 55 | ||
53 | if (options.title || options.description) { | 56 | if (this.options_.title || this.options_.description) { |
54 | el.appendChild(elWrapperTitleDescription) | 57 | el.appendChild(elWrapperTitleDescription) |
55 | } | 58 | } |
56 | 59 | ||
diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts index 245981692..fc71a8c4b 100644 --- a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts +++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts | |||
@@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = { | |||
10 | } | 10 | } |
11 | 11 | ||
12 | class PeerTubeDockPlugin extends Plugin { | 12 | class PeerTubeDockPlugin extends Plugin { |
13 | private dockComponent: PeerTubeDockComponent | ||
14 | |||
13 | constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { | 15 | constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { |
14 | super(player, options) | 16 | super(player, options) |
15 | 17 | ||
16 | this.player.addClass('peertube-dock') | 18 | player.ready(() => { |
17 | 19 | player.addClass('peertube-dock') | |
18 | this.player.ready(() => { | ||
19 | this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent | ||
20 | }) | 20 | }) |
21 | |||
22 | this.dockComponent = new PeerTubeDockComponent(player, options) | ||
23 | player.addChild(this.dockComponent) | ||
24 | } | ||
25 | |||
26 | dispose () { | ||
27 | this.dockComponent?.dispose() | ||
28 | this.player.removeChild(this.dockComponent) | ||
29 | this.player.removeClass('peertube-dock') | ||
30 | |||
31 | super.dispose() | ||
21 | } | 32 | } |
22 | } | 33 | } |
23 | 34 | ||
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts index 2742b21a1..e77b7dc6d 100644 --- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts +++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts | |||
@@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin { | |||
31 | 31 | ||
32 | dispose () { | 32 | dispose () { |
33 | document.removeEventListener('keydown', this.handleKeyFunction) | 33 | document.removeEventListener('keydown', this.handleKeyFunction) |
34 | |||
35 | super.dispose() | ||
34 | } | 36 | } |
35 | 37 | ||
36 | private onKeyDown (event: KeyboardEvent) { | 38 | private onKeyDown (event: KeyboardEvent) { |
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts deleted file mode 100644 index 26f923e92..000000000 --- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts +++ /dev/null | |||
@@ -1,155 +0,0 @@ | |||
1 | import { | ||
2 | CommonOptions, | ||
3 | NextPreviousVideoButtonOptions, | ||
4 | PeerTubeLinkButtonOptions, | ||
5 | PeertubePlayerManagerOptions, | ||
6 | PlayerMode | ||
7 | } from '../../types' | ||
8 | |||
9 | export class ControlBarOptionsBuilder { | ||
10 | private options: CommonOptions | ||
11 | |||
12 | constructor ( | ||
13 | globalOptions: PeertubePlayerManagerOptions, | ||
14 | private mode: PlayerMode | ||
15 | ) { | ||
16 | this.options = globalOptions.common | ||
17 | } | ||
18 | |||
19 | getChildrenOptions () { | ||
20 | const children = {} | ||
21 | |||
22 | if (this.options.previousVideo) { | ||
23 | Object.assign(children, this.getPreviousVideo()) | ||
24 | } | ||
25 | |||
26 | Object.assign(children, { playToggle: {} }) | ||
27 | |||
28 | if (this.options.nextVideo) { | ||
29 | Object.assign(children, this.getNextVideo()) | ||
30 | } | ||
31 | |||
32 | Object.assign(children, { | ||
33 | ...this.getTimeControls(), | ||
34 | |||
35 | flexibleWidthSpacer: {}, | ||
36 | |||
37 | ...this.getProgressControl(), | ||
38 | |||
39 | p2PInfoButton: { | ||
40 | p2pEnabled: this.options.p2pEnabled | ||
41 | }, | ||
42 | |||
43 | muteToggle: {}, | ||
44 | volumeControl: {}, | ||
45 | |||
46 | ...this.getSettingsButton() | ||
47 | }) | ||
48 | |||
49 | if (this.options.peertubeLink === true) { | ||
50 | Object.assign(children, { | ||
51 | peerTubeLinkButton: { | ||
52 | shortUUID: this.options.videoShortUUID, | ||
53 | instanceName: this.options.instanceName | ||
54 | } as PeerTubeLinkButtonOptions | ||
55 | }) | ||
56 | } | ||
57 | |||
58 | if (this.options.theaterButton === true) { | ||
59 | Object.assign(children, { | ||
60 | theaterButton: {} | ||
61 | }) | ||
62 | } | ||
63 | |||
64 | Object.assign(children, { | ||
65 | fullscreenToggle: {} | ||
66 | }) | ||
67 | |||
68 | return children | ||
69 | } | ||
70 | |||
71 | private getSettingsButton () { | ||
72 | const settingEntries: string[] = [] | ||
73 | |||
74 | if (!this.options.isLive) { | ||
75 | settingEntries.push('playbackRateMenuButton') | ||
76 | } | ||
77 | |||
78 | if (this.options.captions === true) settingEntries.push('captionsButton') | ||
79 | |||
80 | settingEntries.push('resolutionMenuButton') | ||
81 | |||
82 | return { | ||
83 | settingsButton: { | ||
84 | setup: { | ||
85 | maxHeightOffset: 40 | ||
86 | }, | ||
87 | entries: settingEntries | ||
88 | } | ||
89 | } | ||
90 | } | ||
91 | |||
92 | private getTimeControls () { | ||
93 | if (this.options.isLive) { | ||
94 | return { | ||
95 | peerTubeLiveDisplay: {} | ||
96 | } | ||
97 | } | ||
98 | |||
99 | return { | ||
100 | currentTimeDisplay: {}, | ||
101 | timeDivider: {}, | ||
102 | durationDisplay: {} | ||
103 | } | ||
104 | } | ||
105 | |||
106 | private getProgressControl () { | ||
107 | if (this.options.isLive) return {} | ||
108 | |||
109 | const loadProgressBar = this.mode === 'webtorrent' | ||
110 | ? 'peerTubeLoadProgressBar' | ||
111 | : 'loadProgressBar' | ||
112 | |||
113 | return { | ||
114 | progressControl: { | ||
115 | children: { | ||
116 | seekBar: { | ||
117 | children: { | ||
118 | [loadProgressBar]: {}, | ||
119 | mouseTimeDisplay: {}, | ||
120 | playProgressBar: {} | ||
121 | } | ||
122 | } | ||
123 | } | ||
124 | } | ||
125 | } | ||
126 | } | ||
127 | |||
128 | private getPreviousVideo () { | ||
129 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
130 | type: 'previous', | ||
131 | handler: this.options.previousVideo, | ||
132 | isDisabled: () => { | ||
133 | if (!this.options.hasPreviousVideo) return false | ||
134 | |||
135 | return !this.options.hasPreviousVideo() | ||
136 | } | ||
137 | } | ||
138 | |||
139 | return { previousVideoButton: buttonOptions } | ||
140 | } | ||
141 | |||
142 | private getNextVideo () { | ||
143 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
144 | type: 'next', | ||
145 | handler: this.options.nextVideo, | ||
146 | isDisabled: () => { | ||
147 | if (!this.options.hasNextVideo) return false | ||
148 | |||
149 | return !this.options.hasNextVideo() | ||
150 | } | ||
151 | } | ||
152 | |||
153 | return { nextVideoButton: buttonOptions } | ||
154 | } | ||
155 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts deleted file mode 100644 index 4934d8302..000000000 --- a/client/src/assets/player/shared/manager-options/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './manager-options-builder' | ||
diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts deleted file mode 100644 index 5d3ee4c4a..000000000 --- a/client/src/assets/player/shared/manager-options/manager-options-builder.ts +++ /dev/null | |||
@@ -1,186 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import { copyToClipboard } from '@root-helpers/utils' | ||
3 | import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' | ||
4 | import { isIOS, isSafari } from '@root-helpers/web-browser' | ||
5 | import { buildVideoLink, decorateVideoLink, pick } from '@shared/core-utils' | ||
6 | import { isDefaultLocale } from '@shared/core-utils/i18n' | ||
7 | import { VideoJSPluginOptions } from '../../types' | ||
8 | import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options' | ||
9 | import { ControlBarOptionsBuilder } from './control-bar-options-builder' | ||
10 | import { HLSOptionsBuilder } from './hls-options-builder' | ||
11 | import { WebTorrentOptionsBuilder } from './webtorrent-options-builder' | ||
12 | |||
13 | export class ManagerOptionsBuilder { | ||
14 | |||
15 | constructor ( | ||
16 | private mode: PlayerMode, | ||
17 | private options: PeertubePlayerManagerOptions, | ||
18 | private p2pMediaLoaderModule?: any | ||
19 | ) { | ||
20 | |||
21 | } | ||
22 | |||
23 | async getVideojsOptions (alreadyPlayed: boolean): Promise<videojs.PlayerOptions> { | ||
24 | const commonOptions = this.options.common | ||
25 | |||
26 | let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed) | ||
27 | const html5 = { | ||
28 | preloadTextTracks: false | ||
29 | } | ||
30 | |||
31 | const plugins: VideoJSPluginOptions = { | ||
32 | peertube: { | ||
33 | mode: this.mode, | ||
34 | autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent | ||
35 | |||
36 | ...pick(commonOptions, [ | ||
37 | 'videoViewUrl', | ||
38 | 'videoViewIntervalMs', | ||
39 | 'authorizationHeader', | ||
40 | 'startTime', | ||
41 | 'videoDuration', | ||
42 | 'subtitle', | ||
43 | 'videoCaptions', | ||
44 | 'stopTime', | ||
45 | 'isLive', | ||
46 | 'videoUUID' | ||
47 | ]) | ||
48 | }, | ||
49 | metrics: { | ||
50 | mode: this.mode, | ||
51 | |||
52 | ...pick(commonOptions, [ | ||
53 | 'metricsUrl', | ||
54 | 'videoUUID' | ||
55 | ]) | ||
56 | } | ||
57 | } | ||
58 | |||
59 | if (commonOptions.playlist) { | ||
60 | plugins.playlist = commonOptions.playlist | ||
61 | } | ||
62 | |||
63 | if (this.mode === 'p2p-media-loader') { | ||
64 | const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule) | ||
65 | const options = await hlsOptionsBuilder.getPluginOptions() | ||
66 | |||
67 | Object.assign(plugins, pick(options, [ 'hlsjs', 'p2pMediaLoader' ])) | ||
68 | Object.assign(html5, options.html5) | ||
69 | } else if (this.mode === 'webtorrent') { | ||
70 | const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed)) | ||
71 | |||
72 | Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions()) | ||
73 | |||
74 | // WebTorrent plugin handles autoplay, because we do some hackish stuff in there | ||
75 | autoplay = false | ||
76 | } | ||
77 | |||
78 | const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode) | ||
79 | |||
80 | const videojsOptions = { | ||
81 | html5, | ||
82 | |||
83 | // We don't use text track settings for now | ||
84 | textTrackSettings: false as any, // FIXME: typings | ||
85 | controls: commonOptions.controls !== undefined ? commonOptions.controls : true, | ||
86 | loop: commonOptions.loop !== undefined ? commonOptions.loop : false, | ||
87 | |||
88 | muted: commonOptions.muted !== undefined | ||
89 | ? commonOptions.muted | ||
90 | : undefined, // Undefined so the player knows it has to check the local storage | ||
91 | |||
92 | autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed), | ||
93 | |||
94 | poster: commonOptions.poster, | ||
95 | inactivityTimeout: commonOptions.inactivityTimeout, | ||
96 | playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ], | ||
97 | |||
98 | plugins, | ||
99 | |||
100 | controlBar: { | ||
101 | children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings | ||
102 | } | ||
103 | } | ||
104 | |||
105 | if (commonOptions.language && !isDefaultLocale(commonOptions.language)) { | ||
106 | Object.assign(videojsOptions, { language: commonOptions.language }) | ||
107 | } | ||
108 | |||
109 | return videojsOptions | ||
110 | } | ||
111 | |||
112 | private getAutoPlayValue (autoplay: videojs.Autoplay, alreadyPlayed: boolean) { | ||
113 | if (autoplay !== true) return autoplay | ||
114 | |||
115 | // On first play, disable autoplay to avoid issues | ||
116 | // But if the player already played videos, we can safely autoplay next ones | ||
117 | if (isIOS() || isSafari()) { | ||
118 | return alreadyPlayed ? 'play' : false | ||
119 | } | ||
120 | |||
121 | return this.options.common.forceAutoplay | ||
122 | ? 'any' | ||
123 | : 'play' | ||
124 | } | ||
125 | |||
126 | getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) { | ||
127 | const content = () => { | ||
128 | const isLoopEnabled = player.options_['loop'] | ||
129 | |||
130 | const items = [ | ||
131 | { | ||
132 | icon: 'repeat', | ||
133 | label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''), | ||
134 | listener: function () { | ||
135 | player.options_['loop'] = !isLoopEnabled | ||
136 | } | ||
137 | }, | ||
138 | { | ||
139 | label: player.localize('Copy the video URL'), | ||
140 | listener: function () { | ||
141 | copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID })) | ||
142 | } | ||
143 | }, | ||
144 | { | ||
145 | label: player.localize('Copy the video URL at the current time'), | ||
146 | listener: function (this: videojs.Player) { | ||
147 | const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID }) | ||
148 | |||
149 | copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() })) | ||
150 | } | ||
151 | }, | ||
152 | { | ||
153 | icon: 'code', | ||
154 | label: player.localize('Copy embed code'), | ||
155 | listener: () => { | ||
156 | copyToClipboard(buildVideoOrPlaylistEmbed({ embedUrl: commonOptions.embedUrl, embedTitle: commonOptions.embedTitle })) | ||
157 | } | ||
158 | } | ||
159 | ] | ||
160 | |||
161 | if (this.mode === 'webtorrent') { | ||
162 | items.push({ | ||
163 | label: player.localize('Copy magnet URI'), | ||
164 | listener: function (this: videojs.Player) { | ||
165 | copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri) | ||
166 | } | ||
167 | }) | ||
168 | } | ||
169 | |||
170 | items.push({ | ||
171 | icon: 'info', | ||
172 | label: player.localize('Stats for nerds'), | ||
173 | listener: () => { | ||
174 | player.stats().show() | ||
175 | } | ||
176 | }) | ||
177 | |||
178 | return items.map(i => ({ | ||
179 | ...i, | ||
180 | label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label | ||
181 | })) | ||
182 | } | ||
183 | |||
184 | return { content } | ||
185 | } | ||
186 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts deleted file mode 100644 index 80eec02cf..000000000 --- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts +++ /dev/null | |||
@@ -1,47 +0,0 @@ | |||
1 | import { addQueryParams } from '../../../../../../shared/core-utils' | ||
2 | import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types' | ||
3 | |||
4 | export class WebTorrentOptionsBuilder { | ||
5 | |||
6 | constructor ( | ||
7 | private options: PeertubePlayerManagerOptions, | ||
8 | private autoPlayValue: any | ||
9 | ) { | ||
10 | |||
11 | } | ||
12 | |||
13 | getPluginOptions () { | ||
14 | const commonOptions = this.options.common | ||
15 | const webtorrentOptions = this.options.webtorrent | ||
16 | const p2pMediaLoaderOptions = this.options.p2pMediaLoader | ||
17 | |||
18 | const autoplay = this.autoPlayValue === 'play' | ||
19 | |||
20 | const webtorrent: WebtorrentPluginOptions = { | ||
21 | autoplay, | ||
22 | |||
23 | playerRefusedP2P: commonOptions.p2pEnabled === false, | ||
24 | videoDuration: commonOptions.videoDuration, | ||
25 | playerElement: commonOptions.playerElement, | ||
26 | |||
27 | videoFileToken: commonOptions.videoFileToken, | ||
28 | |||
29 | requiresUserAuth: commonOptions.requiresUserAuth, | ||
30 | |||
31 | buildWebSeedUrls: file => { | ||
32 | if (!commonOptions.requiresUserAuth && !commonOptions.requiresPassword) return [] | ||
33 | |||
34 | return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ] | ||
35 | }, | ||
36 | |||
37 | videoFiles: webtorrentOptions.videoFiles.length !== 0 | ||
38 | ? webtorrentOptions.videoFiles | ||
39 | // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode | ||
40 | : p2pMediaLoaderOptions?.videoFiles || [], | ||
41 | |||
42 | startTime: commonOptions.startTime | ||
43 | } | ||
44 | |||
45 | return { webtorrent } | ||
46 | } | ||
47 | } | ||
diff --git a/client/src/assets/player/shared/metrics/metrics-plugin.ts b/client/src/assets/player/shared/metrics/metrics-plugin.ts index 2aae3e90a..48363a724 100644 --- a/client/src/assets/player/shared/metrics/metrics-plugin.ts +++ b/client/src/assets/player/shared/metrics/metrics-plugin.ts | |||
@@ -1,14 +1,15 @@ | |||
1 | import debug from 'debug' | ||
1 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
2 | import { PlaybackMetricCreate } from '../../../../../../shared/models' | ||
3 | import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types' | ||
4 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { PlaybackMetricCreate } from '../../../../../../shared/models' | ||
5 | import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types' | ||
6 | |||
7 | const debugLogger = debug('peertube:player:metrics') | ||
5 | 8 | ||
6 | const Plugin = videojs.getPlugin('plugin') | 9 | const Plugin = videojs.getPlugin('plugin') |
7 | 10 | ||
8 | class MetricsPlugin extends Plugin { | 11 | class MetricsPlugin extends Plugin { |
9 | private readonly metricsUrl: string | 12 | options_: MetricsPluginOptions |
10 | private readonly videoUUID: string | ||
11 | private readonly mode: PlayerMode | ||
12 | 13 | ||
13 | private downloadedBytesP2P = 0 | 14 | private downloadedBytesP2P = 0 |
14 | private downloadedBytesHTTP = 0 | 15 | private downloadedBytesHTTP = 0 |
@@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin { | |||
28 | constructor (player: videojs.Player, options: MetricsPluginOptions) { | 29 | constructor (player: videojs.Player, options: MetricsPluginOptions) { |
29 | super(player) | 30 | super(player) |
30 | 31 | ||
31 | this.metricsUrl = options.metricsUrl | 32 | this.options_ = options |
32 | this.videoUUID = options.videoUUID | ||
33 | this.mode = options.mode | ||
34 | 33 | ||
35 | this.player.one('play', () => { | 34 | this.trackBytes() |
36 | this.runMetricsInterval() | 35 | this.trackResolutionChange() |
36 | this.trackErrors() | ||
37 | 37 | ||
38 | this.trackBytes() | 38 | this.one('play', () => { |
39 | this.trackResolutionChange() | 39 | this.player.on('video-change', () => { |
40 | this.trackErrors() | 40 | this.runMetricsIntervalOnPlay() |
41 | }) | ||
41 | }) | 42 | }) |
43 | |||
44 | this.runMetricsIntervalOnPlay() | ||
42 | } | 45 | } |
43 | 46 | ||
44 | dispose () { | 47 | dispose () { |
45 | if (this.metricsInterval) clearInterval(this.metricsInterval) | 48 | if (this.metricsInterval) clearInterval(this.metricsInterval) |
49 | |||
50 | super.dispose() | ||
51 | } | ||
52 | |||
53 | private runMetricsIntervalOnPlay () { | ||
54 | this.downloadedBytesP2P = 0 | ||
55 | this.downloadedBytesHTTP = 0 | ||
56 | this.uploadedBytesP2P = 0 | ||
57 | |||
58 | this.resolutionChanges = 0 | ||
59 | this.errors = 0 | ||
60 | |||
61 | this.lastPlayerNetworkInfo = undefined | ||
62 | |||
63 | debugLogger('Will track metrics on next play') | ||
64 | |||
65 | this.player.one('play', () => { | ||
66 | debugLogger('Tracking metrics') | ||
67 | |||
68 | this.runMetricsInterval() | ||
69 | }) | ||
46 | } | 70 | } |
47 | 71 | ||
48 | private runMetricsInterval () { | 72 | private runMetricsInterval () { |
73 | if (this.metricsInterval) clearInterval(this.metricsInterval) | ||
74 | |||
49 | this.metricsInterval = setInterval(() => { | 75 | this.metricsInterval = setInterval(() => { |
50 | let resolution: number | 76 | let resolution: number |
51 | let fps: number | 77 | let fps: number |
52 | 78 | ||
53 | if (this.mode === 'p2p-media-loader') { | 79 | if (this.player.usingPlugin('p2pMediaLoader')) { |
54 | const level = this.player.p2pMediaLoader().getCurrentLevel() | 80 | const level = this.player.p2pMediaLoader().getCurrentLevel() |
55 | if (!level) return | 81 | if (!level) return |
56 | 82 | ||
@@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin { | |||
60 | fps = framerate | 86 | fps = framerate |
61 | ? parseInt(framerate, 10) | 87 | ? parseInt(framerate, 10) |
62 | : undefined | 88 | : undefined |
63 | } else { // webtorrent | 89 | } else if (this.player.usingPlugin('webVideo')) { |
64 | const videoFile = this.player.webtorrent().getCurrentVideoFile() | 90 | const videoFile = this.player.webVideo().getCurrentVideoFile() |
65 | if (!videoFile) return | 91 | if (!videoFile) return |
66 | 92 | ||
67 | resolution = videoFile.resolution.id | 93 | resolution = videoFile.resolution.id |
68 | fps = videoFile.fps && videoFile.fps !== -1 | 94 | fps = videoFile.fps && videoFile.fps !== -1 |
69 | ? videoFile.fps | 95 | ? videoFile.fps |
70 | : undefined | 96 | : undefined |
97 | } else { | ||
98 | return | ||
71 | } | 99 | } |
72 | 100 | ||
73 | const body: PlaybackMetricCreate = { | 101 | const body: PlaybackMetricCreate = { |
74 | resolution, | 102 | resolution, |
75 | fps, | 103 | fps, |
76 | 104 | ||
77 | playerMode: this.mode, | 105 | playerMode: this.options_.mode(), |
78 | 106 | ||
79 | resolutionChanges: this.resolutionChanges, | 107 | resolutionChanges: this.resolutionChanges, |
80 | 108 | ||
@@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin { | |||
85 | 113 | ||
86 | uploadedBytesP2P: this.uploadedBytesP2P, | 114 | uploadedBytesP2P: this.uploadedBytesP2P, |
87 | 115 | ||
88 | videoId: this.videoUUID | 116 | videoId: this.options_.videoUUID() |
89 | } | 117 | } |
90 | 118 | ||
91 | this.resolutionChanges = 0 | 119 | this.resolutionChanges = 0 |
@@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin { | |||
99 | 127 | ||
100 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) | 128 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) |
101 | 129 | ||
102 | return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers }) | 130 | return fetch(this.options_.metricsUrl(), { method: 'POST', body: JSON.stringify(body), headers }) |
103 | .catch(err => logger.error('Cannot send metrics to the server.', err)) | 131 | .catch(err => logger.error('Cannot send metrics to the server.', err)) |
104 | }, this.CONSTANTS.METRICS_INTERVAL) | 132 | }, this.CONSTANTS.METRICS_INTERVAL) |
105 | } | 133 | } |
106 | 134 | ||
107 | private trackBytes () { | 135 | private trackBytes () { |
108 | this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => { | 136 | this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => { |
109 | if (!data) return | ||
110 | |||
111 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) | 137 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) |
112 | this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) | 138 | this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) |
113 | 139 | ||
@@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin { | |||
115 | 141 | ||
116 | this.lastPlayerNetworkInfo = data | 142 | this.lastPlayerNetworkInfo = data |
117 | }) | 143 | }) |
144 | |||
145 | this.player.on('http-info', (_event, data: PlayerNetworkInfo) => { | ||
146 | this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) | ||
147 | }) | ||
118 | } | 148 | } |
119 | 149 | ||
120 | private trackResolutionChange () { | 150 | private trackResolutionChange () { |
121 | this.player.on('engineResolutionChange', () => { | 151 | this.player.on('engine-resolution-change', () => { |
152 | this.resolutionChanges++ | ||
153 | }) | ||
154 | |||
155 | this.player.on('user-resolution-change', () => { | ||
122 | this.resolutionChanges++ | 156 | this.resolutionChanges++ |
123 | }) | 157 | }) |
124 | } | 158 | } |
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts index 09cb98f2e..1bc3ca38d 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts | |||
@@ -2,22 +2,20 @@ import videojs from 'video.js' | |||
2 | 2 | ||
3 | const Component = videojs.getComponent('Component') | 3 | const Component = videojs.getComponent('Component') |
4 | class PeerTubeMobileButtons extends Component { | 4 | class PeerTubeMobileButtons extends Component { |
5 | private mainButton: HTMLDivElement | ||
5 | 6 | ||
6 | private rewind: Element | 7 | private rewind: Element |
7 | private forward: Element | 8 | private forward: Element |
8 | private rewindText: Element | 9 | private rewindText: Element |
9 | private forwardText: Element | 10 | private forwardText: Element |
10 | 11 | ||
11 | createEl () { | 12 | private touchStartHandler: (e: TouchEvent) => void |
12 | const container = super.createEl('div', { | ||
13 | className: 'vjs-mobile-buttons-overlay' | ||
14 | }) as HTMLDivElement | ||
15 | 13 | ||
16 | const mainButton = super.createEl('div', { | 14 | createEl () { |
17 | className: 'main-button' | 15 | const container = super.createEl('div', { className: 'vjs-mobile-buttons-overlay' }) as HTMLDivElement |
18 | }) as HTMLDivElement | 16 | this.mainButton = super.createEl('div', { className: 'main-button' }) as HTMLDivElement |
19 | 17 | ||
20 | mainButton.addEventListener('touchstart', e => { | 18 | this.touchStartHandler = e => { |
21 | e.stopPropagation() | 19 | e.stopPropagation() |
22 | 20 | ||
23 | if (this.player_.paused() || this.player_.ended()) { | 21 | if (this.player_.paused() || this.player_.ended()) { |
@@ -26,7 +24,9 @@ class PeerTubeMobileButtons extends Component { | |||
26 | } | 24 | } |
27 | 25 | ||
28 | this.player_.pause() | 26 | this.player_.pause() |
29 | }) | 27 | } |
28 | |||
29 | this.mainButton.addEventListener('touchstart', this.touchStartHandler, { passive: true }) | ||
30 | 30 | ||
31 | this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) | 31 | this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) |
32 | this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) | 32 | this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) |
@@ -40,12 +40,18 @@ class PeerTubeMobileButtons extends Component { | |||
40 | this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) | 40 | this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) |
41 | 41 | ||
42 | container.appendChild(this.rewind) | 42 | container.appendChild(this.rewind) |
43 | container.appendChild(mainButton) | 43 | container.appendChild(this.mainButton) |
44 | container.appendChild(this.forward) | 44 | container.appendChild(this.forward) |
45 | 45 | ||
46 | return container | 46 | return container |
47 | } | 47 | } |
48 | 48 | ||
49 | dispose () { | ||
50 | if (this.touchStartHandler) this.mainButton.removeEventListener('touchstart', this.touchStartHandler) | ||
51 | |||
52 | super.dispose() | ||
53 | } | ||
54 | |||
49 | displayFastSeek (amount: number) { | 55 | displayFastSeek (amount: number) { |
50 | if (amount === 0) { | 56 | if (amount === 0) { |
51 | this.hideRewind() | 57 | this.hideRewind() |
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts index 646e9f8c6..f31fa7ddb 100644 --- a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts +++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts | |||
@@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin { | |||
21 | 21 | ||
22 | private setCurrentTimeTimeout: ReturnType<typeof setTimeout> | 22 | private setCurrentTimeTimeout: ReturnType<typeof setTimeout> |
23 | 23 | ||
24 | private onPlayHandler: () => void | ||
25 | private onFullScreenChangeHandler: () => void | ||
26 | private onTouchStartHandler: (event: TouchEvent) => void | ||
27 | private onMobileButtonTouchStartHandler: (event: TouchEvent) => void | ||
28 | private sliderActiveHandler: () => void | ||
29 | private sliderInactiveHandler: () => void | ||
30 | |||
31 | private seekBar: videojs.Component | ||
32 | |||
24 | constructor (player: videojs.Player, options: videojs.PlayerOptions) { | 33 | constructor (player: videojs.Player, options: videojs.PlayerOptions) { |
25 | super(player, options) | 34 | super(player, options) |
26 | 35 | ||
@@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin { | |||
36 | (this.player.options_.userActions as any).click = false | 45 | (this.player.options_.userActions as any).click = false |
37 | this.player.options_.userActions.doubleClick = false | 46 | this.player.options_.userActions.doubleClick = false |
38 | 47 | ||
39 | this.player.one('play', () => { | 48 | this.onPlayHandler = () => this.initTouchStartEvents() |
40 | this.initTouchStartEvents() | 49 | this.player.one('play', this.onPlayHandler) |
41 | }) | 50 | |
51 | this.seekBar = this.player.getDescendant([ 'controlBar', 'progressControl', 'seekBar' ]) | ||
52 | |||
53 | this.sliderActiveHandler = () => this.player.addClass('vjs-mobile-sliding') | ||
54 | this.sliderInactiveHandler = () => this.player.removeClass('vjs-mobile-sliding') | ||
55 | |||
56 | this.seekBar.on('slideractive', this.sliderActiveHandler) | ||
57 | this.seekBar.on('sliderinactive', this.sliderInactiveHandler) | ||
58 | } | ||
59 | |||
60 | dispose () { | ||
61 | if (this.onPlayHandler) this.player.off('play', this.onPlayHandler) | ||
62 | if (this.onFullScreenChangeHandler) this.player.off('fullscreenchange', this.onFullScreenChangeHandler) | ||
63 | if (this.onTouchStartHandler) this.player.off('touchstart', this.onFullScreenChangeHandler) | ||
64 | if (this.onMobileButtonTouchStartHandler) { | ||
65 | this.peerTubeMobileButtons?.el().removeEventListener('touchstart', this.onMobileButtonTouchStartHandler) | ||
66 | } | ||
67 | |||
68 | super.dispose() | ||
42 | } | 69 | } |
43 | 70 | ||
44 | private handleFullscreenRotation () { | 71 | private handleFullscreenRotation () { |
45 | this.player.on('fullscreenchange', () => { | 72 | this.onFullScreenChangeHandler = () => { |
46 | if (!this.player.isFullscreen() || this.isPortraitVideo()) return | 73 | if (!this.player.isFullscreen() || this.isPortraitVideo()) return |
47 | 74 | ||
48 | screen.orientation.lock('landscape') | 75 | screen.orientation.lock('landscape') |
49 | .catch(err => logger.error('Cannot lock screen to landscape.', err)) | 76 | .catch(err => logger.error('Cannot lock screen to landscape.', err)) |
50 | }) | 77 | } |
78 | |||
79 | this.player.on('fullscreenchange', this.onFullScreenChangeHandler) | ||
51 | } | 80 | } |
52 | 81 | ||
53 | private isPortraitVideo () { | 82 | private isPortraitVideo () { |
@@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin { | |||
80 | this.lastTapEvent = event | 109 | this.lastTapEvent = event |
81 | } | 110 | } |
82 | 111 | ||
83 | this.player.on('touchstart', (event: TouchEvent) => { | 112 | this.onTouchStartHandler = event => { |
84 | // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it | 113 | // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it |
85 | if (this.player.userActive()) return | 114 | if (this.player.userActive()) return |
86 | 115 | ||
87 | handleTouchStart(event) | 116 | handleTouchStart(event) |
88 | }) | 117 | } |
118 | this.player.on('touchstart', this.onTouchStartHandler) | ||
89 | 119 | ||
90 | this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { | 120 | this.onMobileButtonTouchStartHandler = event => { |
91 | // Prevent mousemove/click events firing on the player, that conflict with our user active logic | 121 | // Prevent mousemove/click events firing on the player, that conflict with our user active logic |
92 | event.preventDefault() | 122 | event.preventDefault() |
93 | 123 | ||
94 | handleTouchStart(event) | 124 | handleTouchStart(event) |
95 | }, { passive: false }) | 125 | } |
126 | |||
127 | this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false }) | ||
96 | } | 128 | } |
97 | 129 | ||
98 | private onDoubleTap (event: TouchEvent) { | 130 | private onDoubleTap (event: TouchEvent) { |
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts index d05d6193c..d83ec625a 100644 --- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts | |||
@@ -14,6 +14,10 @@ type Metadata = { | |||
14 | levels: Level[] | 14 | levels: Level[] |
15 | } | 15 | } |
16 | 16 | ||
17 | // --------------------------------------------------------------------------- | ||
18 | // Source handler registration | ||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
17 | type HookFn = (player: videojs.Player, hljs: Hlsjs) => void | 21 | type HookFn = (player: videojs.Player, hljs: Hlsjs) => void |
18 | 22 | ||
19 | const registerSourceHandler = function (vjs: typeof videojs) { | 23 | const registerSourceHandler = function (vjs: typeof videojs) { |
@@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) { | |||
25 | const html5 = vjs.getTech('Html5') | 29 | const html5 = vjs.getTech('Html5') |
26 | 30 | ||
27 | if (!html5) { | 31 | if (!html5) { |
28 | logger.error('No Hml5 tech found in videojs') | 32 | logger.error('No "Html5" tech found in videojs') |
29 | return | 33 | return |
30 | } | 34 | } |
31 | 35 | ||
36 | // Already registered | ||
37 | if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return | ||
38 | |||
32 | // FIXME: typings | 39 | // FIXME: typings |
33 | (html5 as any).registerSourceHandler({ | 40 | (html5 as any).registerSourceHandler({ |
34 | canHandleSource: function (source: videojs.Tech.SourceObject) { | 41 | canHandleSource: function (source: videojs.Tech.SourceObject) { |
@@ -56,32 +63,55 @@ const registerSourceHandler = function (vjs: typeof videojs) { | |||
56 | (vjs as any).Html5Hlsjs = Html5Hlsjs | 63 | (vjs as any).Html5Hlsjs = Html5Hlsjs |
57 | } | 64 | } |
58 | 65 | ||
59 | function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { | 66 | // --------------------------------------------------------------------------- |
60 | const player = this | 67 | // HLS options plugin |
68 | // --------------------------------------------------------------------------- | ||
61 | 69 | ||
62 | if (!options) return | 70 | const Plugin = videojs.getPlugin('plugin') |
63 | 71 | ||
64 | if (!player.srOptions_) { | 72 | class HLSJSConfigHandler extends Plugin { |
65 | player.srOptions_ = {} | 73 | |
66 | } | 74 | constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) { |
75 | super(player, options) | ||
76 | |||
77 | if (!options) return | ||
78 | |||
79 | if (!player.srOptions_) { | ||
80 | player.srOptions_ = {} | ||
81 | } | ||
82 | |||
83 | if (!player.srOptions_.hlsjsConfig) { | ||
84 | player.srOptions_.hlsjsConfig = options.hlsjsConfig | ||
85 | } | ||
67 | 86 | ||
68 | if (!player.srOptions_.hlsjsConfig) { | 87 | if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { |
69 | player.srOptions_.hlsjsConfig = options.hlsjsConfig | 88 | player.srOptions_.levelLabelHandler = options.levelLabelHandler |
89 | } | ||
90 | |||
91 | registerSourceHandler(videojs) | ||
70 | } | 92 | } |
71 | 93 | ||
72 | if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { | 94 | dispose () { |
73 | player.srOptions_.levelLabelHandler = options.levelLabelHandler | 95 | this.player.srOptions_ = undefined |
96 | |||
97 | const tech = this.player.tech(true) as any | ||
98 | if (tech.hlsProvider) { | ||
99 | tech.hlsProvider.dispose() | ||
100 | tech.hlsProvider = undefined | ||
101 | } | ||
102 | |||
103 | super.dispose() | ||
74 | } | 104 | } |
75 | } | 105 | } |
76 | 106 | ||
77 | const registerConfigPlugin = function (vjs: typeof videojs) { | 107 | videojs.registerPlugin('hlsjs', HLSJSConfigHandler) |
78 | // Used in Brightcove since we don't pass options directly there | 108 | |
79 | const registerVjsPlugin = vjs.registerPlugin || vjs.plugin | 109 | // --------------------------------------------------------------------------- |
80 | registerVjsPlugin('hlsjs', hlsjsConfigHandler) | 110 | // HLS JS source handler |
81 | } | 111 | // --------------------------------------------------------------------------- |
82 | 112 | ||
83 | class Html5Hlsjs { | 113 | export class Html5Hlsjs { |
84 | private static readonly hooks: { [id: string]: HookFn[] } = {} | 114 | private static hooks: { [id: string]: HookFn[] } = {} |
85 | 115 | ||
86 | private readonly videoElement: HTMLVideoElement | 116 | private readonly videoElement: HTMLVideoElement |
87 | private readonly errorCounts: ErrorCounts = {} | 117 | private readonly errorCounts: ErrorCounts = {} |
@@ -101,8 +131,9 @@ class Html5Hlsjs { | |||
101 | private dvrDuration: number = null | 131 | private dvrDuration: number = null |
102 | private edgeMargin: number = null | 132 | private edgeMargin: number = null |
103 | 133 | ||
104 | private handlers: { [ id in 'play' ]: EventListener } = { | 134 | private handlers: { [ id in 'play' | 'error' ]: EventListener } = { |
105 | play: null | 135 | play: null, |
136 | error: null | ||
106 | } | 137 | } |
107 | 138 | ||
108 | constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { | 139 | constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { |
@@ -115,7 +146,7 @@ class Html5Hlsjs { | |||
115 | this.videoElement = tech.el() as HTMLVideoElement | 146 | this.videoElement = tech.el() as HTMLVideoElement |
116 | this.player = vjs((tech.options_ as any).playerId) | 147 | this.player = vjs((tech.options_ as any).playerId) |
117 | 148 | ||
118 | this.videoElement.addEventListener('error', event => { | 149 | this.handlers.error = event => { |
119 | let errorTxt: string | 150 | let errorTxt: string |
120 | const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error | 151 | const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error |
121 | 152 | ||
@@ -143,7 +174,8 @@ class Html5Hlsjs { | |||
143 | } | 174 | } |
144 | 175 | ||
145 | logger.error(`MEDIA_ERROR: ${errorTxt}`) | 176 | logger.error(`MEDIA_ERROR: ${errorTxt}`) |
146 | }) | 177 | } |
178 | this.videoElement.addEventListener('error', this.handlers.error) | ||
147 | 179 | ||
148 | this.initialize() | 180 | this.initialize() |
149 | } | 181 | } |
@@ -174,6 +206,7 @@ class Html5Hlsjs { | |||
174 | // See comment for `initialize` method. | 206 | // See comment for `initialize` method. |
175 | dispose () { | 207 | dispose () { |
176 | this.videoElement.removeEventListener('play', this.handlers.play) | 208 | this.videoElement.removeEventListener('play', this.handlers.play) |
209 | this.videoElement.removeEventListener('error', this.handlers.error) | ||
177 | 210 | ||
178 | // FIXME: https://github.com/video-dev/hls.js/issues/4092 | 211 | // FIXME: https://github.com/video-dev/hls.js/issues/4092 |
179 | const untypedHLS = this.hls as any | 212 | const untypedHLS = this.hls as any |
@@ -200,6 +233,10 @@ class Html5Hlsjs { | |||
200 | return true | 233 | return true |
201 | } | 234 | } |
202 | 235 | ||
236 | static removeAllHooks () { | ||
237 | Html5Hlsjs.hooks = {} | ||
238 | } | ||
239 | |||
203 | private _executeHooksFor (type: string) { | 240 | private _executeHooksFor (type: string) { |
204 | if (Html5Hlsjs.hooks[type] === undefined) { | 241 | if (Html5Hlsjs.hooks[type] === undefined) { |
205 | return | 242 | return |
@@ -421,7 +458,7 @@ class Html5Hlsjs { | |||
421 | ? data.level | 458 | ? data.level |
422 | : -1 | 459 | : -1 |
423 | 460 | ||
424 | this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) | 461 | this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false }) |
425 | }) | 462 | }) |
426 | 463 | ||
427 | this.hls.attachMedia(this.videoElement) | 464 | this.hls.attachMedia(this.videoElement) |
@@ -433,9 +470,3 @@ class Html5Hlsjs { | |||
433 | this._initHlsjs() | 470 | this._initHlsjs() |
434 | } | 471 | } |
435 | } | 472 | } |
436 | |||
437 | export { | ||
438 | Html5Hlsjs, | ||
439 | registerSourceHandler, | ||
440 | registerConfigPlugin | ||
441 | } | ||
diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts index e6f525fea..fe967a730 100644 --- a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts | |||
@@ -3,19 +3,12 @@ import videojs from 'video.js' | |||
3 | import { Events, Segment } from '@peertube/p2p-media-loader-core' | 3 | import { Events, Segment } from '@peertube/p2p-media-loader-core' |
4 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' | 4 | import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' |
5 | import { logger } from '@root-helpers/logger' | 5 | import { logger } from '@root-helpers/logger' |
6 | import { addQueryParams, timeToInt } from '@shared/core-utils' | 6 | import { addQueryParams } from '@shared/core-utils' |
7 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' | 7 | import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' |
8 | import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' | 8 | import { SettingsButton } from '../settings/settings-menu-button' |
9 | |||
10 | registerConfigPlugin(videojs) | ||
11 | registerSourceHandler(videojs) | ||
12 | 9 | ||
13 | const Plugin = videojs.getPlugin('plugin') | 10 | const Plugin = videojs.getPlugin('plugin') |
14 | class P2pMediaLoaderPlugin extends Plugin { | 11 | class P2pMediaLoaderPlugin extends Plugin { |
15 | |||
16 | private readonly CONSTANTS = { | ||
17 | INFO_SCHEDULER: 1000 // Don't change this | ||
18 | } | ||
19 | private readonly options: P2PMediaLoaderPluginOptions | 12 | private readonly options: P2PMediaLoaderPluginOptions |
20 | 13 | ||
21 | private hlsjs: Hlsjs | 14 | private hlsjs: Hlsjs |
@@ -31,7 +24,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
31 | pendingDownload: [] as number[], | 24 | pendingDownload: [] as number[], |
32 | totalDownload: 0 | 25 | totalDownload: 0 |
33 | } | 26 | } |
34 | private startTime: number | ||
35 | 27 | ||
36 | private networkInfoInterval: any | 28 | private networkInfoInterval: any |
37 | 29 | ||
@@ -39,7 +31,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
39 | super(player) | 31 | super(player) |
40 | 32 | ||
41 | this.options = options | 33 | this.options = options |
42 | this.startTime = timeToInt(options.startTime) | ||
43 | 34 | ||
44 | // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 | 35 | // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 |
45 | if (!(videojs as any).Html5Hlsjs) { | 36 | if (!(videojs as any).Html5Hlsjs) { |
@@ -77,17 +68,22 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
77 | }) | 68 | }) |
78 | 69 | ||
79 | player.ready(() => { | 70 | player.ready(() => { |
80 | this.initializeCore() | ||
81 | |||
82 | this.initializePlugin() | 71 | this.initializePlugin() |
83 | }) | 72 | }) |
84 | } | 73 | } |
85 | 74 | ||
86 | dispose () { | 75 | dispose () { |
87 | if (this.hlsjs) this.hlsjs.destroy() | 76 | this.p2pEngine?.removeAllListeners() |
88 | if (this.p2pEngine) this.p2pEngine.destroy() | 77 | this.p2pEngine?.destroy() |
78 | |||
79 | this.hlsjs?.destroy() | ||
80 | this.options.segmentValidator?.destroy(); | ||
81 | |||
82 | (videojs as any).Html5Hlsjs?.removeAllHooks() | ||
89 | 83 | ||
90 | clearInterval(this.networkInfoInterval) | 84 | clearInterval(this.networkInfoInterval) |
85 | |||
86 | super.dispose() | ||
91 | } | 87 | } |
92 | 88 | ||
93 | getCurrentLevel () { | 89 | getCurrentLevel () { |
@@ -104,18 +100,6 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
104 | return this.hlsjs | 100 | return this.hlsjs |
105 | } | 101 | } |
106 | 102 | ||
107 | private initializeCore () { | ||
108 | this.player.one('play', () => { | ||
109 | this.player.addClass('vjs-has-big-play-button-clicked') | ||
110 | }) | ||
111 | |||
112 | this.player.one('canplay', () => { | ||
113 | if (this.startTime) { | ||
114 | this.player.currentTime(this.startTime) | ||
115 | } | ||
116 | }) | ||
117 | } | ||
118 | |||
119 | private initializePlugin () { | 103 | private initializePlugin () { |
120 | initHlsJsPlayer(this.hlsjs) | 104 | initHlsJsPlayer(this.hlsjs) |
121 | 105 | ||
@@ -133,7 +117,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
133 | 117 | ||
134 | this.runStats() | 118 | this.runStats() |
135 | 119 | ||
136 | this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange')) | 120 | this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engine-resolution-change')) |
137 | } | 121 | } |
138 | 122 | ||
139 | private runStats () { | 123 | private runStats () { |
@@ -167,7 +151,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
167 | this.statsP2PBytes.pendingUpload = [] | 151 | this.statsP2PBytes.pendingUpload = [] |
168 | this.statsHTTPBytes.pendingDownload = [] | 152 | this.statsHTTPBytes.pendingDownload = [] |
169 | 153 | ||
170 | return this.player.trigger('p2pInfo', { | 154 | return this.player.trigger('p2p-info', { |
171 | source: 'p2p-media-loader', | 155 | source: 'p2p-media-loader', |
172 | http: { | 156 | http: { |
173 | downloadSpeed: httpDownloadSpeed, | 157 | downloadSpeed: httpDownloadSpeed, |
@@ -182,7 +166,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
182 | }, | 166 | }, |
183 | bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 | 167 | bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 |
184 | } as PlayerNetworkInfo) | 168 | } as PlayerNetworkInfo) |
185 | }, this.CONSTANTS.INFO_SCHEDULER) | 169 | }, 1000) |
186 | } | 170 | } |
187 | 171 | ||
188 | private arraySum (data: number[]) { | 172 | private arraySum (data: number[]) { |
@@ -190,10 +174,7 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
190 | } | 174 | } |
191 | 175 | ||
192 | private fallbackToBuiltInIOS () { | 176 | private fallbackToBuiltInIOS () { |
193 | logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.'); | 177 | logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.') |
194 | |||
195 | // Workaround to force video.js to not re create a video element | ||
196 | (this.player as any).playerElIngest_ = this.player.el().parentNode | ||
197 | 178 | ||
198 | this.player.src({ | 179 | this.player.src({ |
199 | type: this.options.type, | 180 | type: this.options.type, |
@@ -203,9 +184,14 @@ class P2pMediaLoaderPlugin extends Plugin { | |||
203 | }) | 184 | }) |
204 | }) | 185 | }) |
205 | 186 | ||
206 | this.player.ready(() => { | 187 | // Resolution button is not supported in built-in HLS player |
207 | this.initializeCore() | 188 | this.getResolutionButton().hide() |
208 | }) | 189 | } |
190 | |||
191 | private getResolutionButton () { | ||
192 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
193 | |||
194 | return settingsButton.menu.getChild('resolutionMenuButton') | ||
209 | } | 195 | } |
210 | } | 196 | } |
211 | 197 | ||
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts index e86d3d159..a2f7e676d 100644 --- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts +++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts | |||
@@ -9,30 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string | |||
9 | 9 | ||
10 | const maxRetries = 10 | 10 | const maxRetries = 10 |
11 | 11 | ||
12 | function segmentValidatorFactory (options: { | 12 | export class SegmentValidator { |
13 | serverUrl: string | 13 | |
14 | segmentsSha256Url: string | 14 | private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/ |
15 | authorizationHeader: () => string | 15 | |
16 | requiresUserAuth: boolean | 16 | private destroyed = false |
17 | requiresPassword: boolean | 17 | |
18 | videoPassword: () => string | 18 | constructor (private readonly options: { |
19 | }) { | 19 | serverUrl: string |
20 | const { serverUrl, segmentsSha256Url, authorizationHeader, requiresUserAuth, requiresPassword, videoPassword } = options | 20 | segmentsSha256Url: string |
21 | 21 | authorizationHeader: () => string | |
22 | let segmentsJSON = fetchSha256Segments({ | 22 | requiresUserAuth: boolean |
23 | serverUrl, | 23 | requiresPassword: boolean |
24 | segmentsSha256Url, | 24 | videoPassword: () => string |
25 | authorizationHeader, | 25 | }) { |
26 | requiresUserAuth, | 26 | |
27 | requiresPassword, | 27 | } |
28 | videoPassword | 28 | |
29 | }) | 29 | async validate (segment: Segment, _method: string, _peerId: string, retry = 1) { |
30 | const regex = /bytes=(\d+)-(\d+)/ | 30 | if (this.destroyed) return |
31 | 31 | ||
32 | return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { | ||
33 | const filename = basename(removeQueryParams(segment.url)) | 32 | const filename = basename(removeQueryParams(segment.url)) |
34 | 33 | ||
35 | const segmentValue = (await segmentsJSON)[filename] | 34 | const segmentValue = (await this.fetchSha256Segments())[filename] |
36 | 35 | ||
37 | if (!segmentValue && retry > maxRetries) { | 36 | if (!segmentValue && retry > maxRetries) { |
38 | throw new Error(`Unknown segment name ${filename} in segment validator`) | 37 | throw new Error(`Unknown segment name ${filename} in segment validator`) |
@@ -43,15 +42,7 @@ function segmentValidatorFactory (options: { | |||
43 | 42 | ||
44 | await wait(500) | 43 | await wait(500) |
45 | 44 | ||
46 | segmentsJSON = fetchSha256Segments({ | 45 | await this.validate(segment, _method, _peerId, retry + 1) |
47 | serverUrl, | ||
48 | segmentsSha256Url, | ||
49 | authorizationHeader, | ||
50 | requiresUserAuth, | ||
51 | requiresPassword, | ||
52 | videoPassword | ||
53 | }) | ||
54 | await segmentValidator(segment, _method, _peerId, retry + 1) | ||
55 | 46 | ||
56 | return | 47 | return |
57 | } | 48 | } |
@@ -62,7 +53,7 @@ function segmentValidatorFactory (options: { | |||
62 | if (typeof segmentValue === 'string') { | 53 | if (typeof segmentValue === 'string') { |
63 | hashShouldBe = segmentValue | 54 | hashShouldBe = segmentValue |
64 | } else { | 55 | } else { |
65 | const captured = regex.exec(segment.range) | 56 | const captured = this.bytesRangeRegex.exec(segment.range) |
66 | range = captured[1] + '-' + captured[2] | 57 | range = captured[1] + '-' + captured[2] |
67 | 58 | ||
68 | hashShouldBe = segmentValue[range] | 59 | hashShouldBe = segmentValue[range] |
@@ -72,7 +63,7 @@ function segmentValidatorFactory (options: { | |||
72 | throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) | 63 | throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) |
73 | } | 64 | } |
74 | 65 | ||
75 | const calculatedSha = await sha256Hex(segment.data) | 66 | const calculatedSha = await this.sha256Hex(segment.data) |
76 | if (calculatedSha !== hashShouldBe) { | 67 | if (calculatedSha !== hashShouldBe) { |
77 | throw new Error( | 68 | throw new Error( |
78 | `Hashes does not correspond for segment ${filename}/${range}` + | 69 | `Hashes does not correspond for segment ${filename}/${range}` + |
@@ -80,65 +71,53 @@ function segmentValidatorFactory (options: { | |||
80 | ) | 71 | ) |
81 | } | 72 | } |
82 | } | 73 | } |
83 | } | ||
84 | 74 | ||
85 | // --------------------------------------------------------------------------- | 75 | destroy () { |
76 | this.destroyed = true | ||
77 | } | ||
86 | 78 | ||
87 | export { | 79 | private fetchSha256Segments (): Promise<SegmentsJSON> { |
88 | segmentValidatorFactory | 80 | let headers: { [ id: string ]: string } = {} |
89 | } | ||
90 | 81 | ||
91 | // --------------------------------------------------------------------------- | 82 | if (isSameOrigin(this.options.serverUrl, this.options.segmentsSha256Url)) { |
92 | 83 | if (this.options.requiresPassword) headers = { 'x-peertube-video-password': this.options.videoPassword() } | |
93 | function fetchSha256Segments (options: { | 84 | else if (this.options.requiresUserAuth) headers = { Authorization: this.options.authorizationHeader() } |
94 | serverUrl: string | 85 | } |
95 | segmentsSha256Url: string | 86 | |
96 | authorizationHeader: () => string | 87 | return fetch(this.options.segmentsSha256Url, { headers }) |
97 | requiresUserAuth: boolean | 88 | .then(res => res.json() as Promise<SegmentsJSON>) |
98 | requiresPassword: boolean | 89 | .catch(err => { |
99 | videoPassword: () => string | 90 | logger.error('Cannot get sha256 segments', err) |
100 | }): Promise<SegmentsJSON> { | 91 | return {} |
101 | const { serverUrl, segmentsSha256Url, requiresUserAuth, authorizationHeader, requiresPassword, videoPassword } = options | 92 | }) |
102 | |||
103 | let headers: { [ id: string ]: string } = {} | ||
104 | if (isSameOrigin(serverUrl, segmentsSha256Url)) { | ||
105 | if (requiresPassword) headers = { 'x-peertube-video-password': videoPassword() } | ||
106 | else if (requiresUserAuth) headers = { Authorization: authorizationHeader() } | ||
107 | } | 93 | } |
108 | 94 | ||
109 | return fetch(segmentsSha256Url, { headers }) | 95 | private async sha256Hex (data?: ArrayBuffer) { |
110 | .then(res => res.json() as Promise<SegmentsJSON>) | 96 | if (!data) return undefined |
111 | .catch(err => { | ||
112 | logger.error('Cannot get sha256 segments', err) | ||
113 | return {} | ||
114 | }) | ||
115 | } | ||
116 | 97 | ||
117 | async function sha256Hex (data?: ArrayBuffer) { | 98 | if (window.crypto.subtle) { |
118 | if (!data) return undefined | 99 | return window.crypto.subtle.digest('SHA-256', data) |
100 | .then(data => this.bufferToHex(data)) | ||
101 | } | ||
119 | 102 | ||
120 | if (window.crypto.subtle) { | 103 | // Fallback for non HTTPS context |
121 | return window.crypto.subtle.digest('SHA-256', data) | 104 | const shaModule = (await import('sha.js') as any).default |
122 | .then(data => bufferToHex(data)) | 105 | // eslint-disable-next-line new-cap |
106 | return new shaModule.sha256().update(Buffer.from(data)).digest('hex') | ||
123 | } | 107 | } |
124 | 108 | ||
125 | // Fallback for non HTTPS context | 109 | // Thanks: https://stackoverflow.com/a/53307879 |
126 | const shaModule = (await import('sha.js') as any).default | 110 | private bufferToHex (buffer?: ArrayBuffer) { |
127 | // eslint-disable-next-line new-cap | 111 | if (!buffer) return '' |
128 | return new shaModule.sha256().update(Buffer.from(data)).digest('hex') | ||
129 | } | ||
130 | |||
131 | // Thanks: https://stackoverflow.com/a/53307879 | ||
132 | function bufferToHex (buffer?: ArrayBuffer) { | ||
133 | if (!buffer) return '' | ||
134 | 112 | ||
135 | let s = '' | 113 | let s = '' |
136 | const h = '0123456789abcdef' | 114 | const h = '0123456789abcdef' |
137 | const o = new Uint8Array(buffer) | 115 | const o = new Uint8Array(buffer) |
138 | 116 | ||
139 | o.forEach((v: any) => { | 117 | o.forEach((v: any) => { |
140 | s += h[v >> 4] + h[v & 15] | 118 | s += h[v >> 4] + h[v & 15] |
141 | }) | 119 | }) |
142 | 120 | ||
143 | return s | 121 | return s |
122 | } | ||
144 | } | 123 | } |
diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts index af2147749..f52ec75f4 100644 --- a/client/src/assets/player/shared/peertube/peertube-plugin.ts +++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import debug from 'debug' | 1 | import debug from 'debug' |
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { isMobile } from '@root-helpers/web-browser' | 4 | import { isIOS, isMobile } from '@root-helpers/web-browser' |
5 | import { timeToInt } from '@shared/core-utils' | 5 | import { timeToInt } from '@shared/core-utils' |
6 | import { VideoView, VideoViewEvent } from '@shared/models/videos' | 6 | import { VideoView, VideoViewEvent } from '@shared/models/videos' |
7 | import { | 7 | import { |
@@ -13,7 +13,7 @@ import { | |||
13 | saveVideoWatchHistory, | 13 | saveVideoWatchHistory, |
14 | saveVolumeInStore | 14 | saveVolumeInStore |
15 | } from '../../peertube-player-local-storage' | 15 | } from '../../peertube-player-local-storage' |
16 | import { PeerTubePluginOptions, VideoJSCaption } from '../../types' | 16 | import { PeerTubePluginOptions } from '../../types' |
17 | import { SettingsButton } from '../settings/settings-menu-button' | 17 | import { SettingsButton } from '../settings/settings-menu-button' |
18 | 18 | ||
19 | const debugLogger = debug('peertube:player:peertube') | 19 | const debugLogger = debug('peertube:player:peertube') |
@@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube') | |||
21 | const Plugin = videojs.getPlugin('plugin') | 21 | const Plugin = videojs.getPlugin('plugin') |
22 | 22 | ||
23 | class PeerTubePlugin extends Plugin { | 23 | class PeerTubePlugin extends Plugin { |
24 | private readonly videoViewUrl: string | 24 | private readonly videoViewUrl: () => string |
25 | private readonly authorizationHeader: () => string | 25 | private readonly authorizationHeader: () => string |
26 | private readonly initialInactivityTimeout: number | ||
26 | 27 | ||
27 | private readonly videoUUID: string | 28 | private readonly hasAutoplay: () => videojs.Autoplay |
28 | private readonly startTime: number | ||
29 | |||
30 | private readonly videoViewIntervalMs: number | ||
31 | 29 | ||
32 | private videoCaptions: VideoJSCaption[] | 30 | private currentSubtitle: string |
33 | private defaultSubtitle: string | 31 | private currentPlaybackRate: number |
34 | 32 | ||
35 | private videoViewInterval: any | 33 | private videoViewInterval: any |
36 | 34 | ||
37 | private menuOpened = false | 35 | private menuOpened = false |
38 | private mouseInControlBar = false | 36 | private mouseInControlBar = false |
39 | private mouseInSettings = false | 37 | private mouseInSettings = false |
40 | private readonly initialInactivityTimeout: number | ||
41 | 38 | ||
42 | constructor (player: videojs.Player, options?: PeerTubePluginOptions) { | 39 | private videoViewOnPlayHandler: (...args: any[]) => void |
40 | private videoViewOnSeekedHandler: (...args: any[]) => void | ||
41 | private videoViewOnEndedHandler: (...args: any[]) => void | ||
42 | |||
43 | private stopTimeHandler: (...args: any[]) => void | ||
44 | |||
45 | constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) { | ||
43 | super(player) | 46 | super(player) |
44 | 47 | ||
45 | this.videoViewUrl = options.videoViewUrl | 48 | this.videoViewUrl = options.videoViewUrl |
46 | this.authorizationHeader = options.authorizationHeader | 49 | this.authorizationHeader = options.authorizationHeader |
47 | this.videoUUID = options.videoUUID | 50 | this.hasAutoplay = options.hasAutoplay |
48 | this.startTime = timeToInt(options.startTime) | ||
49 | this.videoViewIntervalMs = options.videoViewIntervalMs | ||
50 | 51 | ||
51 | this.videoCaptions = options.videoCaptions | ||
52 | this.initialInactivityTimeout = this.player.options_.inactivityTimeout | 52 | this.initialInactivityTimeout = this.player.options_.inactivityTimeout |
53 | 53 | ||
54 | if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay') | 54 | this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle() |
55 | |||
56 | this.initializePlayer() | ||
57 | this.initOnVideoChange() | ||
58 | |||
59 | this.deleteLegacyIndexedDB() | ||
55 | 60 | ||
56 | this.player.on('autoplay-failure', () => { | 61 | this.player.on('autoplay-failure', () => { |
62 | debugLogger('Autoplay failed') | ||
63 | |||
57 | this.player.removeClass('vjs-has-autoplay') | 64 | this.player.removeClass('vjs-has-autoplay') |
65 | |||
66 | // Fix a bug on iOS where the big play button is not displayed when autoplay fails | ||
67 | if (isIOS()) this.player.hasStarted(false) | ||
58 | }) | 68 | }) |
59 | 69 | ||
60 | this.player.ready(() => { | 70 | this.player.on('ratechange', () => { |
71 | this.currentPlaybackRate = this.player.playbackRate() | ||
72 | |||
73 | this.player.defaultPlaybackRate(this.currentPlaybackRate) | ||
74 | }) | ||
75 | |||
76 | this.player.one('canplay', () => { | ||
61 | const playerOptions = this.player.options_ | 77 | const playerOptions = this.player.options_ |
62 | 78 | ||
63 | const volume = getStoredVolume() | 79 | const volume = getStoredVolume() |
@@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin { | |||
65 | 81 | ||
66 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | 82 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() |
67 | if (muted !== undefined) this.player.muted(muted) | 83 | if (muted !== undefined) this.player.muted(muted) |
84 | }) | ||
68 | 85 | ||
69 | this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() | 86 | this.player.ready(() => { |
70 | 87 | ||
71 | this.player.on('volumechange', () => { | 88 | this.player.on('volumechange', () => { |
72 | saveVolumeInStore(this.player.volume()) | 89 | saveVolumeInStore(this.player.volume()) |
73 | saveMuteInStore(this.player.muted()) | 90 | saveMuteInStore(this.player.muted()) |
74 | }) | 91 | }) |
75 | 92 | ||
76 | if (options.stopTime) { | ||
77 | const stopTime = timeToInt(options.stopTime) | ||
78 | const self = this | ||
79 | |||
80 | this.player.on('timeupdate', function onTimeUpdate () { | ||
81 | if (self.player.currentTime() > stopTime) { | ||
82 | self.player.pause() | ||
83 | self.player.trigger('stopped') | ||
84 | |||
85 | self.player.off('timeupdate', onTimeUpdate) | ||
86 | } | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | this.player.textTracks().addEventListener('change', () => { | 93 | this.player.textTracks().addEventListener('change', () => { |
91 | const showing = this.player.textTracks().tracks_.find(t => { | 94 | const showing = this.player.textTracks().tracks_.find(t => { |
92 | return t.kind === 'captions' && t.mode === 'showing' | 95 | return t.kind === 'captions' && t.mode === 'showing' |
@@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin { | |||
94 | 97 | ||
95 | if (!showing) { | 98 | if (!showing) { |
96 | saveLastSubtitle('off') | 99 | saveLastSubtitle('off') |
100 | this.currentSubtitle = undefined | ||
97 | return | 101 | return |
98 | } | 102 | } |
99 | 103 | ||
104 | this.currentSubtitle = showing.language | ||
100 | saveLastSubtitle(showing.language) | 105 | saveLastSubtitle(showing.language) |
101 | }) | 106 | }) |
102 | 107 | ||
103 | this.player.on('sourcechange', () => this.initCaptions()) | 108 | this.player.on('video-change', () => { |
104 | 109 | this.initOnVideoChange() | |
105 | this.player.duration(options.videoDuration) | 110 | }) |
106 | |||
107 | this.initializePlayer() | ||
108 | this.runUserViewing() | ||
109 | }) | 111 | }) |
110 | } | 112 | } |
111 | 113 | ||
112 | dispose () { | 114 | dispose () { |
113 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) | 115 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) |
116 | |||
117 | super.dispose() | ||
114 | } | 118 | } |
115 | 119 | ||
116 | onMenuOpened () { | 120 | onMenuOpened () { |
@@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin { | |||
162 | 166 | ||
163 | this.initSmoothProgressBar() | 167 | this.initSmoothProgressBar() |
164 | 168 | ||
165 | this.initCaptions() | 169 | this.player.ready(() => { |
166 | 170 | this.listenControlBarMouse() | |
167 | this.listenControlBarMouse() | 171 | }) |
168 | 172 | ||
169 | this.listenFullScreenChange() | 173 | this.listenFullScreenChange() |
170 | } | 174 | } |
171 | 175 | ||
176 | private initOnVideoChange () { | ||
177 | if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay') | ||
178 | else this.player.removeClass('vjs-has-autoplay') | ||
179 | |||
180 | if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) { | ||
181 | debugLogger('Setting playback rate to ' + this.currentPlaybackRate) | ||
182 | |||
183 | this.player.playbackRate(this.currentPlaybackRate) | ||
184 | } | ||
185 | |||
186 | this.player.ready(() => { | ||
187 | this.initCaptions() | ||
188 | this.updateControlBar() | ||
189 | }) | ||
190 | |||
191 | this.handleStartStopTime() | ||
192 | this.runUserViewing() | ||
193 | } | ||
194 | |||
172 | // --------------------------------------------------------------------------- | 195 | // --------------------------------------------------------------------------- |
173 | 196 | ||
174 | private runUserViewing () { | 197 | private runUserViewing () { |
175 | let lastCurrentTime = this.startTime | 198 | const startTime = timeToInt(this.options.startTime()) |
199 | |||
200 | let lastCurrentTime = startTime | ||
176 | let lastViewEvent: VideoViewEvent | 201 | let lastViewEvent: VideoViewEvent |
177 | 202 | ||
178 | this.player.one('play', () => { | 203 | if (this.videoViewInterval) clearInterval(this.videoViewInterval) |
179 | this.notifyUserIsWatching(this.startTime, lastViewEvent) | 204 | if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler) |
180 | }) | 205 | if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler) |
206 | if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler) | ||
181 | 207 | ||
182 | this.player.on('seeked', () => { | 208 | this.videoViewOnPlayHandler = () => { |
209 | this.notifyUserIsWatching(startTime, lastViewEvent) | ||
210 | } | ||
211 | |||
212 | this.videoViewOnSeekedHandler = () => { | ||
183 | const diff = Math.floor(this.player.currentTime()) - lastCurrentTime | 213 | const diff = Math.floor(this.player.currentTime()) - lastCurrentTime |
184 | 214 | ||
185 | // Don't take into account small forwards | 215 | // Don't take into account small forwards |
186 | if (diff > 0 && diff < 3) return | 216 | if (diff > 0 && diff < 3) return |
187 | 217 | ||
188 | lastViewEvent = 'seek' | 218 | lastViewEvent = 'seek' |
189 | }) | 219 | } |
190 | 220 | ||
191 | this.player.one('ended', () => { | 221 | this.videoViewOnEndedHandler = () => { |
192 | const currentTime = Math.floor(this.player.duration()) | 222 | const currentTime = Math.floor(this.player.duration()) |
193 | lastCurrentTime = currentTime | 223 | lastCurrentTime = currentTime |
194 | 224 | ||
195 | this.notifyUserIsWatching(currentTime, lastViewEvent) | 225 | this.notifyUserIsWatching(currentTime, lastViewEvent) |
196 | 226 | ||
197 | lastViewEvent = undefined | 227 | lastViewEvent = undefined |
198 | }) | 228 | } |
229 | |||
230 | this.player.one('play', this.videoViewOnPlayHandler) | ||
231 | this.player.on('seeked', this.videoViewOnSeekedHandler) | ||
232 | this.player.one('ended', this.videoViewOnEndedHandler) | ||
199 | 233 | ||
200 | this.videoViewInterval = setInterval(() => { | 234 | this.videoViewInterval = setInterval(() => { |
201 | const currentTime = Math.floor(this.player.currentTime()) | 235 | const currentTime = Math.floor(this.player.currentTime()) |
@@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin { | |||
209 | .catch(err => logger.error('Cannot notify user is watching.', err)) | 243 | .catch(err => logger.error('Cannot notify user is watching.', err)) |
210 | 244 | ||
211 | lastViewEvent = undefined | 245 | lastViewEvent = undefined |
212 | }, this.videoViewIntervalMs) | 246 | }, this.options.videoViewIntervalMs) |
213 | } | 247 | } |
214 | 248 | ||
215 | private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { | 249 | private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { |
216 | // Server won't save history, so save the video position in local storage | 250 | // Server won't save history, so save the video position in local storage |
217 | if (!this.authorizationHeader()) { | 251 | if (!this.authorizationHeader()) { |
218 | saveVideoWatchHistory(this.videoUUID, currentTime) | 252 | saveVideoWatchHistory(this.options.videoUUID(), currentTime) |
219 | } | 253 | } |
220 | 254 | ||
221 | if (!this.videoViewUrl) return Promise.resolve(true) | 255 | if (!this.videoViewUrl) return Promise.resolve(true) |
@@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin { | |||
225 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) | 259 | const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) |
226 | if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) | 260 | if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) |
227 | 261 | ||
228 | return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) | 262 | return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers }) |
229 | } | 263 | } |
230 | 264 | ||
231 | // --------------------------------------------------------------------------- | 265 | // --------------------------------------------------------------------------- |
@@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin { | |||
279 | } | 313 | } |
280 | 314 | ||
281 | private initCaptions () { | 315 | private initCaptions () { |
282 | for (const caption of this.videoCaptions) { | 316 | debugLogger('Init captions with current subtitle ' + this.currentSubtitle) |
317 | |||
318 | this.player.tech(true).clearTracks('text') | ||
319 | |||
320 | for (const caption of this.options.videoCaptions()) { | ||
283 | this.player.addRemoteTextTrack({ | 321 | this.player.addRemoteTextTrack({ |
284 | kind: 'captions', | 322 | kind: 'captions', |
285 | label: caption.label, | 323 | label: caption.label, |
286 | language: caption.language, | 324 | language: caption.language, |
287 | id: caption.language, | 325 | id: caption.language, |
288 | src: caption.src, | 326 | src: caption.src, |
289 | default: this.defaultSubtitle === caption.language | 327 | default: this.currentSubtitle === caption.language |
290 | }, false) | 328 | }, true) |
329 | } | ||
330 | |||
331 | this.player.trigger('captions-changed') | ||
332 | } | ||
333 | |||
334 | private updateControlBar () { | ||
335 | debugLogger('Updating control bar') | ||
336 | |||
337 | if (this.options.isLive()) { | ||
338 | this.getPlaybackRateButton().hide() | ||
339 | |||
340 | this.player.controlBar.getChild('progressControl').hide() | ||
341 | this.player.controlBar.getChild('currentTimeDisplay').hide() | ||
342 | this.player.controlBar.getChild('timeDivider').hide() | ||
343 | this.player.controlBar.getChild('durationDisplay').hide() | ||
344 | |||
345 | this.player.controlBar.getChild('peerTubeLiveDisplay').show() | ||
346 | } else { | ||
347 | this.getPlaybackRateButton().show() | ||
348 | |||
349 | this.player.controlBar.getChild('progressControl').show() | ||
350 | this.player.controlBar.getChild('currentTimeDisplay').show() | ||
351 | this.player.controlBar.getChild('timeDivider').show() | ||
352 | this.player.controlBar.getChild('durationDisplay').show() | ||
353 | |||
354 | this.player.controlBar.getChild('peerTubeLiveDisplay').hide() | ||
291 | } | 355 | } |
292 | 356 | ||
293 | this.player.trigger('captionsChanged') | 357 | if (this.options.videoCaptions().length === 0) { |
358 | this.getCaptionsButton().hide() | ||
359 | } else { | ||
360 | this.getCaptionsButton().show() | ||
361 | } | ||
362 | } | ||
363 | |||
364 | private handleStartStopTime () { | ||
365 | this.player.duration(this.options.videoDuration()) | ||
366 | |||
367 | if (this.stopTimeHandler) { | ||
368 | this.player.off('timeupdate', this.stopTimeHandler) | ||
369 | this.stopTimeHandler = undefined | ||
370 | } | ||
371 | |||
372 | // Prefer canplaythrough instead of canplay because Chrome has issues with the second one | ||
373 | this.player.one('canplaythrough', () => { | ||
374 | if (this.options.startTime()) { | ||
375 | debugLogger('Start the video at ' + this.options.startTime()) | ||
376 | |||
377 | this.player.currentTime(timeToInt(this.options.startTime())) | ||
378 | } | ||
379 | |||
380 | if (this.options.stopTime()) { | ||
381 | const stopTime = timeToInt(this.options.stopTime()) | ||
382 | |||
383 | this.stopTimeHandler = () => { | ||
384 | if (this.player.currentTime() <= stopTime) return | ||
385 | |||
386 | debugLogger('Stopping the video at ' + this.options.stopTime()) | ||
387 | |||
388 | // Time top stop | ||
389 | this.player.pause() | ||
390 | this.player.trigger('auto-stopped') | ||
391 | |||
392 | this.player.off('timeupdate', this.stopTimeHandler) | ||
393 | this.stopTimeHandler = undefined | ||
394 | } | ||
395 | |||
396 | this.player.on('timeupdate', this.stopTimeHandler) | ||
397 | } | ||
398 | }) | ||
294 | } | 399 | } |
295 | 400 | ||
296 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 | 401 | // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 |
@@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin { | |||
314 | this.update() | 419 | this.update() |
315 | } | 420 | } |
316 | } | 421 | } |
422 | |||
423 | private getCaptionsButton () { | ||
424 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
425 | |||
426 | return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton | ||
427 | } | ||
428 | |||
429 | private getPlaybackRateButton () { | ||
430 | const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton | ||
431 | |||
432 | return settingsButton.menu.getChild('playbackRateMenuButton') | ||
433 | } | ||
434 | |||
435 | // We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB | ||
436 | private deleteLegacyIndexedDB () { | ||
437 | try { | ||
438 | if (typeof window.indexedDB === 'undefined') return | ||
439 | if (!window.indexedDB) return | ||
440 | if (typeof window.indexedDB.databases !== 'function') return | ||
441 | |||
442 | window.indexedDB.databases() | ||
443 | .then(databases => { | ||
444 | for (const db of databases) { | ||
445 | window.indexedDB.deleteDatabase(db.name) | ||
446 | } | ||
447 | }) | ||
448 | } catch (err) { | ||
449 | debugLogger('Cannot delete legacy indexed DB', err) | ||
450 | // Nothing to do | ||
451 | } | ||
452 | } | ||
317 | } | 453 | } |
318 | 454 | ||
319 | videojs.registerPlugin('peertube', PeerTubePlugin) | 455 | videojs.registerPlugin('peertube', PeerTubePlugin) |
diff --git a/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts new file mode 100644 index 000000000..b467e3637 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts | |||
@@ -0,0 +1,136 @@ | |||
1 | import { | ||
2 | NextPreviousVideoButtonOptions, | ||
3 | PeerTubeLinkButtonOptions, | ||
4 | PeerTubePlayerContructorOptions, | ||
5 | PeerTubePlayerLoadOptions, | ||
6 | TheaterButtonOptions | ||
7 | } from '../../types' | ||
8 | |||
9 | type ControlBarOptionsBuilderConstructorOptions = | ||
10 | Pick<PeerTubePlayerContructorOptions, 'peertubeLink' | 'instanceName' | 'theaterButton'> & | ||
11 | { | ||
12 | videoShortUUID: () => string | ||
13 | p2pEnabled: () => boolean | ||
14 | |||
15 | previousVideo: () => PeerTubePlayerLoadOptions['previousVideo'] | ||
16 | nextVideo: () => PeerTubePlayerLoadOptions['nextVideo'] | ||
17 | } | ||
18 | |||
19 | export class ControlBarOptionsBuilder { | ||
20 | |||
21 | constructor (private options: ControlBarOptionsBuilderConstructorOptions) { | ||
22 | } | ||
23 | |||
24 | getChildrenOptions () { | ||
25 | const children = { | ||
26 | ...this.getPreviousVideo(), | ||
27 | |||
28 | playToggle: {}, | ||
29 | |||
30 | ...this.getNextVideo(), | ||
31 | |||
32 | ...this.getTimeControls(), | ||
33 | |||
34 | ...this.getProgressControl(), | ||
35 | |||
36 | p2PInfoButton: {}, | ||
37 | muteToggle: {}, | ||
38 | volumeControl: {}, | ||
39 | |||
40 | ...this.getSettingsButton(), | ||
41 | |||
42 | ...this.getPeerTubeLinkButton(), | ||
43 | |||
44 | ...this.getTheaterButton(), | ||
45 | |||
46 | fullscreenToggle: {} | ||
47 | } | ||
48 | |||
49 | return children | ||
50 | } | ||
51 | |||
52 | private getSettingsButton () { | ||
53 | const settingEntries: string[] = [] | ||
54 | |||
55 | settingEntries.push('playbackRateMenuButton') | ||
56 | settingEntries.push('captionsButton') | ||
57 | settingEntries.push('resolutionMenuButton') | ||
58 | |||
59 | return { | ||
60 | settingsButton: { | ||
61 | setup: { | ||
62 | maxHeightOffset: 40 | ||
63 | }, | ||
64 | entries: settingEntries | ||
65 | } | ||
66 | } | ||
67 | } | ||
68 | |||
69 | private getTimeControls () { | ||
70 | return { | ||
71 | peerTubeLiveDisplay: {}, | ||
72 | |||
73 | currentTimeDisplay: {}, | ||
74 | timeDivider: {}, | ||
75 | durationDisplay: {} | ||
76 | } | ||
77 | } | ||
78 | |||
79 | private getProgressControl () { | ||
80 | return { | ||
81 | progressControl: { | ||
82 | children: { | ||
83 | seekBar: { | ||
84 | children: { | ||
85 | loadProgressBar: {}, | ||
86 | mouseTimeDisplay: {}, | ||
87 | playProgressBar: {} | ||
88 | } | ||
89 | } | ||
90 | } | ||
91 | } | ||
92 | } | ||
93 | } | ||
94 | |||
95 | private getPreviousVideo () { | ||
96 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
97 | type: 'previous', | ||
98 | handler: () => this.options.previousVideo().handler(), | ||
99 | isDisabled: () => !this.options.previousVideo().enabled, | ||
100 | isDisplayed: () => this.options.previousVideo().displayControlBarButton | ||
101 | } | ||
102 | |||
103 | return { previousVideoButton: buttonOptions } | ||
104 | } | ||
105 | |||
106 | private getNextVideo () { | ||
107 | const buttonOptions: NextPreviousVideoButtonOptions = { | ||
108 | type: 'next', | ||
109 | handler: () => this.options.nextVideo().handler(), | ||
110 | isDisabled: () => !this.options.nextVideo().enabled, | ||
111 | isDisplayed: () => this.options.nextVideo().displayControlBarButton | ||
112 | } | ||
113 | |||
114 | return { nextVideoButton: buttonOptions } | ||
115 | } | ||
116 | |||
117 | private getPeerTubeLinkButton () { | ||
118 | const options: PeerTubeLinkButtonOptions = { | ||
119 | isDisplayed: this.options.peertubeLink, | ||
120 | shortUUID: this.options.videoShortUUID, | ||
121 | instanceName: this.options.instanceName | ||
122 | } | ||
123 | |||
124 | return { peerTubeLinkButton: options } | ||
125 | } | ||
126 | |||
127 | private getTheaterButton () { | ||
128 | const options: TheaterButtonOptions = { | ||
129 | isDisplayed: () => this.options.theaterButton | ||
130 | } | ||
131 | |||
132 | return { | ||
133 | theaterButton: options | ||
134 | } | ||
135 | } | ||
136 | } | ||
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts index 8091110bc..10df2db5d 100644 --- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts +++ b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts | |||
@@ -3,49 +3,61 @@ import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' | |||
3 | import { logger } from '@root-helpers/logger' | 3 | import { logger } from '@root-helpers/logger' |
4 | import { LiveVideoLatencyMode } from '@shared/models' | 4 | import { LiveVideoLatencyMode } from '@shared/models' |
5 | import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' | 5 | import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' |
6 | import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' | 6 | import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types' |
7 | import { PeertubePlayerManagerOptions } from '../../types/manager-options' | ||
8 | import { getRtcConfig, isSameOrigin } from '../common' | 7 | import { getRtcConfig, isSameOrigin } from '../common' |
9 | import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' | 8 | import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' |
10 | import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' | 9 | import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' |
11 | import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' | 10 | import { SegmentValidator } from '../p2p-media-loader/segment-validator' |
11 | |||
12 | type ConstructorOptions = | ||
13 | Pick<PeerTubePlayerContructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader'> & | ||
14 | Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' | | ||
15 | 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'> | ||
12 | 16 | ||
13 | export class HLSOptionsBuilder { | 17 | export class HLSOptionsBuilder { |
14 | 18 | ||
15 | constructor ( | 19 | constructor ( |
16 | private options: PeertubePlayerManagerOptions, | 20 | private options: ConstructorOptions, |
17 | private p2pMediaLoaderModule?: any | 21 | private p2pMediaLoaderModule?: any |
18 | ) { | 22 | ) { |
19 | 23 | ||
20 | } | 24 | } |
21 | 25 | ||
22 | async getPluginOptions () { | 26 | async getPluginOptions () { |
23 | const commonOptions = this.options.common | 27 | const redundancyUrlManager = new RedundancyUrlManager(this.options.hls.redundancyBaseUrls) |
24 | 28 | const segmentValidator = new SegmentValidator({ | |
25 | const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) | 29 | segmentsSha256Url: this.options.hls.segmentsSha256Url, |
30 | authorizationHeader: this.options.authorizationHeader, | ||
31 | requiresUserAuth: this.options.requiresUserAuth, | ||
32 | serverUrl: this.options.serverUrl, | ||
33 | requiresPassword: this.options.requiresPassword, | ||
34 | videoPassword: this.options.videoPassword | ||
35 | }) | ||
26 | 36 | ||
27 | const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( | 37 | const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( |
28 | 'filter:internal.player.p2p-media-loader.options.result', | 38 | 'filter:internal.player.p2p-media-loader.options.result', |
29 | this.getP2PMediaLoaderOptions(redundancyUrlManager) | 39 | this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator }) |
30 | ) | 40 | ) |
31 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader | 41 | const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader |
32 | 42 | ||
33 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { | 43 | const p2pMediaLoader: P2PMediaLoaderPluginOptions = { |
34 | requiresUserAuth: commonOptions.requiresUserAuth, | 44 | requiresUserAuth: this.options.requiresUserAuth, |
35 | videoFileToken: commonOptions.videoFileToken, | 45 | videoFileToken: this.options.videoFileToken, |
36 | 46 | ||
37 | redundancyUrlManager, | 47 | redundancyUrlManager, |
38 | type: 'application/x-mpegURL', | 48 | type: 'application/x-mpegURL', |
39 | startTime: commonOptions.startTime, | 49 | src: this.options.hls.playlistUrl, |
40 | src: this.options.p2pMediaLoader.playlistUrl, | 50 | segmentValidator, |
41 | loader | 51 | loader |
42 | } | 52 | } |
43 | 53 | ||
44 | const hlsjs = { | 54 | const hlsjs = { |
55 | hlsjsConfig: this.getHLSJSOptions(loader), | ||
56 | |||
45 | levelLabelHandler: (level: { height: number, width: number }) => { | 57 | levelLabelHandler: (level: { height: number, width: number }) => { |
46 | const resolution = Math.min(level.height || 0, level.width || 0) | 58 | const resolution = Math.min(level.height || 0, level.width || 0) |
47 | 59 | ||
48 | const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) | 60 | const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution) |
49 | // We don't have files for live videos | 61 | // We don't have files for live videos |
50 | if (!file) return level.height | 62 | if (!file) return level.height |
51 | 63 | ||
@@ -56,26 +68,27 @@ export class HLSOptionsBuilder { | |||
56 | } | 68 | } |
57 | } | 69 | } |
58 | 70 | ||
59 | const html5 = { | 71 | return { p2pMediaLoader, hlsjs } |
60 | hlsjsConfig: this.getHLSJSOptions(loader) | ||
61 | } | ||
62 | |||
63 | return { p2pMediaLoader, hlsjs, html5 } | ||
64 | } | 72 | } |
65 | 73 | ||
66 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
67 | 75 | ||
68 | private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { | 76 | private getP2PMediaLoaderOptions (options: { |
77 | redundancyUrlManager: RedundancyUrlManager | ||
78 | segmentValidator: SegmentValidator | ||
79 | }): HlsJsEngineSettings { | ||
80 | const { redundancyUrlManager, segmentValidator } = options | ||
81 | |||
69 | let consumeOnly = false | 82 | let consumeOnly = false |
70 | if ((navigator as any)?.connection?.type === 'cellular') { | 83 | if ((navigator as any)?.connection?.type === 'cellular') { |
71 | logger.info('We are on a cellular connection: disabling seeding.') | 84 | logger.info('We are on a cellular connection: disabling seeding.') |
72 | consumeOnly = true | 85 | consumeOnly = true |
73 | } | 86 | } |
74 | 87 | ||
75 | const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce | 88 | const trackerAnnounce = this.options.hls.trackerAnnounce |
76 | .filter(t => t.startsWith('ws')) | 89 | .filter(t => t.startsWith('ws')) |
77 | 90 | ||
78 | const specificLiveOrVODOptions = this.options.common.isLive | 91 | const specificLiveOrVODOptions = this.options.isLive |
79 | ? this.getP2PMediaLoaderLiveOptions() | 92 | ? this.getP2PMediaLoaderLiveOptions() |
80 | : this.getP2PMediaLoaderVODOptions() | 93 | : this.getP2PMediaLoaderVODOptions() |
81 | 94 | ||
@@ -88,35 +101,28 @@ export class HLSOptionsBuilder { | |||
88 | httpFailedSegmentTimeout: 1000, | 101 | httpFailedSegmentTimeout: 1000, |
89 | 102 | ||
90 | xhrSetup: (xhr, url) => { | 103 | xhrSetup: (xhr, url) => { |
91 | const { requiresUserAuth, requiresPassword } = this.options.common | 104 | const { requiresUserAuth, requiresPassword } = this.options |
92 | 105 | ||
93 | if (!(requiresUserAuth || requiresPassword)) return | 106 | if (!(requiresUserAuth || requiresPassword)) return |
94 | 107 | ||
95 | if (!isSameOrigin(this.options.common.serverUrl, url)) return | 108 | if (!isSameOrigin(this.options.serverUrl, url)) return |
96 | 109 | ||
97 | if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.common.videoPassword()) | 110 | if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword()) |
98 | 111 | ||
99 | else xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) | 112 | else xhr.setRequestHeader('Authorization', this.options.authorizationHeader()) |
100 | }, | 113 | }, |
101 | 114 | ||
102 | segmentValidator: segmentValidatorFactory({ | 115 | segmentValidator: segmentValidator.validate.bind(segmentValidator), |
103 | segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url, | ||
104 | authorizationHeader: this.options.common.authorizationHeader, | ||
105 | requiresUserAuth: this.options.common.requiresUserAuth, | ||
106 | serverUrl: this.options.common.serverUrl, | ||
107 | requiresPassword: this.options.common.requiresPassword, | ||
108 | videoPassword: this.options.common.videoPassword | ||
109 | }), | ||
110 | 116 | ||
111 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), | 117 | segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), |
112 | 118 | ||
113 | useP2P: this.options.common.p2pEnabled, | 119 | useP2P: this.options.p2pEnabled, |
114 | consumeOnly, | 120 | consumeOnly, |
115 | 121 | ||
116 | ...specificLiveOrVODOptions | 122 | ...specificLiveOrVODOptions |
117 | }, | 123 | }, |
118 | segments: { | 124 | segments: { |
119 | swarmId: this.options.p2pMediaLoader.playlistUrl, | 125 | swarmId: this.options.hls.playlistUrl, |
120 | forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 | 126 | forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 |
121 | } | 127 | } |
122 | } | 128 | } |
@@ -127,7 +133,7 @@ export class HLSOptionsBuilder { | |||
127 | requiredSegmentsPriority: 1 | 133 | requiredSegmentsPriority: 1 |
128 | } | 134 | } |
129 | 135 | ||
130 | const latencyMode = this.options.common.liveOptions.latencyMode | 136 | const latencyMode = this.options.liveOptions.latencyMode |
131 | 137 | ||
132 | switch (latencyMode) { | 138 | switch (latencyMode) { |
133 | case LiveVideoLatencyMode.SMALL_LATENCY: | 139 | case LiveVideoLatencyMode.SMALL_LATENCY: |
@@ -165,7 +171,7 @@ export class HLSOptionsBuilder { | |||
165 | // --------------------------------------------------------------------------- | 171 | // --------------------------------------------------------------------------- |
166 | 172 | ||
167 | private getHLSJSOptions (loader: P2PMediaLoader) { | 173 | private getHLSJSOptions (loader: P2PMediaLoader) { |
168 | const specificLiveOrVODOptions = this.options.common.isLive | 174 | const specificLiveOrVODOptions = this.options.isLive |
169 | ? this.getHLSLiveOptions() | 175 | ? this.getHLSLiveOptions() |
170 | : this.getHLSVODOptions() | 176 | : this.getHLSVODOptions() |
171 | 177 | ||
@@ -193,7 +199,7 @@ export class HLSOptionsBuilder { | |||
193 | } | 199 | } |
194 | 200 | ||
195 | private getHLSLiveOptions () { | 201 | private getHLSLiveOptions () { |
196 | const latencyMode = this.options.common.liveOptions.latencyMode | 202 | const latencyMode = this.options.liveOptions.latencyMode |
197 | 203 | ||
198 | switch (latencyMode) { | 204 | switch (latencyMode) { |
199 | case LiveVideoLatencyMode.SMALL_LATENCY: | 205 | case LiveVideoLatencyMode.SMALL_LATENCY: |
diff --git a/client/src/assets/player/shared/player-options-builder/index.ts b/client/src/assets/player/shared/player-options-builder/index.ts new file mode 100644 index 000000000..674754a94 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './control-bar-options-builder' | ||
2 | export * from './hls-options-builder' | ||
3 | export * from './web-video-options-builder' | ||
diff --git a/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts new file mode 100644 index 000000000..a3c3c3f27 --- /dev/null +++ b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types' | ||
2 | |||
3 | type ConstructorOptions = Pick<PeerTubePlayerLoadOptions, 'videoFileToken' | 'webVideo' | 'hls' | 'startTime'> | ||
4 | |||
5 | export class WebVideoOptionsBuilder { | ||
6 | |||
7 | constructor (private options: ConstructorOptions) { | ||
8 | |||
9 | } | ||
10 | |||
11 | getPluginOptions (): WebVideoPluginOptions { | ||
12 | return { | ||
13 | videoFileToken: this.options.videoFileToken, | ||
14 | |||
15 | videoFiles: this.options.webVideo.videoFiles.length !== 0 | ||
16 | ? this.options.webVideo.videoFiles | ||
17 | : this.options?.hls.videoFiles || [], | ||
18 | |||
19 | startTime: this.options.startTime | ||
20 | } | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts index 6cfaf4158..45cbb4899 100644 --- a/client/src/assets/player/shared/playlist/playlist-button.ts +++ b/client/src/assets/player/shared/playlist/playlist-button.ts | |||
@@ -8,8 +8,15 @@ class PlaylistButton extends ClickableComponent { | |||
8 | private playlistInfoElement: HTMLElement | 8 | private playlistInfoElement: HTMLElement |
9 | private wrapper: HTMLElement | 9 | private wrapper: HTMLElement |
10 | 10 | ||
11 | constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) { | 11 | options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions |
12 | super(player, options as any) | 12 | |
13 | // FIXME: eslint -> it's not a useless constructor, we need to extend constructor options typings | ||
14 | // eslint-disable-next-line @typescript-eslint/no-useless-constructor | ||
15 | constructor ( | ||
16 | player: videojs.Player, | ||
17 | options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions | ||
18 | ) { | ||
19 | super(player, options) | ||
13 | } | 20 | } |
14 | 21 | ||
15 | createEl () { | 22 | createEl () { |
@@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent { | |||
40 | } | 47 | } |
41 | 48 | ||
42 | update () { | 49 | update () { |
43 | const options = this.options_ as PlaylistPluginOptions | 50 | this.playlistInfoElement.innerHTML = this.options_.getCurrentPosition() + '/' + this.options_.playlist.videosLength |
44 | 51 | ||
45 | this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength | 52 | this.wrapper.title = this.player().localize('Playlist: {1}', [ this.options_.playlist.displayName ]) |
46 | this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ]) | ||
47 | } | 53 | } |
48 | 54 | ||
49 | handleClick () { | 55 | handleClick () { |
50 | const playlistMenu = this.getPlaylistMenu() | 56 | const playlistMenu = this.options_.playlistMenu |
51 | playlistMenu.open() | 57 | playlistMenu.open() |
52 | } | 58 | } |
53 | |||
54 | private getPlaylistMenu () { | ||
55 | return (this.options_ as any).playlistMenu as PlaylistMenu | ||
56 | } | ||
57 | } | 59 | } |
58 | 60 | ||
59 | videojs.registerComponent('PlaylistButton', PlaylistButton) | 61 | videojs.registerComponent('PlaylistButton', PlaylistButton) |
diff --git a/client/src/assets/player/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts index 81b5acf30..f9366332d 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu-item.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts | |||
@@ -8,6 +8,11 @@ const Component = videojs.getComponent('Component') | |||
8 | class PlaylistMenuItem extends Component { | 8 | class PlaylistMenuItem extends Component { |
9 | private element: VideoPlaylistElement | 9 | private element: VideoPlaylistElement |
10 | 10 | ||
11 | private clickHandler: () => void | ||
12 | private keyDownHandler: (event: KeyboardEvent) => void | ||
13 | |||
14 | options_: videojs.ComponentOptions & PlaylistItemOptions | ||
15 | |||
11 | constructor (player: videojs.Player, options?: PlaylistItemOptions) { | 16 | constructor (player: videojs.Player, options?: PlaylistItemOptions) { |
12 | super(player, options as any) | 17 | super(player, options as any) |
13 | 18 | ||
@@ -15,19 +20,27 @@ class PlaylistMenuItem extends Component { | |||
15 | 20 | ||
16 | this.element = options.element | 21 | this.element = options.element |
17 | 22 | ||
18 | this.on([ 'click', 'tap' ], () => this.switchPlaylistItem()) | 23 | this.clickHandler = () => this.switchPlaylistItem() |
19 | this.on('keydown', event => this.handleKeyDown(event)) | 24 | this.keyDownHandler = event => this.handleKeyDown(event) |
25 | |||
26 | this.on([ 'click', 'tap' ], this.clickHandler) | ||
27 | this.on('keydown', this.keyDownHandler) | ||
20 | } | 28 | } |
21 | 29 | ||
22 | createEl () { | 30 | dispose () { |
23 | const options = this.options_ as PlaylistItemOptions | 31 | this.off([ 'click', 'tap' ], this.clickHandler) |
32 | this.off('keydown', this.keyDownHandler) | ||
24 | 33 | ||
34 | super.dispose() | ||
35 | } | ||
36 | |||
37 | createEl () { | ||
25 | const li = super.createEl('li', { | 38 | const li = super.createEl('li', { |
26 | className: 'vjs-playlist-menu-item', | 39 | className: 'vjs-playlist-menu-item', |
27 | innerHTML: '' | 40 | innerHTML: '' |
28 | }) as HTMLElement | 41 | }) as HTMLElement |
29 | 42 | ||
30 | if (!options.element.video) { | 43 | if (!this.options_.element.video) { |
31 | li.classList.add('vjs-disabled') | 44 | li.classList.add('vjs-disabled') |
32 | } | 45 | } |
33 | 46 | ||
@@ -37,14 +50,14 @@ class PlaylistMenuItem extends Component { | |||
37 | 50 | ||
38 | const position = super.createEl('div', { | 51 | const position = super.createEl('div', { |
39 | className: 'item-position', | 52 | className: 'item-position', |
40 | innerHTML: options.element.position | 53 | innerHTML: this.options_.element.position |
41 | }) | 54 | }) |
42 | 55 | ||
43 | positionBlock.appendChild(position) | 56 | positionBlock.appendChild(position) |
44 | li.appendChild(positionBlock) | 57 | li.appendChild(positionBlock) |
45 | 58 | ||
46 | if (options.element.video) { | 59 | if (this.options_.element.video) { |
47 | this.buildAvailableVideo(li, positionBlock, options) | 60 | this.buildAvailableVideo(li, positionBlock, this.options_) |
48 | } else { | 61 | } else { |
49 | this.buildUnavailableVideo(li) | 62 | this.buildUnavailableVideo(li) |
50 | } | 63 | } |
@@ -125,9 +138,7 @@ class PlaylistMenuItem extends Component { | |||
125 | } | 138 | } |
126 | 139 | ||
127 | private switchPlaylistItem () { | 140 | private switchPlaylistItem () { |
128 | const options = this.options_ as PlaylistItemOptions | 141 | this.options_.onClicked() |
129 | |||
130 | options.onClicked() | ||
131 | } | 142 | } |
132 | } | 143 | } |
133 | 144 | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts index 1ec9ac804..53a5a7274 100644 --- a/client/src/assets/player/shared/playlist/playlist-menu.ts +++ b/client/src/assets/player/shared/playlist/playlist-menu.ts | |||
@@ -6,26 +6,32 @@ import { PlaylistMenuItem } from './playlist-menu-item' | |||
6 | const Component = videojs.getComponent('Component') | 6 | const Component = videojs.getComponent('Component') |
7 | 7 | ||
8 | class PlaylistMenu extends Component { | 8 | class PlaylistMenu extends Component { |
9 | private menuItems: PlaylistMenuItem[] | 9 | private menuItems: PlaylistMenuItem[] = [] |
10 | 10 | ||
11 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { | 11 | private readonly userInactiveHandler: () => void |
12 | super(player, options as any) | 12 | private readonly onMouseEnter: () => void |
13 | private readonly onMouseLeave: () => void | ||
13 | 14 | ||
14 | const self = this | 15 | private readonly onPlayerCick: (event: Event) => void |
15 | 16 | ||
16 | function userInactiveHandler () { | 17 | options_: PlaylistPluginOptions & videojs.ComponentOptions |
17 | self.close() | 18 | |
19 | constructor (player: videojs.Player, options?: PlaylistPluginOptions & videojs.ComponentOptions) { | ||
20 | super(player, options) | ||
21 | |||
22 | this.userInactiveHandler = () => { | ||
23 | this.close() | ||
18 | } | 24 | } |
19 | 25 | ||
20 | this.el().addEventListener('mouseenter', () => { | 26 | this.onMouseEnter = () => { |
21 | this.player().off('userinactive', userInactiveHandler) | 27 | this.player().off('userinactive', this.userInactiveHandler) |
22 | }) | 28 | } |
23 | 29 | ||
24 | this.el().addEventListener('mouseleave', () => { | 30 | this.onMouseLeave = () => { |
25 | this.player().one('userinactive', userInactiveHandler) | 31 | this.player().one('userinactive', this.userInactiveHandler) |
26 | }) | 32 | } |
27 | 33 | ||
28 | this.player().on('click', event => { | 34 | this.onPlayerCick = event => { |
29 | let current = event.target as HTMLElement | 35 | let current = event.target as HTMLElement |
30 | 36 | ||
31 | do { | 37 | do { |
@@ -40,14 +46,31 @@ class PlaylistMenu extends Component { | |||
40 | } while (current) | 46 | } while (current) |
41 | 47 | ||
42 | this.close() | 48 | this.close() |
43 | }) | 49 | } |
50 | |||
51 | this.el().addEventListener('mouseenter', this.onMouseEnter) | ||
52 | this.el().addEventListener('mouseleave', this.onMouseLeave) | ||
53 | |||
54 | this.player().on('click', this.onPlayerCick) | ||
55 | } | ||
56 | |||
57 | dispose () { | ||
58 | this.el().removeEventListener('mouseenter', this.onMouseEnter) | ||
59 | this.el().removeEventListener('mouseleave', this.onMouseLeave) | ||
60 | |||
61 | this.player().off('userinactive', this.userInactiveHandler) | ||
62 | this.player().off('click', this.onPlayerCick) | ||
63 | |||
64 | for (const item of this.menuItems) { | ||
65 | item.dispose() | ||
66 | } | ||
67 | |||
68 | super.dispose() | ||
44 | } | 69 | } |
45 | 70 | ||
46 | createEl () { | 71 | createEl () { |
47 | this.menuItems = [] | 72 | this.menuItems = [] |
48 | 73 | ||
49 | const options = this.getOptions() | ||
50 | |||
51 | const menu = super.createEl('div', { | 74 | const menu = super.createEl('div', { |
52 | className: 'vjs-playlist-menu', | 75 | className: 'vjs-playlist-menu', |
53 | innerHTML: '', | 76 | innerHTML: '', |
@@ -61,11 +84,11 @@ class PlaylistMenu extends Component { | |||
61 | const headerLeft = super.createEl('div') | 84 | const headerLeft = super.createEl('div') |
62 | 85 | ||
63 | const leftTitle = super.createEl('div', { | 86 | const leftTitle = super.createEl('div', { |
64 | innerHTML: options.playlist.displayName, | 87 | innerHTML: this.options_.playlist.displayName, |
65 | className: 'title' | 88 | className: 'title' |
66 | }) | 89 | }) |
67 | 90 | ||
68 | const playlistChannel = options.playlist.videoChannel | 91 | const playlistChannel = this.options_.playlist.videoChannel |
69 | const leftSubtitle = super.createEl('div', { | 92 | const leftSubtitle = super.createEl('div', { |
70 | innerHTML: playlistChannel | 93 | innerHTML: playlistChannel |
71 | ? this.player().localize('By {1}', [ playlistChannel.displayName ]) | 94 | ? this.player().localize('By {1}', [ playlistChannel.displayName ]) |
@@ -86,7 +109,7 @@ class PlaylistMenu extends Component { | |||
86 | 109 | ||
87 | const list = super.createEl('ol') | 110 | const list = super.createEl('ol') |
88 | 111 | ||
89 | for (const playlistElement of options.elements) { | 112 | for (const playlistElement of this.options_.elements) { |
90 | const item = new PlaylistMenuItem(this.player(), { | 113 | const item = new PlaylistMenuItem(this.player(), { |
91 | element: playlistElement, | 114 | element: playlistElement, |
92 | onClicked: () => this.onItemClicked(playlistElement) | 115 | onClicked: () => this.onItemClicked(playlistElement) |
@@ -100,13 +123,13 @@ class PlaylistMenu extends Component { | |||
100 | menu.appendChild(header) | 123 | menu.appendChild(header) |
101 | menu.appendChild(list) | 124 | menu.appendChild(list) |
102 | 125 | ||
126 | this.update() | ||
127 | |||
103 | return menu | 128 | return menu |
104 | } | 129 | } |
105 | 130 | ||
106 | update () { | 131 | update () { |
107 | const options = this.getOptions() | 132 | this.updateSelected(this.options_.getCurrentPosition()) |
108 | |||
109 | this.updateSelected(options.getCurrentPosition()) | ||
110 | } | 133 | } |
111 | 134 | ||
112 | open () { | 135 | open () { |
@@ -123,12 +146,8 @@ class PlaylistMenu extends Component { | |||
123 | } | 146 | } |
124 | } | 147 | } |
125 | 148 | ||
126 | private getOptions () { | ||
127 | return this.options_ as PlaylistPluginOptions | ||
128 | } | ||
129 | |||
130 | private onItemClicked (element: VideoPlaylistElement) { | 149 | private onItemClicked (element: VideoPlaylistElement) { |
131 | this.getOptions().onItemClicked(element) | 150 | this.options_.onItemClicked(element) |
132 | } | 151 | } |
133 | } | 152 | } |
134 | 153 | ||
diff --git a/client/src/assets/player/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts index 44de0da5a..c00e45843 100644 --- a/client/src/assets/player/shared/playlist/playlist-plugin.ts +++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts | |||
@@ -8,17 +8,10 @@ const Plugin = videojs.getPlugin('plugin') | |||
8 | class PlaylistPlugin extends Plugin { | 8 | class PlaylistPlugin extends Plugin { |
9 | private playlistMenu: PlaylistMenu | 9 | private playlistMenu: PlaylistMenu |
10 | private playlistButton: PlaylistButton | 10 | private playlistButton: PlaylistButton |
11 | private options: PlaylistPluginOptions | ||
12 | 11 | ||
13 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { | 12 | constructor (player: videojs.Player, options?: PlaylistPluginOptions) { |
14 | super(player, options) | 13 | super(player, options) |
15 | 14 | ||
16 | this.options = options | ||
17 | |||
18 | this.player.ready(() => { | ||
19 | player.addClass('vjs-playlist') | ||
20 | }) | ||
21 | |||
22 | this.playlistMenu = new PlaylistMenu(player, options) | 15 | this.playlistMenu = new PlaylistMenu(player, options) |
23 | this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) | 16 | this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) |
24 | 17 | ||
@@ -26,8 +19,16 @@ class PlaylistPlugin extends Plugin { | |||
26 | player.addChild(this.playlistButton, options) | 19 | player.addChild(this.playlistButton, options) |
27 | } | 20 | } |
28 | 21 | ||
29 | updateSelected () { | 22 | dispose () { |
30 | this.playlistMenu.updateSelected(this.options.getCurrentPosition()) | 23 | this.player.removeClass('vjs-playlist') |
24 | |||
25 | this.playlistMenu.dispose() | ||
26 | this.playlistButton.dispose() | ||
27 | |||
28 | this.player.removeChild(this.playlistMenu) | ||
29 | this.player.removeChild(this.playlistButton) | ||
30 | |||
31 | super.dispose() | ||
31 | } | 32 | } |
32 | } | 33 | } |
33 | 34 | ||
diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts index 4fafd27b1..4d6701003 100644 --- a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts +++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts | |||
@@ -8,7 +8,16 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
8 | private resolutions: PeerTubeResolution[] = [] | 8 | private resolutions: PeerTubeResolution[] = [] |
9 | 9 | ||
10 | private autoResolutionChosenId: number | 10 | private autoResolutionChosenId: number |
11 | private autoResolutionEnabled = true | 11 | |
12 | constructor (player: videojs.Player) { | ||
13 | super(player) | ||
14 | |||
15 | player.on('video-change', () => { | ||
16 | this.resolutions = [] | ||
17 | |||
18 | this.trigger('resolutions-removed') | ||
19 | }) | ||
20 | } | ||
12 | 21 | ||
13 | add (resolutions: PeerTubeResolution[]) { | 22 | add (resolutions: PeerTubeResolution[]) { |
14 | for (const r of resolutions) { | 23 | for (const r of resolutions) { |
@@ -18,12 +27,12 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
18 | this.currentSelection = this.getSelected() | 27 | this.currentSelection = this.getSelected() |
19 | 28 | ||
20 | this.sort() | 29 | this.sort() |
21 | this.trigger('resolutionsAdded') | 30 | this.trigger('resolutions-added') |
22 | } | 31 | } |
23 | 32 | ||
24 | remove (resolutionIndex: number) { | 33 | remove (resolutionIndex: number) { |
25 | this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) | 34 | this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) |
26 | this.trigger('resolutionRemoved') | 35 | this.trigger('resolutions-removed') |
27 | } | 36 | } |
28 | 37 | ||
29 | getResolutions () { | 38 | getResolutions () { |
@@ -40,10 +49,10 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
40 | 49 | ||
41 | select (options: { | 50 | select (options: { |
42 | id: number | 51 | id: number |
43 | byEngine: boolean | 52 | fireCallback: boolean |
44 | autoResolutionChosenId?: number | 53 | autoResolutionChosenId?: number |
45 | }) { | 54 | }) { |
46 | const { id, autoResolutionChosenId, byEngine } = options | 55 | const { id, autoResolutionChosenId, fireCallback } = options |
47 | 56 | ||
48 | if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return | 57 | if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return |
49 | 58 | ||
@@ -55,25 +64,11 @@ class PeerTubeResolutionsPlugin extends Plugin { | |||
55 | if (r.selected) { | 64 | if (r.selected) { |
56 | this.currentSelection = r | 65 | this.currentSelection = r |
57 | 66 | ||
58 | if (!byEngine) r.selectCallback() | 67 | if (fireCallback) r.selectCallback() |
59 | } | 68 | } |
60 | } | 69 | } |
61 | 70 | ||
62 | this.trigger('resolutionChanged') | 71 | this.trigger('resolutions-changed') |
63 | } | ||
64 | |||
65 | disableAutoResolution () { | ||
66 | this.autoResolutionEnabled = false | ||
67 | this.trigger('autoResolutionEnabledChanged') | ||
68 | } | ||
69 | |||
70 | enabledAutoResolution () { | ||
71 | this.autoResolutionEnabled = true | ||
72 | this.trigger('autoResolutionEnabledChanged') | ||
73 | } | ||
74 | |||
75 | isAutoResolutionEnabeld () { | ||
76 | return this.autoResolutionEnabled | ||
77 | } | 72 | } |
78 | 73 | ||
79 | private sort () { | 74 | private sort () { |
diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts index 672411c11..c39894284 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-button.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts | |||
@@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton { | |||
11 | 11 | ||
12 | this.controlText('Quality') | 12 | this.controlText('Quality') |
13 | 13 | ||
14 | player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) | 14 | player.peertubeResolutions().on('resolutions-added', () => this.update()) |
15 | player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities()) | 15 | player.peertubeResolutions().on('resolutions-removed', () => this.update()) |
16 | 16 | ||
17 | // For parent | 17 | // For parent |
18 | player.peertubeResolutions().on('resolutionChanged', () => { | 18 | player.peertubeResolutions().on('resolutions-changed', () => { |
19 | setTimeout(() => this.trigger('labelUpdated')) | 19 | setTimeout(() => this.trigger('label-updated')) |
20 | }) | 20 | }) |
21 | } | 21 | } |
22 | 22 | ||
@@ -37,69 +37,42 @@ class ResolutionMenuButton extends MenuButton { | |||
37 | } | 37 | } |
38 | 38 | ||
39 | createMenu () { | 39 | createMenu () { |
40 | return new Menu(this.player_) | 40 | const menu: videojs.Menu = new Menu(this.player_, { menuButton: this }) |
41 | } | 41 | const resolutions = this.player().peertubeResolutions().getResolutions() |
42 | |||
43 | buildCSSClass () { | ||
44 | return super.buildCSSClass() + ' vjs-resolution-button' | ||
45 | } | ||
46 | |||
47 | buildWrapperCSSClass () { | ||
48 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
49 | } | ||
50 | |||
51 | private addClickListener (component: any) { | ||
52 | component.on('click', () => { | ||
53 | const children = this.menu.children() | ||
54 | |||
55 | for (const child of children) { | ||
56 | if (component !== child) { | ||
57 | (child as videojs.MenuItem).selected(false) | ||
58 | } | ||
59 | } | ||
60 | }) | ||
61 | } | ||
62 | 42 | ||
63 | private buildQualities () { | 43 | for (const r of resolutions) { |
64 | for (const d of this.player().peertubeResolutions().getResolutions()) { | 44 | const label = r.label === '0p' |
65 | const label = d.label === '0p' | ||
66 | ? this.player().localize('Audio-only') | 45 | ? this.player().localize('Audio-only') |
67 | : d.label | 46 | : r.label |
68 | 47 | ||
69 | this.menu.addChild(new ResolutionMenuItem( | 48 | const component = new ResolutionMenuItem( |
70 | this.player_, | 49 | this.player_, |
71 | { | 50 | { |
72 | id: d.id + '', | 51 | id: r.id + '', |
73 | resolutionId: d.id, | 52 | resolutionId: r.id, |
74 | label, | 53 | label, |
75 | selected: d.selected | 54 | selected: r.selected |
76 | }) | 55 | } |
77 | ) | 56 | ) |
78 | } | ||
79 | 57 | ||
80 | for (const m of this.menu.children()) { | 58 | menu.addItem(component) |
81 | this.addClickListener(m) | ||
82 | } | 59 | } |
83 | 60 | ||
84 | this.trigger('menuChanged') | 61 | return menu |
85 | } | 62 | } |
86 | 63 | ||
87 | private cleanupQualities () { | 64 | update () { |
88 | const resolutions = this.player().peertubeResolutions().getResolutions() | 65 | super.update() |
89 | |||
90 | this.menu.children().forEach((children: ResolutionMenuItem) => { | ||
91 | if (children.resolutionId === undefined) { | ||
92 | return | ||
93 | } | ||
94 | 66 | ||
95 | if (resolutions.find(r => r.id === children.resolutionId)) { | 67 | this.trigger('menu-changed') |
96 | return | 68 | } |
97 | } | ||
98 | 69 | ||
99 | this.menu.removeChild(children) | 70 | buildCSSClass () { |
100 | }) | 71 | return super.buildCSSClass() + ' vjs-resolution-button' |
72 | } | ||
101 | 73 | ||
102 | this.trigger('menuChanged') | 74 | buildWrapperCSSClass () { |
75 | return 'vjs-resolution-control ' + super.buildWrapperCSSClass() | ||
103 | } | 76 | } |
104 | } | 77 | } |
105 | 78 | ||
diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts index c59b8b891..86387f533 100644 --- a/client/src/assets/player/shared/settings/resolution-menu-item.ts +++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts | |||
@@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem { | |||
10 | readonly resolutionId: number | 10 | readonly resolutionId: number |
11 | private readonly label: string | 11 | private readonly label: string |
12 | 12 | ||
13 | private autoResolutionEnabled: boolean | ||
14 | private autoResolutionChosen: string | 13 | private autoResolutionChosen: string |
15 | 14 | ||
16 | constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { | 15 | private updateSelectionHandler: () => void |
17 | options.selectable = true | ||
18 | 16 | ||
19 | super(player, options) | 17 | constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { |
18 | super(player, { ...options, selectable: true }) | ||
20 | 19 | ||
21 | this.autoResolutionEnabled = true | ||
22 | this.autoResolutionChosen = '' | 20 | this.autoResolutionChosen = '' |
23 | 21 | ||
24 | this.resolutionId = options.resolutionId | 22 | this.resolutionId = options.resolutionId |
25 | this.label = options.label | 23 | this.label = options.label |
26 | 24 | ||
27 | player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) | 25 | this.updateSelectionHandler = () => this.updateSelection() |
26 | player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler) | ||
27 | } | ||
28 | |||
29 | dispose () { | ||
30 | this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler) | ||
28 | 31 | ||
29 | // We only want to disable the "Auto" item | 32 | super.dispose() |
30 | if (this.resolutionId === -1) { | ||
31 | player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution()) | ||
32 | } | ||
33 | } | 33 | } |
34 | 34 | ||
35 | handleClick (event: any) { | 35 | handleClick (event: any) { |
36 | // Auto button disabled? | ||
37 | if (this.autoResolutionEnabled === false && this.resolutionId === -1) return | ||
38 | |||
39 | super.handleClick(event) | 36 | super.handleClick(event) |
40 | 37 | ||
41 | this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) | 38 | this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true }) |
42 | } | 39 | } |
43 | 40 | ||
44 | updateSelection () { | 41 | updateSelection () { |
@@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem { | |||
51 | this.selected(this.resolutionId === selectedResolution.id) | 48 | this.selected(this.resolutionId === selectedResolution.id) |
52 | } | 49 | } |
53 | 50 | ||
54 | updateAutoResolution () { | ||
55 | const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld() | ||
56 | |||
57 | // Check if the auto resolution is enabled or not | ||
58 | if (enabled === false) { | ||
59 | this.addClass('disabled') | ||
60 | } else { | ||
61 | this.removeClass('disabled') | ||
62 | } | ||
63 | |||
64 | this.autoResolutionEnabled = enabled | ||
65 | } | ||
66 | |||
67 | getLabel () { | 51 | getLabel () { |
68 | if (this.resolutionId === -1) { | 52 | if (this.resolutionId === -1) { |
69 | return this.label + ' <small>' + this.autoResolutionChosen + '</small>' | 53 | return this.label + ' <small>' + this.autoResolutionChosen + '</small>' |
diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts index f5fbbe7ad..ba39d0f45 100644 --- a/client/src/assets/player/shared/settings/settings-dialog.ts +++ b/client/src/assets/player/shared/settings/settings-dialog.ts | |||
@@ -28,6 +28,18 @@ class SettingsDialog extends Component { | |||
28 | 'aria-describedby': dialogDescriptionId | 28 | 'aria-describedby': dialogDescriptionId |
29 | }) | 29 | }) |
30 | } | 30 | } |
31 | |||
32 | show () { | ||
33 | this.player().addClass('vjs-settings-dialog-opened') | ||
34 | |||
35 | super.show() | ||
36 | } | ||
37 | |||
38 | hide () { | ||
39 | this.player().removeClass('vjs-settings-dialog-opened') | ||
40 | |||
41 | super.hide() | ||
42 | } | ||
31 | } | 43 | } |
32 | 44 | ||
33 | Component.registerComponent('SettingsDialog', SettingsDialog) | 45 | Component.registerComponent('SettingsDialog', SettingsDialog) |
diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts index 4cf29866b..9499a43eb 100644 --- a/client/src/assets/player/shared/settings/settings-menu-button.ts +++ b/client/src/assets/player/shared/settings/settings-menu-button.ts | |||
@@ -71,7 +71,7 @@ class SettingsButton extends Button { | |||
71 | } | 71 | } |
72 | } | 72 | } |
73 | 73 | ||
74 | onDisposeSettingsItem (event: any, name: string) { | 74 | onDisposeSettingsItem (_event: any, name: string) { |
75 | if (name === undefined) { | 75 | if (name === undefined) { |
76 | const children = this.menu.children() | 76 | const children = this.menu.children() |
77 | 77 | ||
@@ -103,6 +103,8 @@ class SettingsButton extends Button { | |||
103 | if (this.isInIframe()) { | 103 | if (this.isInIframe()) { |
104 | window.removeEventListener('blur', this.documentClickHandler) | 104 | window.removeEventListener('blur', this.documentClickHandler) |
105 | } | 105 | } |
106 | |||
107 | super.dispose() | ||
106 | } | 108 | } |
107 | 109 | ||
108 | onAddSettingsItem (event: any, data: any) { | 110 | onAddSettingsItem (event: any, data: any) { |
@@ -249,8 +251,8 @@ class SettingsButton extends Button { | |||
249 | } | 251 | } |
250 | 252 | ||
251 | resetChildren () { | 253 | resetChildren () { |
252 | for (const menuChild of this.menu.children()) { | 254 | for (const menuChild of this.menu.children() as SettingsMenuItem[]) { |
253 | (menuChild as SettingsMenuItem).reset() | 255 | menuChild.reset() |
254 | } | 256 | } |
255 | } | 257 | } |
256 | 258 | ||
@@ -258,8 +260,8 @@ class SettingsButton extends Button { | |||
258 | * Hide all the sub menus | 260 | * Hide all the sub menus |
259 | */ | 261 | */ |
260 | hideChildren () { | 262 | hideChildren () { |
261 | for (const menuChild of this.menu.children()) { | 263 | for (const menuChild of this.menu.children() as SettingsMenuItem[]) { |
262 | (menuChild as SettingsMenuItem).hideSubMenu() | 264 | menuChild.hideSubMenu() |
263 | } | 265 | } |
264 | } | 266 | } |
265 | 267 | ||
diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts index 288e3b233..9916ae27f 100644 --- a/client/src/assets/player/shared/settings/settings-menu-item.ts +++ b/client/src/assets/player/shared/settings/settings-menu-item.ts | |||
@@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem { | |||
70 | this.build() | 70 | this.build() |
71 | 71 | ||
72 | // Update on rate change | 72 | // Update on rate change |
73 | player.on('ratechange', this.submenuClickHandler) | 73 | if (subMenuName === 'PlaybackRateMenuButton') { |
74 | player.on('ratechange', this.submenuClickHandler) | ||
75 | } | ||
74 | 76 | ||
75 | if (subMenuName === 'CaptionsButton') { | 77 | if (subMenuName === 'CaptionsButton') { |
76 | // Hack to regenerate captions on HTTP fallback | 78 | player.on('captions-changed', () => { |
77 | player.on('captionsChanged', () => { | 79 | // Wait menu component rebuild |
78 | setTimeout(() => { | 80 | setTimeout(() => { |
79 | this.settingsSubMenuEl_.innerHTML = '' | 81 | this.rebuildAfterMenuChange() |
80 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) | 82 | }, 150) |
81 | this.update() | 83 | }) |
82 | this.bindClickEvents() | 84 | } |
83 | }, 0) | 85 | |
86 | if (subMenuName === 'ResolutionMenuButton') { | ||
87 | this.subMenu.on('menu-changed', () => { | ||
88 | this.rebuildAfterMenuChange() | ||
84 | }) | 89 | }) |
85 | } | 90 | } |
86 | 91 | ||
@@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem { | |||
89 | }) | 94 | }) |
90 | } | 95 | } |
91 | 96 | ||
97 | dispose () { | ||
98 | this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler) | ||
99 | |||
100 | super.dispose() | ||
101 | } | ||
102 | |||
92 | eventHandlers () { | 103 | eventHandlers () { |
93 | this.submenuClickHandler = this.onSubmenuClick.bind(this) | 104 | this.submenuClickHandler = this.onSubmenuClick.bind(this) |
94 | this.transitionEndHandler = this.onTransitionEnd.bind(this) | 105 | this.transitionEndHandler = this.onTransitionEnd.bind(this) |
@@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem { | |||
190 | (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) | 201 | (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) |
191 | } | 202 | } |
192 | 203 | ||
193 | /** | ||
194 | * Add/remove prefixed event listener for CSS Transition | ||
195 | * | ||
196 | * @method PrefixedEvent | ||
197 | */ | ||
198 | PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') { | ||
199 | const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ] | ||
200 | |||
201 | for (let p = 0; p < prefix.length; p++) { | ||
202 | if (!prefix[p]) { | ||
203 | type = type.toLowerCase() | ||
204 | } | ||
205 | |||
206 | if (action === 'addEvent') { | ||
207 | element.addEventListener(prefix[p] + type, callback, false) | ||
208 | } else if (action === 'removeEvent') { | ||
209 | element.removeEventListener(prefix[p] + type, callback, false) | ||
210 | } | ||
211 | } | ||
212 | } | ||
213 | |||
214 | onTransitionEnd (event: any) { | 204 | onTransitionEnd (event: any) { |
215 | if (event.propertyName !== 'margin-right') { | 205 | if (event.propertyName !== 'margin-right') { |
216 | return | 206 | return |
@@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem { | |||
254 | } | 244 | } |
255 | 245 | ||
256 | build () { | 246 | build () { |
257 | this.subMenu.on('labelUpdated', () => { | 247 | this.subMenu.on('label-updated', () => { |
258 | this.update() | ||
259 | }) | ||
260 | this.subMenu.on('menuChanged', () => { | ||
261 | this.bindClickEvents() | ||
262 | this.setSize() | ||
263 | this.update() | 248 | this.update() |
264 | }) | 249 | }) |
265 | 250 | ||
@@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem { | |||
272 | this.setSize() | 257 | this.setSize() |
273 | this.bindClickEvents() | 258 | this.bindClickEvents() |
274 | 259 | ||
275 | // prefixed event listeners for CSS TransitionEnd | 260 | this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false) |
276 | this.PrefixedEvent( | ||
277 | this.settingsSubMenuEl_, | ||
278 | 'TransitionEnd', | ||
279 | this.transitionEndHandler, | ||
280 | 'addEvent' | ||
281 | ) | ||
282 | } | 261 | } |
283 | 262 | ||
284 | update (event?: any) { | 263 | update (event?: any) { |
285 | let target: HTMLElement = null | ||
286 | const subMenu = this.subMenu.name() | 264 | const subMenu = this.subMenu.name() |
287 | 265 | ||
288 | if (event && event.type === 'tap') { | ||
289 | target = event.target | ||
290 | } else if (event) { | ||
291 | target = event.currentTarget | ||
292 | } | ||
293 | |||
294 | // Playback rate menu button doesn't get a vjs-selected class | 266 | // Playback rate menu button doesn't get a vjs-selected class |
295 | // or sets options_['selected'] on the selected playback rate. | 267 | // or sets options_['selected'] on the selected playback rate. |
296 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton | 268 | // Thus we get the submenu value based on the labelEl of playbackRateMenuButton |
@@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem { | |||
321 | } | 293 | } |
322 | } | 294 | } |
323 | 295 | ||
296 | let target: HTMLElement = null | ||
297 | if (event && event.type === 'tap') { | ||
298 | target = event.target | ||
299 | } else if (event) { | ||
300 | target = event.currentTarget | ||
301 | } | ||
302 | |||
324 | if (target && !target.classList.contains('vjs-back-button')) { | 303 | if (target && !target.classList.contains('vjs-back-button')) { |
325 | this.settingsButton.hideDialog() | 304 | this.settingsButton.hideDialog() |
326 | } | 305 | } |
@@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem { | |||
369 | } | 348 | } |
370 | } | 349 | } |
371 | 350 | ||
351 | private rebuildAfterMenuChange () { | ||
352 | this.settingsSubMenuEl_.innerHTML = '' | ||
353 | this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) | ||
354 | this.update() | ||
355 | this.createBackButton() | ||
356 | this.setSize() | ||
357 | this.bindClickEvents() | ||
358 | } | ||
359 | |||
372 | } | 360 | } |
373 | 361 | ||
374 | (SettingsMenuItem as any).prototype.contentElType = 'button' | 362 | (SettingsMenuItem as any).prototype.contentElType = 'button' |
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts index 471a5e46c..fad68cec9 100644 --- a/client/src/assets/player/shared/stats/stats-card.ts +++ b/client/src/assets/player/shared/stats/stats-card.ts | |||
@@ -7,7 +7,7 @@ import { bytes } from '../common' | |||
7 | interface StatsCardOptions extends videojs.ComponentOptions { | 7 | interface StatsCardOptions extends videojs.ComponentOptions { |
8 | videoUUID: string | 8 | videoUUID: string |
9 | videoIsLive: boolean | 9 | videoIsLive: boolean |
10 | mode: 'webtorrent' | 'p2p-media-loader' | 10 | mode: 'web-video' | 'p2p-media-loader' |
11 | p2pEnabled: boolean | 11 | p2pEnabled: boolean |
12 | } | 12 | } |
13 | 13 | ||
@@ -34,7 +34,7 @@ class StatsCard extends Component { | |||
34 | 34 | ||
35 | updateInterval: any | 35 | updateInterval: any |
36 | 36 | ||
37 | mode: 'webtorrent' | 'p2p-media-loader' | 37 | mode: 'web-video' | 'p2p-media-loader' |
38 | 38 | ||
39 | metadataStore: any = {} | 39 | metadataStore: any = {} |
40 | 40 | ||
@@ -63,6 +63,9 @@ class StatsCard extends Component { | |||
63 | 63 | ||
64 | private liveLatency: InfoElement | 64 | private liveLatency: InfoElement |
65 | 65 | ||
66 | private onP2PInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void | ||
67 | private onHTTPInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void | ||
68 | |||
66 | createEl () { | 69 | createEl () { |
67 | this.containerEl = videojs.dom.createEl('div', { | 70 | this.containerEl = videojs.dom.createEl('div', { |
68 | className: 'vjs-stats-content' | 71 | className: 'vjs-stats-content' |
@@ -86,9 +89,7 @@ class StatsCard extends Component { | |||
86 | 89 | ||
87 | this.populateInfoBlocks() | 90 | this.populateInfoBlocks() |
88 | 91 | ||
89 | this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { | 92 | this.onP2PInfoHandler = (_event, data) => { |
90 | if (!data) return // HTTP fallback | ||
91 | |||
92 | this.mode = data.source | 93 | this.mode = data.source |
93 | 94 | ||
94 | const p2pStats = data.p2p | 95 | const p2pStats = data.p2p |
@@ -105,11 +106,29 @@ class StatsCard extends Component { | |||
105 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') | 106 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') |
106 | this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') | 107 | this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') |
107 | } | 108 | } |
108 | }) | 109 | } |
110 | |||
111 | this.onHTTPInfoHandler = (_event, data) => { | ||
112 | this.mode = data.source | ||
113 | |||
114 | this.playerNetworkInfo.totalDownloaded = bytes(data.http.downloaded).join(' ') | ||
115 | } | ||
116 | |||
117 | this.player().on('p2p-info', this.onP2PInfoHandler) | ||
118 | this.player().on('http-info', this.onHTTPInfoHandler) | ||
109 | 119 | ||
110 | return this.containerEl | 120 | return this.containerEl |
111 | } | 121 | } |
112 | 122 | ||
123 | dispose () { | ||
124 | if (this.updateInterval) clearInterval(this.updateInterval) | ||
125 | |||
126 | this.player().off('p2p-info', this.onP2PInfoHandler) | ||
127 | this.player().off('http-info', this.onHTTPInfoHandler) | ||
128 | |||
129 | super.dispose() | ||
130 | } | ||
131 | |||
113 | toggle () { | 132 | toggle () { |
114 | if (this.updateInterval) this.hide() | 133 | if (this.updateInterval) this.hide() |
115 | else this.show() | 134 | else this.show() |
@@ -122,7 +141,7 @@ class StatsCard extends Component { | |||
122 | try { | 141 | try { |
123 | const options = this.mode === 'p2p-media-loader' | 142 | const options = this.mode === 'p2p-media-loader' |
124 | ? this.buildHLSOptions() | 143 | ? this.buildHLSOptions() |
125 | : await this.buildWebTorrentOptions() // Default | 144 | : await this.buildWebVideoOptions() // Default |
126 | 145 | ||
127 | this.populateInfoValues(options) | 146 | this.populateInfoValues(options) |
128 | } catch (err) { | 147 | } catch (err) { |
@@ -170,8 +189,8 @@ class StatsCard extends Component { | |||
170 | } | 189 | } |
171 | } | 190 | } |
172 | 191 | ||
173 | private async buildWebTorrentOptions () { | 192 | private async buildWebVideoOptions () { |
174 | const videoFile = this.player_.webtorrent().getCurrentVideoFile() | 193 | const videoFile = this.player_.webVideo().getCurrentVideoFile() |
175 | 194 | ||
176 | if (!this.metadataStore[videoFile.fileUrl]) { | 195 | if (!this.metadataStore[videoFile.fileUrl]) { |
177 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) | 196 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) |
@@ -194,7 +213,7 @@ class StatsCard extends Component { | |||
194 | 213 | ||
195 | const resolution = videoFile?.resolution.label + videoFile?.fps | 214 | const resolution = videoFile?.resolution.label + videoFile?.fps |
196 | const buffer = this.timeRangesToString(this.player_.buffered()) | 215 | const buffer = this.timeRangesToString(this.player_.buffered()) |
197 | const progress = this.player_.webtorrent().getTorrent()?.progress | 216 | const progress = this.player_.bufferedPercent() |
198 | 217 | ||
199 | return { | 218 | return { |
200 | playerNetworkInfo: this.playerNetworkInfo, | 219 | playerNetworkInfo: this.playerNetworkInfo, |
@@ -284,8 +303,10 @@ class StatsCard extends Component { | |||
284 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` | 303 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` |
285 | : undefined | 304 | : undefined |
286 | 305 | ||
287 | this.setInfoValue(this.playerMode, this.mode || 'HTTP') | 306 | const p2pEnabled = this.options_.p2pEnabled && this.mode === 'p2p-media-loader' |
288 | this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled')) | 307 | |
308 | this.setInfoValue(this.playerMode, this.mode) | ||
309 | this.setInfoValue(this.p2p, player.localize(p2pEnabled ? 'enabled' : 'disabled')) | ||
289 | this.setInfoValue(this.uuid, this.options_.videoUUID) | 310 | this.setInfoValue(this.uuid, this.options_.videoUUID) |
290 | 311 | ||
291 | this.setInfoValue(this.viewport, frames) | 312 | this.setInfoValue(this.viewport, frames) |
diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts index 8aad80e8a..86684a78c 100644 --- a/client/src/assets/player/shared/stats/stats-plugin.ts +++ b/client/src/assets/player/shared/stats/stats-plugin.ts | |||
@@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin { | |||
7 | private statsCard: StatsCard | 7 | private statsCard: StatsCard |
8 | 8 | ||
9 | constructor (player: videojs.Player, options: StatsCardOptions) { | 9 | constructor (player: videojs.Player, options: StatsCardOptions) { |
10 | const settings = { | ||
11 | ...options | ||
12 | } | ||
13 | |||
14 | super(player) | 10 | super(player) |
15 | 11 | ||
16 | this.player.ready(() => { | 12 | this.player.ready(() => { |
@@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin { | |||
19 | 15 | ||
20 | this.statsCard = new StatsCard(player, options) | 16 | this.statsCard = new StatsCard(player, options) |
21 | 17 | ||
22 | player.addChild(this.statsCard, settings) | 18 | // Copy options |
19 | player.addChild(this.statsCard) | ||
20 | } | ||
21 | |||
22 | dispose () { | ||
23 | if (this.statsCard) { | ||
24 | this.statsCard.dispose() | ||
25 | this.player.removeChild(this.statsCard) | ||
26 | } | ||
27 | |||
28 | super.dispose() | ||
23 | } | 29 | } |
24 | 30 | ||
25 | show () { | 31 | show () { |
diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts index 61668e407..3589e1fd8 100644 --- a/client/src/assets/player/shared/upnext/end-card.ts +++ b/client/src/assets/player/shared/upnext/end-card.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { UpNextPluginOptions } from '../../types' | ||
2 | 3 | ||
3 | function getMainTemplate (options: any) { | 4 | function getMainTemplate (options: EndCardOptions) { |
4 | return ` | 5 | return ` |
5 | <div class="vjs-upnext-top"> | 6 | <div class="vjs-upnext-top"> |
6 | <span class="vjs-upnext-headtext">${options.headText}</span> | 7 | <span class="vjs-upnext-headtext">${options.headText}</span> |
@@ -23,15 +24,10 @@ function getMainTemplate (options: any) { | |||
23 | ` | 24 | ` |
24 | } | 25 | } |
25 | 26 | ||
26 | export interface EndCardOptions extends videojs.ComponentOptions { | 27 | export interface EndCardOptions extends videojs.ComponentOptions, UpNextPluginOptions { |
27 | next: () => void | ||
28 | getTitle: () => string | ||
29 | timeout: number | ||
30 | cancelText: string | 28 | cancelText: string |
31 | headText: string | 29 | headText: string |
32 | suspendedText: string | 30 | suspendedText: string |
33 | condition: () => boolean | ||
34 | suspended: () => boolean | ||
35 | } | 31 | } |
36 | 32 | ||
37 | const Component = videojs.getComponent('Component') | 33 | const Component = videojs.getComponent('Component') |
@@ -52,27 +48,43 @@ class EndCard extends Component { | |||
52 | suspendedMessage: HTMLElement | 48 | suspendedMessage: HTMLElement |
53 | nextButton: HTMLElement | 49 | nextButton: HTMLElement |
54 | 50 | ||
51 | private onEndedHandler: () => void | ||
52 | private onPlayingHandler: () => void | ||
53 | |||
55 | constructor (player: videojs.Player, options: EndCardOptions) { | 54 | constructor (player: videojs.Player, options: EndCardOptions) { |
56 | super(player, options) | 55 | super(player, options) |
57 | 56 | ||
58 | this.totalTicks = this.options_.timeout / this.interval | 57 | this.totalTicks = this.options_.timeout / this.interval |
59 | 58 | ||
60 | player.on('ended', (_: any) => { | 59 | this.onEndedHandler = () => { |
61 | if (!this.options_.condition()) return | 60 | if (!this.options_.isDisplayed()) return |
62 | 61 | ||
63 | player.addClass('vjs-upnext--showing') | 62 | player.addClass('vjs-upnext--showing') |
64 | this.showCard((canceled: boolean) => { | 63 | |
64 | this.showCard(canceled => { | ||
65 | player.removeClass('vjs-upnext--showing') | 65 | player.removeClass('vjs-upnext--showing') |
66 | |||
66 | this.container.style.display = 'none' | 67 | this.container.style.display = 'none' |
68 | |||
67 | if (!canceled) { | 69 | if (!canceled) { |
68 | this.options_.next() | 70 | this.options_.next() |
69 | } | 71 | } |
70 | }) | 72 | }) |
71 | }) | 73 | } |
72 | 74 | ||
73 | player.on('playing', () => { | 75 | this.onPlayingHandler = () => { |
74 | this.upNextEvents.trigger('playing') | 76 | this.upNextEvents.trigger('playing') |
75 | }) | 77 | } |
78 | |||
79 | player.on([ 'auto-stopped', 'ended' ], this.onEndedHandler) | ||
80 | player.on('playing', this.onPlayingHandler) | ||
81 | } | ||
82 | |||
83 | dispose () { | ||
84 | if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler) | ||
85 | if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler) | ||
86 | |||
87 | super.dispose() | ||
76 | } | 88 | } |
77 | 89 | ||
78 | createEl () { | 90 | createEl () { |
@@ -101,7 +113,7 @@ class EndCard extends Component { | |||
101 | return container | 113 | return container |
102 | } | 114 | } |
103 | 115 | ||
104 | showCard (cb: (value: boolean) => void) { | 116 | showCard (cb: (canceled: boolean) => void) { |
105 | let timeout: any | 117 | let timeout: any |
106 | 118 | ||
107 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) | 119 | this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) |
@@ -109,6 +121,10 @@ class EndCard extends Component { | |||
109 | 121 | ||
110 | this.title.innerHTML = this.options_.getTitle() | 122 | this.title.innerHTML = this.options_.getTitle() |
111 | 123 | ||
124 | if (this.totalTicks === 0) { | ||
125 | return cb(false) | ||
126 | } | ||
127 | |||
112 | this.upNextEvents.one('cancel', () => { | 128 | this.upNextEvents.one('cancel', () => { |
113 | clearTimeout(timeout) | 129 | clearTimeout(timeout) |
114 | cb(true) | 130 | cb(true) |
@@ -134,7 +150,7 @@ class EndCard extends Component { | |||
134 | } | 150 | } |
135 | 151 | ||
136 | const update = () => { | 152 | const update = () => { |
137 | if (this.options_.suspended()) { | 153 | if (this.options_.isSuspended()) { |
138 | this.suspendedMessage.innerText = this.options_.suspendedText | 154 | this.suspendedMessage.innerText = this.options_.suspendedText |
139 | goToPercent(0) | 155 | goToPercent(0) |
140 | this.ticks = 0 | 156 | this.ticks = 0 |
diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts index e12e8c503..0badcd68c 100644 --- a/client/src/assets/player/shared/upnext/upnext-plugin.ts +++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts | |||
@@ -1,27 +1,25 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { UpNextPluginOptions } from '../../types' | ||
2 | import { EndCardOptions } from './end-card' | 3 | import { EndCardOptions } from './end-card' |
3 | 4 | ||
4 | const Plugin = videojs.getPlugin('plugin') | 5 | const Plugin = videojs.getPlugin('plugin') |
5 | 6 | ||
6 | class UpNextPlugin extends Plugin { | 7 | class UpNextPlugin extends Plugin { |
7 | 8 | ||
8 | constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) { | 9 | constructor (player: videojs.Player, options: UpNextPluginOptions) { |
9 | const settings = { | 10 | super(player) |
11 | |||
12 | const settings: EndCardOptions = { | ||
10 | next: options.next, | 13 | next: options.next, |
11 | getTitle: options.getTitle, | 14 | getTitle: options.getTitle, |
12 | timeout: options.timeout || 5000, | 15 | timeout: options.timeout, |
13 | cancelText: options.cancelText || 'Cancel', | 16 | cancelText: player.localize('Cancel'), |
14 | headText: options.headText || 'Up Next', | 17 | headText: player.localize('Up Next'), |
15 | suspendedText: options.suspendedText || 'Autoplay is suspended', | 18 | suspendedText: player.localize('Autoplay is suspended'), |
16 | condition: options.condition, | 19 | isDisplayed: options.isDisplayed, |
17 | suspended: options.suspended | 20 | isSuspended: options.isSuspended |
18 | } | 21 | } |
19 | 22 | ||
20 | super(player) | ||
21 | |||
22 | // UpNext plugin can be called later, so ensure the player is not disposed | ||
23 | if (this.player.isDisposed()) return | ||
24 | |||
25 | this.player.ready(() => { | 23 | this.player.ready(() => { |
26 | player.addClass('vjs-upnext') | 24 | player.addClass('vjs-upnext') |
27 | }) | 25 | }) |
diff --git a/client/src/assets/player/shared/web-video/web-video-plugin.ts b/client/src/assets/player/shared/web-video/web-video-plugin.ts new file mode 100644 index 000000000..80e56795b --- /dev/null +++ b/client/src/assets/player/shared/web-video/web-video-plugin.ts | |||
@@ -0,0 +1,186 @@ | |||
1 | import debug from 'debug' | ||
2 | import videojs from 'video.js' | ||
3 | import { logger } from '@root-helpers/logger' | ||
4 | import { addQueryParams } from '@shared/core-utils' | ||
5 | import { VideoFile } from '@shared/models' | ||
6 | import { PeerTubeResolution, PlayerNetworkInfo, WebVideoPluginOptions } from '../../types' | ||
7 | |||
8 | const debugLogger = debug('peertube:player:web-video-plugin') | ||
9 | |||
10 | const Plugin = videojs.getPlugin('plugin') | ||
11 | |||
12 | class WebVideoPlugin extends Plugin { | ||
13 | private readonly videoFiles: VideoFile[] | ||
14 | |||
15 | private currentVideoFile: VideoFile | ||
16 | private videoFileToken: () => string | ||
17 | |||
18 | private networkInfoInterval: any | ||
19 | |||
20 | private onErrorHandler: () => void | ||
21 | private onPlayHandler: () => void | ||
22 | |||
23 | constructor (player: videojs.Player, options?: WebVideoPluginOptions) { | ||
24 | super(player, options) | ||
25 | |||
26 | this.videoFiles = options.videoFiles | ||
27 | this.videoFileToken = options.videoFileToken | ||
28 | |||
29 | this.updateVideoFile({ videoFile: this.pickAverageVideoFile(), isUserResolutionChange: false }) | ||
30 | |||
31 | player.ready(() => { | ||
32 | this.buildQualities() | ||
33 | |||
34 | this.setupNetworkInfoInterval() | ||
35 | |||
36 | if (this.videoFiles.length === 0) { | ||
37 | this.player.addClass('disabled') | ||
38 | return | ||
39 | } | ||
40 | }) | ||
41 | } | ||
42 | |||
43 | dispose () { | ||
44 | clearInterval(this.networkInfoInterval) | ||
45 | |||
46 | if (this.onErrorHandler) this.player.off('error', this.onErrorHandler) | ||
47 | if (this.onPlayHandler) this.player.off('canplay', this.onPlayHandler) | ||
48 | |||
49 | super.dispose() | ||
50 | } | ||
51 | |||
52 | getCurrentResolutionId () { | ||
53 | return this.currentVideoFile.resolution.id | ||
54 | } | ||
55 | |||
56 | updateVideoFile (options: { | ||
57 | videoFile: VideoFile | ||
58 | isUserResolutionChange: boolean | ||
59 | }) { | ||
60 | this.currentVideoFile = options.videoFile | ||
61 | |||
62 | debugLogger('Updating web video file to ' + this.currentVideoFile.fileUrl) | ||
63 | |||
64 | const paused = this.player.paused() | ||
65 | const playbackRate = this.player.playbackRate() | ||
66 | const currentTime = this.player.currentTime() | ||
67 | |||
68 | // Enable error display now this is our last fallback | ||
69 | this.onErrorHandler = () => this.player.peertube().displayFatalError() | ||
70 | this.player.one('error', this.onErrorHandler) | ||
71 | |||
72 | let httpUrl = this.currentVideoFile.fileUrl | ||
73 | |||
74 | if (this.videoFileToken()) { | ||
75 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) | ||
76 | } | ||
77 | |||
78 | const oldAutoplayValue = this.player.autoplay() | ||
79 | if (options.isUserResolutionChange) { | ||
80 | this.player.autoplay(false) | ||
81 | this.player.addClass('vjs-updating-resolution') | ||
82 | } | ||
83 | |||
84 | this.player.src(httpUrl) | ||
85 | |||
86 | this.onPlayHandler = () => { | ||
87 | this.player.playbackRate(playbackRate) | ||
88 | this.player.currentTime(currentTime) | ||
89 | |||
90 | this.adaptPosterForAudioOnly() | ||
91 | |||
92 | if (options.isUserResolutionChange) { | ||
93 | this.player.trigger('user-resolution-change') | ||
94 | this.player.trigger('web-video-source-change') | ||
95 | |||
96 | this.tryToPlay() | ||
97 | .then(() => { | ||
98 | if (paused) this.player.pause() | ||
99 | |||
100 | this.player.autoplay(oldAutoplayValue) | ||
101 | }) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | this.player.one('canplay', this.onPlayHandler) | ||
106 | } | ||
107 | |||
108 | getCurrentVideoFile () { | ||
109 | return this.currentVideoFile | ||
110 | } | ||
111 | |||
112 | private adaptPosterForAudioOnly () { | ||
113 | // Audio-only (resolutionId === 0) gets special treatment | ||
114 | if (this.currentVideoFile.resolution.id === 0) { | ||
115 | this.player.audioPosterMode(true) | ||
116 | } else { | ||
117 | this.player.audioPosterMode(false) | ||
118 | } | ||
119 | } | ||
120 | |||
121 | private tryToPlay () { | ||
122 | debugLogger('Try to play manually the video') | ||
123 | |||
124 | const playPromise = this.player.play() | ||
125 | if (playPromise === undefined) return | ||
126 | |||
127 | return playPromise | ||
128 | .catch((err: Error) => { | ||
129 | if (err.message.includes('The play() request was interrupted by a call to pause()')) { | ||
130 | return | ||
131 | } | ||
132 | |||
133 | logger.warn(err) | ||
134 | this.player.pause() | ||
135 | this.player.posterImage.show() | ||
136 | this.player.removeClass('vjs-has-autoplay') | ||
137 | this.player.removeClass('vjs-playing-audio-only-content') | ||
138 | }) | ||
139 | .finally(() => { | ||
140 | this.player.removeClass('vjs-updating-resolution') | ||
141 | }) | ||
142 | } | ||
143 | |||
144 | private pickAverageVideoFile () { | ||
145 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
146 | |||
147 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
148 | return files[Math.floor(files.length / 2)] | ||
149 | } | ||
150 | |||
151 | private buildQualities () { | ||
152 | const resolutions: PeerTubeResolution[] = this.videoFiles.map(videoFile => ({ | ||
153 | id: videoFile.resolution.id, | ||
154 | label: this.buildQualityLabel(videoFile), | ||
155 | height: videoFile.resolution.id, | ||
156 | selected: videoFile.id === this.currentVideoFile.id, | ||
157 | selectCallback: () => this.updateVideoFile({ videoFile, isUserResolutionChange: true }) | ||
158 | })) | ||
159 | |||
160 | this.player.peertubeResolutions().add(resolutions) | ||
161 | } | ||
162 | |||
163 | private buildQualityLabel (file: VideoFile) { | ||
164 | let label = file.resolution.label | ||
165 | |||
166 | if (file.fps && file.fps >= 50) { | ||
167 | label += file.fps | ||
168 | } | ||
169 | |||
170 | return label | ||
171 | } | ||
172 | |||
173 | private setupNetworkInfoInterval () { | ||
174 | this.networkInfoInterval = setInterval(() => { | ||
175 | return this.player.trigger('http-info', { | ||
176 | source: 'web-video', | ||
177 | http: { | ||
178 | downloaded: this.player.bufferedPercent() * this.currentVideoFile.size | ||
179 | } | ||
180 | } as PlayerNetworkInfo) | ||
181 | }, 1000) | ||
182 | } | ||
183 | } | ||
184 | |||
185 | videojs.registerPlugin('webVideo', WebVideoPlugin) | ||
186 | export { WebVideoPlugin } | ||
diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts deleted file mode 100644 index 74ae17704..000000000 --- a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts +++ /dev/null | |||
@@ -1,234 +0,0 @@ | |||
1 | // From https://github.com/MinEduTDF/idb-chunk-store | ||
2 | // We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues | ||
3 | // Thanks @santiagogil and @Feross | ||
4 | |||
5 | import Dexie from 'dexie' | ||
6 | import { EventEmitter } from 'events' | ||
7 | import { logger } from '@root-helpers/logger' | ||
8 | |||
9 | class ChunkDatabase extends Dexie { | ||
10 | chunks: Dexie.Table<{ id: number, buf: Buffer }, number> | ||
11 | |||
12 | constructor (dbname: string) { | ||
13 | super(dbname) | ||
14 | |||
15 | this.version(1).stores({ | ||
16 | chunks: 'id' | ||
17 | }) | ||
18 | } | ||
19 | } | ||
20 | |||
21 | class ExpirationDatabase extends Dexie { | ||
22 | databases: Dexie.Table<{ name: string, expiration: number }, number> | ||
23 | |||
24 | constructor () { | ||
25 | super('webtorrent-expiration') | ||
26 | |||
27 | this.version(1).stores({ | ||
28 | databases: 'name,expiration' | ||
29 | }) | ||
30 | } | ||
31 | } | ||
32 | |||
33 | export class PeertubeChunkStore extends EventEmitter { | ||
34 | private static readonly BUFFERING_PUT_MS = 1000 | ||
35 | private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute | ||
36 | private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes | ||
37 | |||
38 | chunkLength: number | ||
39 | |||
40 | private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = [] | ||
41 | // If the store is full | ||
42 | private memoryChunks: { [ id: number ]: Buffer | true } = {} | ||
43 | private databaseName: string | ||
44 | private putBulkTimeout: any | ||
45 | private cleanerInterval: any | ||
46 | private db: ChunkDatabase | ||
47 | private expirationDB: ExpirationDatabase | ||
48 | private readonly length: number | ||
49 | private readonly lastChunkLength: number | ||
50 | private readonly lastChunkIndex: number | ||
51 | |||
52 | constructor (chunkLength: number, opts: any) { | ||
53 | super() | ||
54 | |||
55 | this.databaseName = 'webtorrent-chunks-' | ||
56 | |||
57 | if (!opts) opts = {} | ||
58 | if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash | ||
59 | else this.databaseName += '-default' | ||
60 | |||
61 | this.setMaxListeners(100) | ||
62 | |||
63 | this.chunkLength = Number(chunkLength) | ||
64 | if (!this.chunkLength) throw new Error('First argument must be a chunk length') | ||
65 | |||
66 | this.length = Number(opts.length) || Infinity | ||
67 | |||
68 | if (this.length !== Infinity) { | ||
69 | this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength | ||
70 | this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1 | ||
71 | } | ||
72 | |||
73 | this.db = new ChunkDatabase(this.databaseName) | ||
74 | // Track databases that expired | ||
75 | this.expirationDB = new ExpirationDatabase() | ||
76 | |||
77 | this.runCleaner() | ||
78 | } | ||
79 | |||
80 | put (index: number, buf: Buffer, cb: (err?: Error) => void) { | ||
81 | const isLastChunk = (index === this.lastChunkIndex) | ||
82 | if (isLastChunk && buf.length !== this.lastChunkLength) { | ||
83 | return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength)) | ||
84 | } | ||
85 | if (!isLastChunk && buf.length !== this.chunkLength) { | ||
86 | return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength)) | ||
87 | } | ||
88 | |||
89 | // Specify we have this chunk | ||
90 | this.memoryChunks[index] = true | ||
91 | |||
92 | // Add it to the pending put | ||
93 | this.pendingPut.push({ id: index, buf, cb }) | ||
94 | // If it's already planned, return | ||
95 | if (this.putBulkTimeout) return | ||
96 | |||
97 | // Plan a future bulk insert | ||
98 | this.putBulkTimeout = setTimeout(async () => { | ||
99 | const processing = this.pendingPut | ||
100 | this.pendingPut = [] | ||
101 | this.putBulkTimeout = undefined | ||
102 | |||
103 | try { | ||
104 | await this.db.transaction('rw', this.db.chunks, () => { | ||
105 | return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf }))) | ||
106 | }) | ||
107 | } catch (err) { | ||
108 | logger.info('Cannot bulk insert chunks. Store them in memory.', err) | ||
109 | |||
110 | processing.forEach(p => { | ||
111 | this.memoryChunks[p.id] = p.buf | ||
112 | }) | ||
113 | } finally { | ||
114 | processing.forEach(p => p.cb()) | ||
115 | } | ||
116 | }, PeertubeChunkStore.BUFFERING_PUT_MS) | ||
117 | } | ||
118 | |||
119 | get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void { | ||
120 | if (typeof opts === 'function') return this.get(index, null, opts) | ||
121 | |||
122 | // IndexDB could be slow, use our memory index first | ||
123 | const memoryChunk = this.memoryChunks[index] | ||
124 | if (memoryChunk === undefined) { | ||
125 | const err = new Error('Chunk not found') as any | ||
126 | err['notFound'] = true | ||
127 | |||
128 | return process.nextTick(() => cb(err)) | ||
129 | } | ||
130 | |||
131 | // Chunk in memory | ||
132 | if (memoryChunk !== true) return cb(null, memoryChunk) | ||
133 | |||
134 | // Chunk in store | ||
135 | this.db.transaction('r', this.db.chunks, async () => { | ||
136 | const result = await this.db.chunks.get({ id: index }) | ||
137 | if (result === undefined) return cb(null, Buffer.alloc(0)) | ||
138 | |||
139 | const buf = result.buf | ||
140 | if (!opts) return this.nextTick(cb, null, buf) | ||
141 | |||
142 | const offset = opts.offset || 0 | ||
143 | const len = opts.length || (buf.length - offset) | ||
144 | return cb(null, buf.slice(offset, len + offset)) | ||
145 | }) | ||
146 | .catch(err => { | ||
147 | logger.error(err) | ||
148 | return cb(err) | ||
149 | }) | ||
150 | } | ||
151 | |||
152 | close (cb: (err?: Error) => void) { | ||
153 | return this.destroy(cb) | ||
154 | } | ||
155 | |||
156 | async destroy (cb: (err?: Error) => void) { | ||
157 | try { | ||
158 | if (this.pendingPut) { | ||
159 | clearTimeout(this.putBulkTimeout) | ||
160 | this.pendingPut = null | ||
161 | } | ||
162 | if (this.cleanerInterval) { | ||
163 | clearInterval(this.cleanerInterval) | ||
164 | this.cleanerInterval = null | ||
165 | } | ||
166 | |||
167 | if (this.db) { | ||
168 | this.db.close() | ||
169 | |||
170 | await this.dropDatabase(this.databaseName) | ||
171 | } | ||
172 | |||
173 | if (this.expirationDB) { | ||
174 | this.expirationDB.close() | ||
175 | this.expirationDB = null | ||
176 | } | ||
177 | |||
178 | return cb() | ||
179 | } catch (err) { | ||
180 | logger.error('Cannot destroy peertube chunk store.', err) | ||
181 | return cb(err) | ||
182 | } | ||
183 | } | ||
184 | |||
185 | private runCleaner () { | ||
186 | this.checkExpiration() | ||
187 | |||
188 | this.cleanerInterval = setInterval(() => { | ||
189 | this.checkExpiration() | ||
190 | }, PeertubeChunkStore.CLEANER_INTERVAL_MS) | ||
191 | } | ||
192 | |||
193 | private async checkExpiration () { | ||
194 | let databasesToDeleteInfo: { name: string }[] = [] | ||
195 | |||
196 | try { | ||
197 | await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => { | ||
198 | // Update our database expiration since we are alive | ||
199 | await this.expirationDB.databases.put({ | ||
200 | name: this.databaseName, | ||
201 | expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS | ||
202 | }) | ||
203 | |||
204 | const now = new Date().getTime() | ||
205 | databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray() | ||
206 | }) | ||
207 | } catch (err) { | ||
208 | logger.error('Cannot update expiration of fetch expired databases.', err) | ||
209 | } | ||
210 | |||
211 | for (const databaseToDeleteInfo of databasesToDeleteInfo) { | ||
212 | await this.dropDatabase(databaseToDeleteInfo.name) | ||
213 | } | ||
214 | } | ||
215 | |||
216 | private async dropDatabase (databaseName: string) { | ||
217 | const dbToDelete = new ChunkDatabase(databaseName) | ||
218 | logger.info(`Destroying IndexDB database ${databaseName}`) | ||
219 | |||
220 | try { | ||
221 | await dbToDelete.delete() | ||
222 | |||
223 | await this.expirationDB.transaction('rw', this.expirationDB.databases, () => { | ||
224 | return this.expirationDB.databases.where({ name: databaseName }).delete() | ||
225 | }) | ||
226 | } catch (err) { | ||
227 | logger.error(`Cannot delete ${databaseName}.`, err) | ||
228 | } | ||
229 | } | ||
230 | |||
231 | private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) { | ||
232 | process.nextTick(() => cb(err, val), undefined) | ||
233 | } | ||
234 | } | ||
diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts deleted file mode 100644 index a85d7a838..000000000 --- a/client/src/assets/player/shared/webtorrent/video-renderer.ts +++ /dev/null | |||
@@ -1,134 +0,0 @@ | |||
1 | // Thanks: https://github.com/feross/render-media | ||
2 | |||
3 | const MediaElementWrapper = require('mediasource') | ||
4 | import { logger } from '@root-helpers/logger' | ||
5 | import { extname } from 'path' | ||
6 | const Videostream = require('videostream') | ||
7 | |||
8 | const VIDEOSTREAM_EXTS = [ | ||
9 | '.m4a', | ||
10 | '.m4v', | ||
11 | '.mp4' | ||
12 | ] | ||
13 | |||
14 | type RenderMediaOptions = { | ||
15 | controls: boolean | ||
16 | autoplay: boolean | ||
17 | } | ||
18 | |||
19 | function renderVideo ( | ||
20 | file: any, | ||
21 | elem: HTMLVideoElement, | ||
22 | opts: RenderMediaOptions, | ||
23 | callback: (err: Error, renderer: any) => void | ||
24 | ) { | ||
25 | validateFile(file) | ||
26 | |||
27 | return renderMedia(file, elem, opts, callback) | ||
28 | } | ||
29 | |||
30 | function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) { | ||
31 | const extension = extname(file.name).toLowerCase() | ||
32 | let preparedElem: any | ||
33 | let currentTime = 0 | ||
34 | let renderer: any | ||
35 | |||
36 | try { | ||
37 | if (VIDEOSTREAM_EXTS.includes(extension)) { | ||
38 | renderer = useVideostream() | ||
39 | } else { | ||
40 | renderer = useMediaSource() | ||
41 | } | ||
42 | } catch (err) { | ||
43 | return callback(err) | ||
44 | } | ||
45 | |||
46 | function useVideostream () { | ||
47 | prepareElem() | ||
48 | preparedElem.addEventListener('error', function onError (err: Error) { | ||
49 | preparedElem.removeEventListener('error', onError) | ||
50 | |||
51 | return callback(err) | ||
52 | }) | ||
53 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
54 | return new Videostream(file, preparedElem) | ||
55 | } | ||
56 | |||
57 | function useMediaSource (useVP9 = false) { | ||
58 | const codecs = getCodec(file.name, useVP9) | ||
59 | |||
60 | prepareElem() | ||
61 | preparedElem.addEventListener('error', function onError (err: Error) { | ||
62 | preparedElem.removeEventListener('error', onError) | ||
63 | |||
64 | // Try with vp9 before returning an error | ||
65 | if (codecs.includes('vp8')) return fallbackToMediaSource(true) | ||
66 | |||
67 | return callback(err) | ||
68 | }) | ||
69 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
70 | |||
71 | const wrapper = new MediaElementWrapper(preparedElem) | ||
72 | const writable = wrapper.createWriteStream(codecs) | ||
73 | file.createReadStream().pipe(writable) | ||
74 | |||
75 | if (currentTime) preparedElem.currentTime = currentTime | ||
76 | |||
77 | return wrapper | ||
78 | } | ||
79 | |||
80 | function fallbackToMediaSource (useVP9 = false) { | ||
81 | if (useVP9 === true) logger.info('Falling back to media source with VP9 enabled.') | ||
82 | else logger.info('Falling back to media source..') | ||
83 | |||
84 | useMediaSource(useVP9) | ||
85 | } | ||
86 | |||
87 | function prepareElem () { | ||
88 | if (preparedElem === undefined) { | ||
89 | preparedElem = elem | ||
90 | |||
91 | preparedElem.addEventListener('progress', function () { | ||
92 | currentTime = elem.currentTime | ||
93 | }) | ||
94 | } | ||
95 | } | ||
96 | |||
97 | function onLoadStart () { | ||
98 | preparedElem.removeEventListener('loadstart', onLoadStart) | ||
99 | if (opts.autoplay) preparedElem.play() | ||
100 | |||
101 | callback(null, renderer) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | function validateFile (file: any) { | ||
106 | if (file == null) { | ||
107 | throw new Error('file cannot be null or undefined') | ||
108 | } | ||
109 | if (typeof file.name !== 'string') { | ||
110 | throw new Error('missing or invalid file.name property') | ||
111 | } | ||
112 | if (typeof file.createReadStream !== 'function') { | ||
113 | throw new Error('missing or invalid file.createReadStream property') | ||
114 | } | ||
115 | } | ||
116 | |||
117 | function getCodec (name: string, useVP9 = false) { | ||
118 | const ext = extname(name).toLowerCase() | ||
119 | if (ext === '.mp4') { | ||
120 | return 'video/mp4; codecs="avc1.640029, mp4a.40.5"' | ||
121 | } | ||
122 | |||
123 | if (ext === '.webm') { | ||
124 | if (useVP9 === true) return 'video/webm; codecs="vp9, opus"' | ||
125 | |||
126 | return 'video/webm; codecs="vp8, vorbis"' | ||
127 | } | ||
128 | |||
129 | return undefined | ||
130 | } | ||
131 | |||
132 | export { | ||
133 | renderVideo | ||
134 | } | ||
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts deleted file mode 100644 index e2e220c03..000000000 --- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts +++ /dev/null | |||
@@ -1,663 +0,0 @@ | |||
1 | import videojs from 'video.js' | ||
2 | import * as WebTorrent from 'webtorrent' | ||
3 | import { logger } from '@root-helpers/logger' | ||
4 | import { isIOS } from '@root-helpers/web-browser' | ||
5 | import { addQueryParams, timeToInt } from '@shared/core-utils' | ||
6 | import { VideoFile } from '@shared/models' | ||
7 | import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage' | ||
8 | import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types' | ||
9 | import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common' | ||
10 | import { PeertubeChunkStore } from './peertube-chunk-store' | ||
11 | import { renderVideo } from './video-renderer' | ||
12 | |||
13 | const CacheChunkStore = require('cache-chunk-store') | ||
14 | |||
15 | type PlayOptions = { | ||
16 | forcePlay?: boolean | ||
17 | seek?: number | ||
18 | delay?: number | ||
19 | } | ||
20 | |||
21 | const Plugin = videojs.getPlugin('plugin') | ||
22 | |||
23 | class WebTorrentPlugin extends Plugin { | ||
24 | readonly videoFiles: VideoFile[] | ||
25 | |||
26 | private readonly playerElement: HTMLVideoElement | ||
27 | |||
28 | private readonly autoplay: boolean | string = false | ||
29 | private readonly startTime: number = 0 | ||
30 | private readonly savePlayerSrcFunction: videojs.Player['src'] | ||
31 | private readonly videoDuration: number | ||
32 | private readonly CONSTANTS = { | ||
33 | INFO_SCHEDULER: 1000, // Don't change this | ||
34 | AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds | ||
35 | AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it | ||
36 | AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check | ||
37 | AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds | ||
38 | BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth | ||
39 | } | ||
40 | |||
41 | private readonly buildWebSeedUrls: (file: VideoFile) => string[] | ||
42 | |||
43 | private readonly webtorrent = new WebTorrent({ | ||
44 | tracker: { | ||
45 | rtcConfig: getRtcConfig() | ||
46 | }, | ||
47 | dht: false | ||
48 | }) | ||
49 | |||
50 | private currentVideoFile: VideoFile | ||
51 | private torrent: WebTorrent.Torrent | ||
52 | |||
53 | private renderer: any | ||
54 | private fakeRenderer: any | ||
55 | private destroyingFakeRenderer = false | ||
56 | |||
57 | private autoResolution = true | ||
58 | private autoResolutionPossible = true | ||
59 | private isAutoResolutionObservation = false | ||
60 | private playerRefusedP2P = false | ||
61 | |||
62 | private requiresUserAuth: boolean | ||
63 | private videoFileToken: () => string | ||
64 | |||
65 | private torrentInfoInterval: any | ||
66 | private autoQualityInterval: any | ||
67 | private addTorrentDelay: any | ||
68 | private qualityObservationTimer: any | ||
69 | private runAutoQualitySchedulerTimer: any | ||
70 | |||
71 | private downloadSpeeds: number[] = [] | ||
72 | |||
73 | constructor (player: videojs.Player, options?: WebtorrentPluginOptions) { | ||
74 | super(player) | ||
75 | |||
76 | this.startTime = timeToInt(options.startTime) | ||
77 | |||
78 | // Custom autoplay handled by webtorrent because we lazy play the video | ||
79 | this.autoplay = options.autoplay | ||
80 | |||
81 | this.playerRefusedP2P = options.playerRefusedP2P | ||
82 | |||
83 | this.videoFiles = options.videoFiles | ||
84 | this.videoDuration = options.videoDuration | ||
85 | |||
86 | this.savePlayerSrcFunction = this.player.src | ||
87 | this.playerElement = options.playerElement | ||
88 | |||
89 | this.requiresUserAuth = options.requiresUserAuth | ||
90 | this.videoFileToken = options.videoFileToken | ||
91 | |||
92 | this.buildWebSeedUrls = options.buildWebSeedUrls | ||
93 | |||
94 | this.player.ready(() => { | ||
95 | const playerOptions = this.player.options_ | ||
96 | |||
97 | const volume = getStoredVolume() | ||
98 | if (volume !== undefined) this.player.volume(volume) | ||
99 | |||
100 | const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() | ||
101 | if (muted !== undefined) this.player.muted(muted) | ||
102 | |||
103 | this.player.duration(options.videoDuration) | ||
104 | |||
105 | this.initializePlayer() | ||
106 | this.runTorrentInfoScheduler() | ||
107 | |||
108 | this.player.one('play', () => { | ||
109 | // Don't run immediately scheduler, wait some seconds the TCP connections are made | ||
110 | this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER) | ||
111 | }) | ||
112 | }) | ||
113 | } | ||
114 | |||
115 | dispose () { | ||
116 | clearTimeout(this.addTorrentDelay) | ||
117 | clearTimeout(this.qualityObservationTimer) | ||
118 | clearTimeout(this.runAutoQualitySchedulerTimer) | ||
119 | |||
120 | clearInterval(this.torrentInfoInterval) | ||
121 | clearInterval(this.autoQualityInterval) | ||
122 | |||
123 | // Don't need to destroy renderer, video player will be destroyed | ||
124 | this.flushVideoFile(this.currentVideoFile, false) | ||
125 | |||
126 | this.destroyFakeRenderer() | ||
127 | } | ||
128 | |||
129 | getCurrentResolutionId () { | ||
130 | return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1 | ||
131 | } | ||
132 | |||
133 | updateVideoFile ( | ||
134 | videoFile?: VideoFile, | ||
135 | options: { | ||
136 | forcePlay?: boolean | ||
137 | seek?: number | ||
138 | delay?: number | ||
139 | } = {}, | ||
140 | done: () => void = () => { /* empty */ } | ||
141 | ) { | ||
142 | // Automatically choose the adapted video file | ||
143 | if (!videoFile) { | ||
144 | const savedAverageBandwidth = getAverageBandwidthInStore() | ||
145 | videoFile = savedAverageBandwidth | ||
146 | ? this.getAppropriateFile(savedAverageBandwidth) | ||
147 | : this.pickAverageVideoFile() | ||
148 | } | ||
149 | |||
150 | if (!videoFile) { | ||
151 | throw Error(`Can't update video file since videoFile is undefined.`) | ||
152 | } | ||
153 | |||
154 | // Don't add the same video file once again | ||
155 | if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { | ||
156 | return | ||
157 | } | ||
158 | |||
159 | // Do not display error to user because we will have multiple fallback | ||
160 | this.player.peertube().hideFatalError(); | ||
161 | |||
162 | // Hack to "simulate" src link in video.js >= 6 | ||
163 | // Without this, we can't play the video after pausing it | ||
164 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | ||
165 | (this.player as any).src = () => true | ||
166 | const oldPlaybackRate = this.player.playbackRate() | ||
167 | |||
168 | const previousVideoFile = this.currentVideoFile | ||
169 | this.currentVideoFile = videoFile | ||
170 | |||
171 | // Don't try on iOS that does not support MediaSource | ||
172 | // Or don't use P2P if webtorrent is disabled | ||
173 | if (isIOS() || this.playerRefusedP2P) { | ||
174 | return this.fallbackToHttp(options, () => { | ||
175 | this.player.playbackRate(oldPlaybackRate) | ||
176 | return done() | ||
177 | }) | ||
178 | } | ||
179 | |||
180 | this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => { | ||
181 | this.player.playbackRate(oldPlaybackRate) | ||
182 | return done() | ||
183 | }) | ||
184 | |||
185 | this.selectAppropriateResolution(true) | ||
186 | } | ||
187 | |||
188 | updateEngineResolution (resolutionId: number, delay = 0) { | ||
189 | // Remember player state | ||
190 | const currentTime = this.player.currentTime() | ||
191 | const isPaused = this.player.paused() | ||
192 | |||
193 | // Hide bigPlayButton | ||
194 | if (!isPaused) { | ||
195 | this.player.bigPlayButton.hide() | ||
196 | } | ||
197 | |||
198 | // Audio-only (resolutionId === 0) gets special treatment | ||
199 | if (resolutionId === 0) { | ||
200 | // Audio-only: show poster, do not auto-hide controls | ||
201 | this.player.addClass('vjs-playing-audio-only-content') | ||
202 | this.player.posterImage.show() | ||
203 | } else { | ||
204 | // Hide poster to have black background | ||
205 | this.player.removeClass('vjs-playing-audio-only-content') | ||
206 | this.player.posterImage.hide() | ||
207 | } | ||
208 | |||
209 | const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId) | ||
210 | const options = { | ||
211 | forcePlay: false, | ||
212 | delay, | ||
213 | seek: currentTime + (delay / 1000) | ||
214 | } | ||
215 | |||
216 | this.updateVideoFile(newVideoFile, options) | ||
217 | |||
218 | this.player.trigger('engineResolutionChange') | ||
219 | } | ||
220 | |||
221 | flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { | ||
222 | if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) { | ||
223 | if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() | ||
224 | |||
225 | this.webtorrent.remove(videoFile.magnetUri) | ||
226 | logger.info(`Removed ${videoFile.magnetUri}`) | ||
227 | } | ||
228 | } | ||
229 | |||
230 | disableAutoResolution () { | ||
231 | this.autoResolution = false | ||
232 | this.autoResolutionPossible = false | ||
233 | this.player.peertubeResolutions().disableAutoResolution() | ||
234 | } | ||
235 | |||
236 | isAutoResolutionPossible () { | ||
237 | return this.autoResolutionPossible | ||
238 | } | ||
239 | |||
240 | getTorrent () { | ||
241 | return this.torrent | ||
242 | } | ||
243 | |||
244 | getCurrentVideoFile () { | ||
245 | return this.currentVideoFile | ||
246 | } | ||
247 | |||
248 | changeQuality (id: number) { | ||
249 | if (id === -1) { | ||
250 | if (this.autoResolutionPossible === true) { | ||
251 | this.autoResolution = true | ||
252 | |||
253 | this.selectAppropriateResolution(false) | ||
254 | } | ||
255 | |||
256 | return | ||
257 | } | ||
258 | |||
259 | this.autoResolution = false | ||
260 | this.updateEngineResolution(id) | ||
261 | this.selectAppropriateResolution(false) | ||
262 | } | ||
263 | |||
264 | private addTorrent ( | ||
265 | magnetOrTorrentUrl: string, | ||
266 | previousVideoFile: VideoFile, | ||
267 | options: PlayOptions, | ||
268 | done: (err?: Error) => void | ||
269 | ) { | ||
270 | if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done) | ||
271 | |||
272 | logger.info(`Adding ${magnetOrTorrentUrl}.`) | ||
273 | |||
274 | const oldTorrent = this.torrent | ||
275 | const torrentOptions = { | ||
276 | // Don't use arrow function: it breaks webtorrent (that uses `new` keyword) | ||
277 | store: function (chunkLength: number, storeOpts: any) { | ||
278 | return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), { | ||
279 | max: 100 | ||
280 | }) | ||
281 | }, | ||
282 | urlList: this.buildWebSeedUrls(this.currentVideoFile) | ||
283 | } | ||
284 | |||
285 | this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => { | ||
286 | logger.info(`Added ${magnetOrTorrentUrl}.`) | ||
287 | |||
288 | if (oldTorrent) { | ||
289 | // Pause the old torrent | ||
290 | this.stopTorrent(oldTorrent) | ||
291 | |||
292 | // We use a fake renderer so we download correct pieces of the next file | ||
293 | if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay) | ||
294 | } | ||
295 | |||
296 | // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution) | ||
297 | this.addTorrentDelay = setTimeout(() => { | ||
298 | // We don't need the fake renderer anymore | ||
299 | this.destroyFakeRenderer() | ||
300 | |||
301 | const paused = this.player.paused() | ||
302 | |||
303 | this.flushVideoFile(previousVideoFile) | ||
304 | |||
305 | // Update progress bar (just for the UI), do not wait rendering | ||
306 | if (options.seek) this.player.currentTime(options.seek) | ||
307 | |||
308 | const renderVideoOptions = { autoplay: false, controls: true } | ||
309 | renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => { | ||
310 | this.renderer = renderer | ||
311 | |||
312 | if (err) return this.fallbackToHttp(options, done) | ||
313 | |||
314 | return this.tryToPlay(err => { | ||
315 | if (err) return done(err) | ||
316 | |||
317 | if (options.seek) this.seek(options.seek) | ||
318 | if (options.forcePlay === false && paused === true) this.player.pause() | ||
319 | |||
320 | return done() | ||
321 | }) | ||
322 | }) | ||
323 | }, options.delay || 0) | ||
324 | }) | ||
325 | |||
326 | this.torrent.on('error', (err: any) => logger.error(err)) | ||
327 | |||
328 | this.torrent.on('warning', (err: any) => { | ||
329 | // We don't support HTTP tracker but we don't care -> we use the web socket tracker | ||
330 | if (err.message.indexOf('Unsupported tracker protocol') !== -1) return | ||
331 | |||
332 | // Users don't care about issues with WebRTC, but developers do so log it in the console | ||
333 | if (err.message.indexOf('Ice connection failed') !== -1) { | ||
334 | logger.info(err) | ||
335 | return | ||
336 | } | ||
337 | |||
338 | // Magnet hash is not up to date with the torrent file, add directly the torrent file | ||
339 | if (err.message.indexOf('incorrect info hash') !== -1) { | ||
340 | logger.error('Incorrect info hash detected, falling back to torrent file.') | ||
341 | const newOptions = { forcePlay: true, seek: options.seek } | ||
342 | return this.addTorrent((this.torrent as any)['xs'], previousVideoFile, newOptions, done) | ||
343 | } | ||
344 | |||
345 | // Remote instance is down | ||
346 | if (err.message.indexOf('from xs param') !== -1) { | ||
347 | this.handleError(err) | ||
348 | } | ||
349 | |||
350 | logger.warn(err) | ||
351 | }) | ||
352 | } | ||
353 | |||
354 | private tryToPlay (done?: (err?: Error) => void) { | ||
355 | if (!done) done = function () { /* empty */ } | ||
356 | |||
357 | const playPromise = this.player.play() | ||
358 | if (playPromise !== undefined) { | ||
359 | return playPromise.then(() => done()) | ||
360 | .catch((err: Error) => { | ||
361 | if (err.message.includes('The play() request was interrupted by a call to pause()')) { | ||
362 | return | ||
363 | } | ||
364 | |||
365 | logger.warn(err) | ||
366 | this.player.pause() | ||
367 | this.player.posterImage.show() | ||
368 | this.player.removeClass('vjs-has-autoplay') | ||
369 | this.player.removeClass('vjs-has-big-play-button-clicked') | ||
370 | this.player.removeClass('vjs-playing-audio-only-content') | ||
371 | |||
372 | return done() | ||
373 | }) | ||
374 | } | ||
375 | |||
376 | return done() | ||
377 | } | ||
378 | |||
379 | private seek (time: number) { | ||
380 | this.player.currentTime(time) | ||
381 | this.player.handleTechSeeked_() | ||
382 | } | ||
383 | |||
384 | private getAppropriateFile (averageDownloadSpeed?: number): VideoFile { | ||
385 | if (this.videoFiles === undefined) return undefined | ||
386 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
387 | |||
388 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
389 | if (files.length === 0) return undefined | ||
390 | |||
391 | // Don't change the torrent if the player ended | ||
392 | if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile | ||
393 | |||
394 | if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed() | ||
395 | |||
396 | // Limit resolution according to player height | ||
397 | const playerHeight = this.playerElement.offsetHeight | ||
398 | |||
399 | // We take the first resolution just above the player height | ||
400 | // Example: player height is 530px, we want the 720p file instead of 480p | ||
401 | let maxResolution = files[0].resolution.id | ||
402 | for (let i = files.length - 1; i >= 0; i--) { | ||
403 | const resolutionId = files[i].resolution.id | ||
404 | if (resolutionId !== 0 && resolutionId >= playerHeight) { | ||
405 | maxResolution = resolutionId | ||
406 | break | ||
407 | } | ||
408 | } | ||
409 | |||
410 | // Filter videos we can play according to our screen resolution and bandwidth | ||
411 | const filteredFiles = files.filter(f => f.resolution.id <= maxResolution) | ||
412 | .filter(f => { | ||
413 | const fileBitrate = (f.size / this.videoDuration) | ||
414 | let threshold = fileBitrate | ||
415 | |||
416 | // If this is for a higher resolution or an initial load: add a margin | ||
417 | if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) { | ||
418 | threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100) | ||
419 | } | ||
420 | |||
421 | return averageDownloadSpeed > threshold | ||
422 | }) | ||
423 | |||
424 | // If the download speed is too bad, return the lowest resolution we have | ||
425 | if (filteredFiles.length === 0) return videoFileMinByResolution(files) | ||
426 | |||
427 | return videoFileMaxByResolution(filteredFiles) | ||
428 | } | ||
429 | |||
430 | private getAndSaveActualDownloadSpeed () { | ||
431 | const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0) | ||
432 | const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length) | ||
433 | if (lastDownloadSpeeds.length === 0) return -1 | ||
434 | |||
435 | const sum = lastDownloadSpeeds.reduce((a, b) => a + b) | ||
436 | const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length) | ||
437 | |||
438 | // Save the average bandwidth for future use | ||
439 | saveAverageBandwidth(averageBandwidth) | ||
440 | |||
441 | return averageBandwidth | ||
442 | } | ||
443 | |||
444 | private initializePlayer () { | ||
445 | this.buildQualities() | ||
446 | |||
447 | if (this.videoFiles.length === 0) { | ||
448 | this.player.addClass('disabled') | ||
449 | return | ||
450 | } | ||
451 | |||
452 | if (this.autoplay !== false) { | ||
453 | this.player.posterImage.hide() | ||
454 | |||
455 | return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) | ||
456 | } | ||
457 | |||
458 | // Proxy first play | ||
459 | const oldPlay = this.player.play.bind(this.player); | ||
460 | (this.player as any).play = () => { | ||
461 | this.player.addClass('vjs-has-big-play-button-clicked') | ||
462 | this.player.play = oldPlay | ||
463 | |||
464 | this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime }) | ||
465 | } | ||
466 | } | ||
467 | |||
468 | private runAutoQualityScheduler () { | ||
469 | this.autoQualityInterval = setInterval(() => { | ||
470 | |||
471 | // Not initialized or in HTTP fallback | ||
472 | if (this.torrent === undefined || this.torrent === null) return | ||
473 | if (this.autoResolution === false) return | ||
474 | if (this.isAutoResolutionObservation === true) return | ||
475 | |||
476 | const file = this.getAppropriateFile() | ||
477 | let changeResolution = false | ||
478 | let changeResolutionDelay = 0 | ||
479 | |||
480 | // Lower resolution | ||
481 | if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) { | ||
482 | logger.info(`Downgrading automatically the resolution to: ${file.resolution.label}`) | ||
483 | changeResolution = true | ||
484 | } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution | ||
485 | logger.info(`Upgrading automatically the resolution to: ${file.resolution.label}`) | ||
486 | changeResolution = true | ||
487 | changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY | ||
488 | } | ||
489 | |||
490 | if (changeResolution === true) { | ||
491 | this.updateEngineResolution(file.resolution.id, changeResolutionDelay) | ||
492 | |||
493 | // Wait some seconds in observation of our new resolution | ||
494 | this.isAutoResolutionObservation = true | ||
495 | |||
496 | this.qualityObservationTimer = setTimeout(() => { | ||
497 | this.isAutoResolutionObservation = false | ||
498 | }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME) | ||
499 | } | ||
500 | }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER) | ||
501 | } | ||
502 | |||
503 | private isPlayerWaiting () { | ||
504 | return this.player?.hasClass('vjs-waiting') | ||
505 | } | ||
506 | |||
507 | private runTorrentInfoScheduler () { | ||
508 | this.torrentInfoInterval = setInterval(() => { | ||
509 | // Not initialized yet | ||
510 | if (this.torrent === undefined) return | ||
511 | |||
512 | // Http fallback | ||
513 | if (this.torrent === null) return this.player.trigger('p2pInfo', false) | ||
514 | |||
515 | // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too | ||
516 | if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed) | ||
517 | |||
518 | return this.player.trigger('p2pInfo', { | ||
519 | source: 'webtorrent', | ||
520 | http: { | ||
521 | downloadSpeed: 0, | ||
522 | downloaded: 0 | ||
523 | }, | ||
524 | p2p: { | ||
525 | downloadSpeed: this.torrent.downloadSpeed, | ||
526 | numPeers: this.torrent.numPeers, | ||
527 | uploadSpeed: this.torrent.uploadSpeed, | ||
528 | downloaded: this.torrent.downloaded, | ||
529 | uploaded: this.torrent.uploaded | ||
530 | }, | ||
531 | bandwidthEstimate: this.webtorrent.downloadSpeed | ||
532 | } as PlayerNetworkInfo) | ||
533 | }, this.CONSTANTS.INFO_SCHEDULER) | ||
534 | } | ||
535 | |||
536 | private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) { | ||
537 | const paused = this.player.paused() | ||
538 | |||
539 | this.disableAutoResolution() | ||
540 | |||
541 | this.flushVideoFile(this.currentVideoFile, true) | ||
542 | this.torrent = null | ||
543 | |||
544 | // Enable error display now this is our last fallback | ||
545 | this.player.one('error', () => this.player.peertube().displayFatalError()) | ||
546 | |||
547 | let httpUrl = this.currentVideoFile.fileUrl | ||
548 | |||
549 | if (this.videoFileToken) { | ||
550 | httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() }) | ||
551 | } | ||
552 | |||
553 | this.player.src = this.savePlayerSrcFunction | ||
554 | this.player.src(httpUrl) | ||
555 | |||
556 | this.selectAppropriateResolution(true) | ||
557 | |||
558 | // We changed the source, so reinit captions | ||
559 | this.player.trigger('sourcechange') | ||
560 | |||
561 | return this.tryToPlay(err => { | ||
562 | if (err && done) return done(err) | ||
563 | |||
564 | if (options.seek) this.seek(options.seek) | ||
565 | if (options.forcePlay === false && paused === true) this.player.pause() | ||
566 | |||
567 | if (done) return done() | ||
568 | }) | ||
569 | } | ||
570 | |||
571 | private handleError (err: Error | string) { | ||
572 | return this.player.trigger('customError', { err }) | ||
573 | } | ||
574 | |||
575 | private pickAverageVideoFile () { | ||
576 | if (this.videoFiles.length === 1) return this.videoFiles[0] | ||
577 | |||
578 | const files = this.videoFiles.filter(f => f.resolution.id !== 0) | ||
579 | return files[Math.floor(files.length / 2)] | ||
580 | } | ||
581 | |||
582 | private stopTorrent (torrent: WebTorrent.Torrent) { | ||
583 | torrent.pause() | ||
584 | // Pause does not remove actual peers (in particular the webseed peer) | ||
585 | torrent.removePeer((torrent as any)['ws']) | ||
586 | } | ||
587 | |||
588 | private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) { | ||
589 | this.destroyingFakeRenderer = false | ||
590 | |||
591 | const fakeVideoElem = document.createElement('video') | ||
592 | renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => { | ||
593 | this.fakeRenderer = renderer | ||
594 | |||
595 | // The renderer returns an error when we destroy it, so skip them | ||
596 | if (this.destroyingFakeRenderer === false && err) { | ||
597 | logger.error('Cannot render new torrent in fake video element.', err) | ||
598 | } | ||
599 | |||
600 | // Load the future file at the correct time (in delay MS - 2 seconds) | ||
601 | fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000) | ||
602 | }) | ||
603 | } | ||
604 | |||
605 | private destroyFakeRenderer () { | ||
606 | if (this.fakeRenderer) { | ||
607 | this.destroyingFakeRenderer = true | ||
608 | |||
609 | if (this.fakeRenderer.destroy) { | ||
610 | try { | ||
611 | this.fakeRenderer.destroy() | ||
612 | } catch (err) { | ||
613 | logger.info('Cannot destroy correctly fake renderer.', err) | ||
614 | } | ||
615 | } | ||
616 | this.fakeRenderer = undefined | ||
617 | } | ||
618 | } | ||
619 | |||
620 | private buildQualities () { | ||
621 | const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({ | ||
622 | id: file.resolution.id, | ||
623 | label: this.buildQualityLabel(file), | ||
624 | height: file.resolution.id, | ||
625 | selected: false, | ||
626 | selectCallback: () => this.changeQuality(file.resolution.id) | ||
627 | })) | ||
628 | |||
629 | resolutions.push({ | ||
630 | id: -1, | ||
631 | label: this.player.localize('Auto'), | ||
632 | selected: true, | ||
633 | selectCallback: () => this.changeQuality(-1) | ||
634 | }) | ||
635 | |||
636 | this.player.peertubeResolutions().add(resolutions) | ||
637 | } | ||
638 | |||
639 | private buildQualityLabel (file: VideoFile) { | ||
640 | let label = file.resolution.label | ||
641 | |||
642 | if (file.fps && file.fps >= 50) { | ||
643 | label += file.fps | ||
644 | } | ||
645 | |||
646 | return label | ||
647 | } | ||
648 | |||
649 | private selectAppropriateResolution (byEngine: boolean) { | ||
650 | const resolution = this.autoResolution | ||
651 | ? -1 | ||
652 | : this.getCurrentResolutionId() | ||
653 | |||
654 | const autoResolutionChosen = this.autoResolution | ||
655 | ? this.getCurrentResolutionId() | ||
656 | : undefined | ||
657 | |||
658 | this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine }) | ||
659 | } | ||
660 | } | ||
661 | |||
662 | videojs.registerPlugin('webtorrent', WebTorrentPlugin) | ||
663 | export { WebTorrentPlugin } | ||
diff --git a/client/src/assets/player/types/index.ts b/client/src/assets/player/types/index.ts index b73e0b3cb..4bf49f65c 100644 --- a/client/src/assets/player/types/index.ts +++ b/client/src/assets/player/types/index.ts | |||
@@ -1,2 +1,2 @@ | |||
1 | export * from './manager-options' | 1 | export * from './peertube-player-options' |
2 | export * from './peertube-videojs-typings' | 2 | export * from './peertube-videojs-typings' |
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/peertube-player-options.ts index a73341b4c..e1b8c7fab 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/peertube-player-options.ts | |||
@@ -1,101 +1,117 @@ | |||
1 | import { PluginsManager } from '@root-helpers/plugins-manager' | 1 | import { PluginsManager } from '@root-helpers/plugins-manager' |
2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' | 2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' |
3 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | ||
3 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' | 4 | import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings' |
4 | 5 | ||
5 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | 6 | export type PlayerMode = 'web-video' | 'p2p-media-loader' |
6 | 7 | ||
7 | export type WebtorrentOptions = { | 8 | export type PeerTubePlayerContructorOptions = { |
8 | videoFiles: VideoFile[] | 9 | playerElement: () => HTMLVideoElement |
9 | } | ||
10 | 10 | ||
11 | export type P2PMediaLoaderOptions = { | 11 | controls: boolean |
12 | playlistUrl: string | 12 | controlBar: boolean |
13 | segmentsSha256Url: string | ||
14 | trackerAnnounce: string[] | ||
15 | redundancyBaseUrls: string[] | ||
16 | videoFiles: VideoFile[] | ||
17 | } | ||
18 | 13 | ||
19 | export interface CustomizationOptions { | 14 | muted: boolean |
20 | startTime: number | string | 15 | loop: boolean |
21 | stopTime: number | string | ||
22 | 16 | ||
23 | controls?: boolean | 17 | peertubeLink: () => boolean |
24 | controlBar?: boolean | ||
25 | 18 | ||
26 | muted?: boolean | 19 | playbackRate?: number | string |
27 | loop?: boolean | ||
28 | subtitle?: string | ||
29 | resume?: string | ||
30 | 20 | ||
31 | peertubeLink: boolean | 21 | enableHotkeys: boolean |
22 | inactivityTimeout: number | ||
32 | 23 | ||
33 | playbackRate?: number | string | 24 | videoViewIntervalMs: number |
34 | } | ||
35 | 25 | ||
36 | export interface CommonOptions extends CustomizationOptions { | 26 | instanceName: string |
37 | playerElement: HTMLVideoElement | ||
38 | onPlayerElementChange: (element: HTMLVideoElement) => void | ||
39 | 27 | ||
40 | autoplay: boolean | 28 | theaterButton: boolean |
41 | forceAutoplay: boolean | ||
42 | 29 | ||
43 | p2pEnabled: boolean | 30 | authorizationHeader: () => string |
44 | 31 | ||
45 | nextVideo?: () => void | 32 | metricsUrl: string |
46 | hasNextVideo?: () => boolean | 33 | serverUrl: string |
47 | 34 | ||
48 | previousVideo?: () => void | 35 | errorNotifier: (message: string) => void |
49 | hasPreviousVideo?: () => boolean | ||
50 | 36 | ||
51 | playlist?: PlaylistPluginOptions | 37 | // Current web browser language |
38 | language: string | ||
52 | 39 | ||
53 | videoDuration: number | 40 | pluginsManager: PluginsManager |
54 | enableHotkeys: boolean | 41 | } |
55 | inactivityTimeout: number | ||
56 | poster: string | ||
57 | 42 | ||
58 | videoViewIntervalMs: number | 43 | export type PeerTubePlayerLoadOptions = { |
44 | mode: PlayerMode | ||
59 | 45 | ||
60 | instanceName: string | 46 | startTime?: number | string |
47 | stopTime?: number | string | ||
61 | 48 | ||
62 | theaterButton: boolean | 49 | autoplay: boolean |
63 | captions: boolean | 50 | forceAutoplay: boolean |
64 | 51 | ||
52 | poster: string | ||
53 | subtitle?: string | ||
65 | videoViewUrl: string | 54 | videoViewUrl: string |
66 | authorizationHeader?: () => string | ||
67 | |||
68 | metricsUrl: string | ||
69 | 55 | ||
70 | embedUrl: string | 56 | embedUrl: string |
71 | embedTitle: string | 57 | embedTitle: string |
72 | 58 | ||
73 | isLive: boolean | 59 | isLive: boolean |
60 | |||
74 | liveOptions?: { | 61 | liveOptions?: { |
75 | latencyMode: LiveVideoLatencyMode | 62 | latencyMode: LiveVideoLatencyMode |
76 | } | 63 | } |
77 | 64 | ||
78 | language?: string | ||
79 | |||
80 | videoCaptions: VideoJSCaption[] | 65 | videoCaptions: VideoJSCaption[] |
81 | storyboard: VideoJSStoryboard | 66 | storyboard: VideoJSStoryboard |
82 | 67 | ||
83 | videoUUID: string | 68 | videoUUID: string |
84 | videoShortUUID: string | 69 | videoShortUUID: string |
85 | 70 | ||
86 | serverUrl: string | 71 | duration: number |
72 | |||
87 | requiresUserAuth: boolean | 73 | requiresUserAuth: boolean |
88 | videoFileToken: () => string | 74 | videoFileToken: () => string |
89 | requiresPassword: boolean | 75 | requiresPassword: boolean |
90 | videoPassword: () => string | 76 | videoPassword: () => string |
91 | 77 | ||
92 | errorNotifier: (message: string) => void | 78 | nextVideo: { |
79 | enabled: boolean | ||
80 | getVideoTitle: () => string | ||
81 | handler?: () => void | ||
82 | displayControlBarButton: boolean | ||
83 | } | ||
84 | |||
85 | previousVideo: { | ||
86 | enabled: boolean | ||
87 | handler?: () => void | ||
88 | displayControlBarButton: boolean | ||
89 | } | ||
90 | |||
91 | upnext?: { | ||
92 | isEnabled: () => boolean | ||
93 | isSuspended: (player: videojs.VideoJsPlayer) => boolean | ||
94 | timeout: number | ||
95 | } | ||
96 | |||
97 | dock?: PeerTubeDockPluginOptions | ||
98 | |||
99 | playlist?: PlaylistPluginOptions | ||
100 | |||
101 | p2pEnabled: boolean | ||
102 | |||
103 | hls?: HLSOptions | ||
104 | webVideo?: WebVideoOptions | ||
93 | } | 105 | } |
94 | 106 | ||
95 | export type PeertubePlayerManagerOptions = { | 107 | export type WebVideoOptions = { |
96 | common: CommonOptions | 108 | videoFiles: VideoFile[] |
97 | webtorrent: WebtorrentOptions | 109 | } |
98 | p2pMediaLoader?: P2PMediaLoaderOptions | ||
99 | 110 | ||
100 | pluginsManager: PluginsManager | 111 | export type HLSOptions = { |
112 | playlistUrl: string | ||
113 | segmentsSha256Url: string | ||
114 | trackerAnnounce: string[] | ||
115 | redundancyBaseUrls: string[] | ||
116 | videoFiles: VideoFile[] | ||
101 | } | 117 | } |
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 30d2b287f..f10fc03a8 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts | |||
@@ -2,8 +2,11 @@ import { HlsConfig, Level } from 'hls.js' | |||
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' | 3 | import { Engine } from '@peertube/p2p-media-loader-hlsjs' |
4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' | 4 | import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' |
5 | import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | 5 | import { BezelsPlugin } from '../shared/bezels/bezels-plugin' |
6 | import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin' | 6 | import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin' |
7 | import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' | ||
8 | import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin' | ||
9 | import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin' | ||
7 | import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' | 10 | import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' |
8 | import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' | 11 | import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' |
9 | import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' | 12 | import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' |
@@ -12,9 +15,10 @@ import { PlaylistPlugin } from '../shared/playlist/playlist-plugin' | |||
12 | import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' | 15 | import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' |
13 | import { StatsCardOptions } from '../shared/stats/stats-card' | 16 | import { StatsCardOptions } from '../shared/stats/stats-card' |
14 | import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' | 17 | import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' |
15 | import { EndCardOptions } from '../shared/upnext/end-card' | 18 | import { UpNextPlugin } from '../shared/upnext/upnext-plugin' |
16 | import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' | 19 | import { WebVideoPlugin } from '../shared/web-video/web-video-plugin' |
17 | import { PlayerMode } from './manager-options' | 20 | import { PlayerMode } from './peertube-player-options' |
21 | import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator' | ||
18 | 22 | ||
19 | declare module 'video.js' { | 23 | declare module 'video.js' { |
20 | 24 | ||
@@ -31,35 +35,36 @@ declare module 'video.js' { | |||
31 | 35 | ||
32 | handleTechSeeked_ (): void | 36 | handleTechSeeked_ (): void |
33 | 37 | ||
38 | textTracks (): TextTrackList & { | ||
39 | tracks_: (TextTrack & { id: string, label: string, src: string })[] | ||
40 | } | ||
41 | |||
34 | // Plugins | 42 | // Plugins |
35 | 43 | ||
36 | peertube (): PeerTubePlugin | 44 | peertube (): PeerTubePlugin |
37 | 45 | ||
38 | webtorrent (): WebTorrentPlugin | 46 | webVideo (options?: any): WebVideoPlugin |
39 | 47 | ||
40 | p2pMediaLoader (): P2pMediaLoaderPlugin | 48 | p2pMediaLoader (options?: any): P2pMediaLoaderPlugin |
49 | hlsjs (options?: any): any | ||
41 | 50 | ||
42 | peertubeResolutions (): PeerTubeResolutionsPlugin | 51 | peertubeResolutions (): PeerTubeResolutionsPlugin |
43 | 52 | ||
44 | contextmenuUI (options: any): any | 53 | contextmenuUI (options?: any): any |
45 | 54 | ||
46 | bezels (): void | 55 | bezels (): BezelsPlugin |
47 | peertubeMobile (): void | 56 | peertubeMobile (): PeerTubeMobilePlugin |
48 | peerTubeHotkeysPlugin (options?: HotkeysOptions): void | 57 | peerTubeHotkeysPlugin (options?: HotkeysOptions): PeerTubeHotkeysPlugin |
49 | 58 | ||
50 | stats (options?: StatsCardOptions): StatsForNerdsPlugin | 59 | stats (options?: StatsCardOptions): StatsForNerdsPlugin |
51 | 60 | ||
52 | storyboard (options: StoryboardOptions): void | 61 | storyboard (options?: StoryboardOptions): StoryboardPlugin |
53 | |||
54 | textTracks (): TextTrackList & { | ||
55 | tracks_: (TextTrack & { id: string, label: string, src: string })[] | ||
56 | } | ||
57 | 62 | ||
58 | peertubeDock (options: PeerTubeDockPluginOptions): void | 63 | peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin |
59 | 64 | ||
60 | upnext (options: Partial<EndCardOptions>): void | 65 | upnext (options?: UpNextPluginOptions): UpNextPlugin |
61 | 66 | ||
62 | playlist (): PlaylistPlugin | 67 | playlist (options?: PlaylistPluginOptions): PlaylistPlugin |
63 | } | 68 | } |
64 | } | 69 | } |
65 | 70 | ||
@@ -99,32 +104,28 @@ type VideoJSStoryboard = { | |||
99 | } | 104 | } |
100 | 105 | ||
101 | type PeerTubePluginOptions = { | 106 | type PeerTubePluginOptions = { |
102 | mode: PlayerMode | 107 | hasAutoplay: () => videojs.Autoplay |
103 | 108 | ||
104 | autoplay: videojs.Autoplay | 109 | videoViewUrl: () => string |
105 | videoDuration: number | 110 | videoViewIntervalMs: number |
106 | 111 | ||
107 | videoViewUrl: string | ||
108 | authorizationHeader?: () => string | 112 | authorizationHeader?: () => string |
109 | 113 | ||
110 | subtitle?: string | 114 | videoDuration: () => number |
111 | 115 | ||
112 | videoCaptions: VideoJSCaption[] | 116 | startTime: () => number | string |
113 | 117 | stopTime: () => number | string | |
114 | startTime: number | string | ||
115 | stopTime: number | string | ||
116 | 118 | ||
117 | isLive: boolean | 119 | videoCaptions: () => VideoJSCaption[] |
118 | 120 | isLive: () => boolean | |
119 | videoUUID: string | 121 | videoUUID: () => string |
120 | 122 | subtitle: () => string | |
121 | videoViewIntervalMs: number | ||
122 | } | 123 | } |
123 | 124 | ||
124 | type MetricsPluginOptions = { | 125 | type MetricsPluginOptions = { |
125 | mode: PlayerMode | 126 | mode: () => PlayerMode |
126 | metricsUrl: string | 127 | metricsUrl: () => string |
127 | videoUUID: string | 128 | videoUUID: () => string |
128 | } | 129 | } |
129 | 130 | ||
130 | type StoryboardOptions = { | 131 | type StoryboardOptions = { |
@@ -144,37 +145,36 @@ type PlaylistPluginOptions = { | |||
144 | onItemClicked: (element: VideoPlaylistElement) => void | 145 | onItemClicked: (element: VideoPlaylistElement) => void |
145 | } | 146 | } |
146 | 147 | ||
148 | type UpNextPluginOptions = { | ||
149 | timeout: number | ||
150 | |||
151 | next: () => void | ||
152 | getTitle: () => string | ||
153 | isDisplayed: () => boolean | ||
154 | isSuspended: () => boolean | ||
155 | } | ||
156 | |||
147 | type NextPreviousVideoButtonOptions = { | 157 | type NextPreviousVideoButtonOptions = { |
148 | type: 'next' | 'previous' | 158 | type: 'next' | 'previous' |
149 | handler: () => void | 159 | handler?: () => void |
160 | isDisplayed: () => boolean | ||
150 | isDisabled: () => boolean | 161 | isDisabled: () => boolean |
151 | } | 162 | } |
152 | 163 | ||
153 | type PeerTubeLinkButtonOptions = { | 164 | type PeerTubeLinkButtonOptions = { |
154 | shortUUID: string | 165 | isDisplayed: () => boolean |
166 | shortUUID: () => string | ||
155 | instanceName: string | 167 | instanceName: string |
156 | } | 168 | } |
157 | 169 | ||
158 | type PeerTubeP2PInfoButtonOptions = { | 170 | type TheaterButtonOptions = { |
159 | p2pEnabled: boolean | 171 | isDisplayed: () => boolean |
160 | } | 172 | } |
161 | 173 | ||
162 | type WebtorrentPluginOptions = { | 174 | type WebVideoPluginOptions = { |
163 | playerElement: HTMLVideoElement | ||
164 | |||
165 | autoplay: videojs.Autoplay | ||
166 | videoDuration: number | ||
167 | |||
168 | videoFiles: VideoFile[] | 175 | videoFiles: VideoFile[] |
169 | |||
170 | startTime: number | string | 176 | startTime: number | string |
171 | |||
172 | playerRefusedP2P: boolean | ||
173 | |||
174 | requiresUserAuth: boolean | ||
175 | videoFileToken: () => string | 177 | videoFileToken: () => string |
176 | |||
177 | buildWebSeedUrls: (file: VideoFile) => string[] | ||
178 | } | 178 | } |
179 | 179 | ||
180 | type P2PMediaLoaderPluginOptions = { | 180 | type P2PMediaLoaderPluginOptions = { |
@@ -182,9 +182,8 @@ type P2PMediaLoaderPluginOptions = { | |||
182 | type: string | 182 | type: string |
183 | src: string | 183 | src: string |
184 | 184 | ||
185 | startTime: number | string | ||
186 | |||
187 | loader: P2PMediaLoader | 185 | loader: P2PMediaLoader |
186 | segmentValidator: SegmentValidator | ||
188 | 187 | ||
189 | requiresUserAuth: boolean | 188 | requiresUserAuth: boolean |
190 | videoFileToken: () => string | 189 | videoFileToken: () => string |
@@ -192,6 +191,8 @@ type P2PMediaLoaderPluginOptions = { | |||
192 | 191 | ||
193 | export type P2PMediaLoader = { | 192 | export type P2PMediaLoader = { |
194 | getEngine(): Engine | 193 | getEngine(): Engine |
194 | |||
195 | destroy: () => void | ||
195 | } | 196 | } |
196 | 197 | ||
197 | type VideoJSPluginOptions = { | 198 | type VideoJSPluginOptions = { |
@@ -200,7 +201,7 @@ type VideoJSPluginOptions = { | |||
200 | peertube: PeerTubePluginOptions | 201 | peertube: PeerTubePluginOptions |
201 | metrics: MetricsPluginOptions | 202 | metrics: MetricsPluginOptions |
202 | 203 | ||
203 | webtorrent?: WebtorrentPluginOptions | 204 | webVideo?: WebVideoPluginOptions |
204 | 205 | ||
205 | p2pMediaLoader?: P2PMediaLoaderPluginOptions | 206 | p2pMediaLoader?: P2PMediaLoaderPluginOptions |
206 | } | 207 | } |
@@ -227,14 +228,14 @@ type AutoResolutionUpdateData = { | |||
227 | } | 228 | } |
228 | 229 | ||
229 | type PlayerNetworkInfo = { | 230 | type PlayerNetworkInfo = { |
230 | source: 'webtorrent' | 'p2p-media-loader' | 231 | source: 'web-video' | 'p2p-media-loader' |
231 | 232 | ||
232 | http: { | 233 | http: { |
233 | downloadSpeed: number | 234 | downloadSpeed?: number |
234 | downloaded: number | 235 | downloaded: number |
235 | } | 236 | } |
236 | 237 | ||
237 | p2p: { | 238 | p2p?: { |
238 | downloadSpeed: number | 239 | downloadSpeed: number |
239 | uploadSpeed: number | 240 | uploadSpeed: number |
240 | downloaded: number | 241 | downloaded: number |
@@ -243,7 +244,7 @@ type PlayerNetworkInfo = { | |||
243 | } | 244 | } |
244 | 245 | ||
245 | // In bytes | 246 | // In bytes |
246 | bandwidthEstimate: number | 247 | bandwidthEstimate?: number |
247 | } | 248 | } |
248 | 249 | ||
249 | type PlaylistItemOptions = { | 250 | type PlaylistItemOptions = { |
@@ -254,6 +255,7 @@ type PlaylistItemOptions = { | |||
254 | 255 | ||
255 | export { | 256 | export { |
256 | PlayerNetworkInfo, | 257 | PlayerNetworkInfo, |
258 | TheaterButtonOptions, | ||
257 | VideoJSStoryboard, | 259 | VideoJSStoryboard, |
258 | PlaylistItemOptions, | 260 | PlaylistItemOptions, |
259 | NextPreviousVideoButtonOptions, | 261 | NextPreviousVideoButtonOptions, |
@@ -263,12 +265,12 @@ export { | |||
263 | MetricsPluginOptions, | 265 | MetricsPluginOptions, |
264 | VideoJSCaption, | 266 | VideoJSCaption, |
265 | PeerTubePluginOptions, | 267 | PeerTubePluginOptions, |
266 | WebtorrentPluginOptions, | 268 | WebVideoPluginOptions, |
267 | P2PMediaLoaderPluginOptions, | 269 | P2PMediaLoaderPluginOptions, |
268 | PeerTubeResolution, | 270 | PeerTubeResolution, |
269 | VideoJSPluginOptions, | 271 | VideoJSPluginOptions, |
272 | UpNextPluginOptions, | ||
270 | LoadedQualityData, | 273 | LoadedQualityData, |
271 | StoryboardOptions, | 274 | StoryboardOptions, |
272 | PeerTubeLinkButtonOptions, | 275 | PeerTubeLinkButtonOptions |
273 | PeerTubeP2PInfoButtonOptions | ||
274 | } | 276 | } |