]>
Commit | Line | Data |
---|---|---|
aa8b6df4 C |
1 | // Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher |
2 | ||
63c4db6d | 3 | import * as videojs from 'video.js' |
aa8b6df4 | 4 | import * as WebTorrent from 'webtorrent' |
b6827820 | 5 | import { VideoFile } from '../../../../shared/models/videos/video.model' |
aa8b6df4 | 6 | import { renderVideo } from './video-renderer' |
be6a4802 | 7 | |
339632b4 C |
8 | declare module 'video.js' { |
9 | interface Player { | |
10 | peertube (): PeerTubePlugin | |
11 | } | |
12 | } | |
13 | ||
a22bfc3e | 14 | interface VideoJSComponentInterface { |
339632b4 | 15 | _player: videojs.Player |
a22bfc3e | 16 | |
339632b4 | 17 | new (player: videojs.Player, options?: any) |
a22bfc3e C |
18 | |
19 | registerComponent (name: string, obj: any) | |
20 | } | |
21 | ||
a22bfc3e C |
22 | type PeertubePluginOptions = { |
23 | videoFiles: VideoFile[] | |
24 | playerElement: HTMLVideoElement | |
8cac1b64 | 25 | videoViewUrl: string |
3bcfff7f | 26 | videoDuration: number |
a22bfc3e C |
27 | } |
28 | ||
be6a4802 C |
29 | // https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts |
30 | // Don't import all Angular stuff, just copy the code with shame | |
31 | const dictionaryBytes: Array<{max: number, type: string}> = [ | |
32 | { max: 1024, type: 'B' }, | |
33 | { max: 1048576, type: 'KB' }, | |
34 | { max: 1073741824, type: 'MB' }, | |
35 | { max: 1.0995116e12, type: 'GB' } | |
36 | ] | |
37 | function bytes (value) { | |
38 | const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1] | |
39 | const calc = Math.floor(value / (format.max / 1024)).toString() | |
40 | ||
41 | return [ calc, format.type ] | |
42 | } | |
aa8b6df4 C |
43 | |
44 | // videojs typings don't have some method we need | |
45 | const videojsUntyped = videojs as any | |
46 | const webtorrent = new WebTorrent({ dht: false }) | |
47 | ||
a22bfc3e C |
48 | const MenuItem: VideoJSComponentInterface = videojsUntyped.getComponent('MenuItem') |
49 | class ResolutionMenuItem extends MenuItem { | |
50 | ||
339632b4 | 51 | constructor (player: videojs.Player, options) { |
aa8b6df4 | 52 | options.selectable = true |
a22bfc3e | 53 | super(player, options) |
aa8b6df4 | 54 | |
a22bfc3e | 55 | const currentResolution = this.player_.peertube().getCurrentResolution() |
aa8b6df4 | 56 | this.selected(this.options_.id === currentResolution) |
a22bfc3e | 57 | } |
aa8b6df4 | 58 | |
a22bfc3e | 59 | handleClick (event) { |
531ab5b6 C |
60 | super.handleClick(event) |
61 | ||
a22bfc3e | 62 | this.player_.peertube().updateResolution(this.options_.id) |
aa8b6df4 | 63 | } |
a22bfc3e | 64 | } |
aa8b6df4 C |
65 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) |
66 | ||
a22bfc3e C |
67 | const MenuButton: VideoJSComponentInterface = videojsUntyped.getComponent('MenuButton') |
68 | class ResolutionMenuButton extends MenuButton { | |
69 | label: HTMLElement | |
70 | ||
339632b4 | 71 | constructor (player: videojs.Player, options) { |
aa8b6df4 | 72 | options.label = 'Quality' |
a22bfc3e C |
73 | super(player, options) |
74 | ||
75 | this.label = document.createElement('span') | |
aa8b6df4 | 76 | |
aa8b6df4 C |
77 | this.el().setAttribute('aria-label', 'Quality') |
78 | this.controlText('Quality') | |
79 | ||
80 | videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') | |
81 | this.el().appendChild(this.label) | |
82 | ||
a22bfc3e C |
83 | player.peertube().on('videoFileUpdate', () => this.update()) |
84 | } | |
aa8b6df4 | 85 | |
a22bfc3e | 86 | createItems () { |
aa8b6df4 | 87 | const menuItems = [] |
a22bfc3e | 88 | for (const videoFile of this.player_.peertube().videoFiles) { |
aa8b6df4 C |
89 | menuItems.push(new ResolutionMenuItem( |
90 | this.player_, | |
91 | { | |
92 | id: videoFile.resolution, | |
93 | label: videoFile.resolutionLabel, | |
94 | src: videoFile.magnetUri, | |
95 | selected: videoFile.resolution === this.currentSelection | |
96 | }) | |
97 | ) | |
98 | } | |
99 | ||
100 | return menuItems | |
a22bfc3e C |
101 | } |
102 | ||
103 | update () { | |
104 | if (!this.label) return | |
aa8b6df4 | 105 | |
a22bfc3e | 106 | this.label.innerHTML = this.player_.peertube().getCurrentResolutionLabel() |
be6a4802 | 107 | this.hide() |
a22bfc3e C |
108 | return super.update() |
109 | } | |
110 | ||
111 | buildCSSClass () { | |
112 | return super.buildCSSClass() + ' vjs-resolution-button' | |
113 | } | |
a22bfc3e | 114 | } |
aa8b6df4 C |
115 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) |
116 | ||
a22bfc3e | 117 | const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button') |
3ec8dc09 | 118 | class PeerTubeLinkButton extends Button { |
aa8b6df4 | 119 | |
a22bfc3e | 120 | createEl () { |
aa8b6df4 C |
121 | const link = document.createElement('a') |
122 | link.href = window.location.href.replace('embed', 'watch') | |
123 | link.innerHTML = 'PeerTube' | |
124 | link.title = 'Go to the video page' | |
125 | link.className = 'vjs-peertube-link' | |
126 | link.target = '_blank' | |
127 | ||
128 | return link | |
a22bfc3e | 129 | } |
aa8b6df4 | 130 | |
a22bfc3e | 131 | handleClick () { |
be6a4802 | 132 | this.player_.pause() |
aa8b6df4 | 133 | } |
a22bfc3e | 134 | } |
3ec8dc09 | 135 | Button.registerComponent('PeerTubeLinkButton', PeerTubeLinkButton) |
be6a4802 | 136 | |
a22bfc3e C |
137 | class WebTorrentButton extends Button { |
138 | createEl () { | |
be6a4802 | 139 | const div = document.createElement('div') |
bf5685f0 C |
140 | const subDivWebtorrent = document.createElement('div') |
141 | div.appendChild(subDivWebtorrent) | |
be6a4802 C |
142 | |
143 | const downloadIcon = document.createElement('span') | |
144 | downloadIcon.classList.add('icon', 'icon-download') | |
bf5685f0 | 145 | subDivWebtorrent.appendChild(downloadIcon) |
be6a4802 C |
146 | |
147 | const downloadSpeedText = document.createElement('span') | |
148 | downloadSpeedText.classList.add('download-speed-text') | |
149 | const downloadSpeedNumber = document.createElement('span') | |
150 | downloadSpeedNumber.classList.add('download-speed-number') | |
151 | const downloadSpeedUnit = document.createElement('span') | |
152 | downloadSpeedText.appendChild(downloadSpeedNumber) | |
153 | downloadSpeedText.appendChild(downloadSpeedUnit) | |
bf5685f0 | 154 | subDivWebtorrent.appendChild(downloadSpeedText) |
be6a4802 C |
155 | |
156 | const uploadIcon = document.createElement('span') | |
157 | uploadIcon.classList.add('icon', 'icon-upload') | |
bf5685f0 | 158 | subDivWebtorrent.appendChild(uploadIcon) |
be6a4802 C |
159 | |
160 | const uploadSpeedText = document.createElement('span') | |
161 | uploadSpeedText.classList.add('upload-speed-text') | |
162 | const uploadSpeedNumber = document.createElement('span') | |
163 | uploadSpeedNumber.classList.add('upload-speed-number') | |
164 | const uploadSpeedUnit = document.createElement('span') | |
165 | uploadSpeedText.appendChild(uploadSpeedNumber) | |
166 | uploadSpeedText.appendChild(uploadSpeedUnit) | |
bf5685f0 | 167 | subDivWebtorrent.appendChild(uploadSpeedText) |
be6a4802 C |
168 | |
169 | const peersText = document.createElement('span') | |
be6a4802 C |
170 | peersText.classList.add('peers-text') |
171 | const peersNumber = document.createElement('span') | |
172 | peersNumber.classList.add('peers-number') | |
bf5685f0 C |
173 | subDivWebtorrent.appendChild(peersNumber) |
174 | subDivWebtorrent.appendChild(peersText) | |
be6a4802 | 175 | |
bf5685f0 | 176 | div.className = 'vjs-peertube' |
be6a4802 | 177 | // Hide the stats before we get the info |
bf5685f0 C |
178 | subDivWebtorrent.className = 'vjs-peertube-hidden' |
179 | ||
180 | const subDivHttp = document.createElement('div') | |
181 | subDivHttp.className = 'vjs-peertube-hidden' | |
182 | const subDivHttpText = document.createElement('span') | |
183 | subDivHttpText.classList.add('peers-number') | |
184 | subDivHttpText.textContent = 'HTTP' | |
185 | const subDivFallbackText = document.createElement('span') | |
186 | subDivFallbackText.classList.add('peers-text') | |
187 | subDivFallbackText.textContent = ' fallback' | |
188 | ||
189 | subDivHttp.appendChild(subDivHttpText) | |
190 | subDivHttp.appendChild(subDivFallbackText) | |
191 | div.appendChild(subDivHttp) | |
be6a4802 | 192 | |
a22bfc3e | 193 | this.player_.peertube().on('torrentInfo', (event, data) => { |
bf5685f0 C |
194 | // We are in HTTP fallback |
195 | if (!data) { | |
196 | subDivHttp.className = 'vjs-peertube-displayed' | |
197 | subDivWebtorrent.className = 'vjs-peertube-hidden' | |
198 | ||
199 | return | |
200 | } | |
201 | ||
be6a4802 C |
202 | const downloadSpeed = bytes(data.downloadSpeed) |
203 | const uploadSpeed = bytes(data.uploadSpeed) | |
204 | const numPeers = data.numPeers | |
205 | ||
bf5685f0 C |
206 | downloadSpeedNumber.textContent = downloadSpeed[ 0 ] |
207 | downloadSpeedUnit.textContent = ' ' + downloadSpeed[ 1 ] | |
be6a4802 | 208 | |
bf5685f0 C |
209 | uploadSpeedNumber.textContent = uploadSpeed[ 0 ] |
210 | uploadSpeedUnit.textContent = ' ' + uploadSpeed[ 1 ] | |
be6a4802 C |
211 | |
212 | peersNumber.textContent = numPeers | |
bf5685f0 | 213 | peersText.textContent = ' peers' |
be6a4802 | 214 | |
bf5685f0 C |
215 | subDivHttp.className = 'vjs-peertube-hidden' |
216 | subDivWebtorrent.className = 'vjs-peertube-displayed' | |
be6a4802 C |
217 | }) |
218 | ||
219 | return div | |
220 | } | |
aa8b6df4 | 221 | } |
a22bfc3e C |
222 | Button.registerComponent('WebTorrentButton', WebTorrentButton) |
223 | ||
224 | const Plugin: VideoJSComponentInterface = videojsUntyped.getPlugin('plugin') | |
225 | class PeerTubePlugin extends Plugin { | |
226 | private player: any | |
227 | private currentVideoFile: VideoFile | |
228 | private playerElement: HTMLVideoElement | |
229 | private videoFiles: VideoFile[] | |
230 | private torrent: WebTorrent.Torrent | |
481d3596 | 231 | private autoplay = false |
8cac1b64 | 232 | private videoViewUrl: string |
3bcfff7f | 233 | private videoDuration: number |
8cac1b64 | 234 | private videoViewInterval |
3bcfff7f | 235 | private torrentInfoInterval |
bf5685f0 | 236 | private savePlayerSrcFunction: Function |
a22bfc3e | 237 | |
339632b4 | 238 | constructor (player: videojs.Player, options: PeertubePluginOptions) { |
a22bfc3e C |
239 | super(player, options) |
240 | ||
481d3596 C |
241 | // Fix canplay event on google chrome by disabling default videojs autoplay |
242 | this.autoplay = this.player.options_.autoplay | |
243 | this.player.options_.autoplay = false | |
244 | ||
a22bfc3e | 245 | this.videoFiles = options.videoFiles |
8cac1b64 | 246 | this.videoViewUrl = options.videoViewUrl |
3bcfff7f | 247 | this.videoDuration = options.videoDuration |
a22bfc3e | 248 | |
bf5685f0 | 249 | this.savePlayerSrcFunction = this.player.src |
a22bfc3e C |
250 | // Hack to "simulate" src link in video.js >= 6 |
251 | // Without this, we can't play the video after pausing it | |
252 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | |
bf5685f0 | 253 | this.player.src = () => true |
a22bfc3e C |
254 | |
255 | this.playerElement = options.playerElement | |
256 | ||
257 | this.player.ready(() => { | |
258 | this.initializePlayer(options) | |
259 | this.runTorrentInfoScheduler() | |
3bcfff7f | 260 | this.runViewAdd() |
a22bfc3e C |
261 | }) |
262 | } | |
263 | ||
264 | dispose () { | |
3bcfff7f C |
265 | clearInterval(this.videoViewInterval) |
266 | clearInterval(this.torrentInfoInterval) | |
267 | ||
a22bfc3e C |
268 | // Don't need to destroy renderer, video player will be destroyed |
269 | this.flushVideoFile(this.currentVideoFile, false) | |
aa8b6df4 C |
270 | } |
271 | ||
a22bfc3e C |
272 | getCurrentResolution () { |
273 | return this.currentVideoFile ? this.currentVideoFile.resolution : -1 | |
aa8b6df4 C |
274 | } |
275 | ||
a22bfc3e C |
276 | getCurrentResolutionLabel () { |
277 | return this.currentVideoFile ? this.currentVideoFile.resolutionLabel : '' | |
aa8b6df4 C |
278 | } |
279 | ||
a22bfc3e | 280 | updateVideoFile (videoFile?: VideoFile, done?: () => void) { |
aa8b6df4 C |
281 | if (done === undefined) { |
282 | done = () => { /* empty */ } | |
283 | } | |
284 | ||
285 | // Pick the first one | |
286 | if (videoFile === undefined) { | |
a22bfc3e | 287 | videoFile = this.videoFiles[0] |
aa8b6df4 C |
288 | } |
289 | ||
290 | // Don't add the same video file once again | |
a22bfc3e | 291 | if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) { |
aa8b6df4 C |
292 | return |
293 | } | |
294 | ||
bf5685f0 C |
295 | // Do not display error to user because we will have multiple fallbacks |
296 | this.disableErrorDisplay() | |
1198a08c | 297 | |
bf5685f0 | 298 | this.player.src = () => true |
1198a08c | 299 | this.player.playbackRate(1) |
bf5685f0 | 300 | |
a22bfc3e C |
301 | const previousVideoFile = this.currentVideoFile |
302 | this.currentVideoFile = videoFile | |
aa8b6df4 C |
303 | |
304 | console.log('Adding ' + videoFile.magnetUri + '.') | |
a22bfc3e | 305 | this.torrent = webtorrent.add(videoFile.magnetUri, torrent => { |
aa8b6df4 C |
306 | console.log('Added ' + videoFile.magnetUri + '.') |
307 | ||
308 | this.flushVideoFile(previousVideoFile) | |
309 | ||
310 | const options = { autoplay: true, controls: true } | |
a22bfc3e | 311 | renderVideo(torrent.files[0], this.playerElement, options,(err, renderer) => { |
bf5685f0 | 312 | if (err) return this.fallbackToHttp() |
aa8b6df4 C |
313 | |
314 | this.renderer = renderer | |
3bcfff7f C |
315 | if (!this.player.paused()) { |
316 | const playPromise = this.player.play() | |
317 | if (playPromise !== undefined) return playPromise.then(done) | |
318 | ||
319 | return done() | |
320 | } | |
321 | ||
322 | return done() | |
aa8b6df4 C |
323 | }) |
324 | }) | |
325 | ||
a22bfc3e C |
326 | this.torrent.on('error', err => this.handleError(err)) |
327 | this.torrent.on('warning', (err: any) => { | |
a96aed15 | 328 | // We don't support HTTP tracker but we don't care -> we use the web socket tracker |
531ab5b6 | 329 | if (err.message.indexOf('Unsupported tracker protocol') !== -1) return |
7dbdc3ba C |
330 | // Users don't care about issues with WebRTC, but developers do so log it in the console |
331 | if (err.message.indexOf('Ice connection failed') !== -1) { | |
332 | console.error(err) | |
333 | return | |
334 | } | |
a96aed15 | 335 | |
a22bfc3e | 336 | return this.handleError(err) |
a96aed15 | 337 | }) |
aa8b6df4 | 338 | |
a22bfc3e | 339 | this.trigger('videoFileUpdate') |
aa8b6df4 C |
340 | } |
341 | ||
a22bfc3e | 342 | updateResolution (resolution) { |
aa8b6df4 | 343 | // Remember player state |
a22bfc3e C |
344 | const currentTime = this.player.currentTime() |
345 | const isPaused = this.player.paused() | |
aa8b6df4 | 346 | |
531ab5b6 C |
347 | // Remove poster to have black background |
348 | this.playerElement.poster = '' | |
349 | ||
aa8b6df4 | 350 | // Hide bigPlayButton |
8fa5653a | 351 | if (!isPaused) { |
a22bfc3e | 352 | this.player.bigPlayButton.hide() |
aa8b6df4 C |
353 | } |
354 | ||
a22bfc3e C |
355 | const newVideoFile = this.videoFiles.find(f => f.resolution === resolution) |
356 | this.updateVideoFile(newVideoFile, () => { | |
357 | this.player.currentTime(currentTime) | |
358 | this.player.handleTechSeeked_() | |
aa8b6df4 C |
359 | }) |
360 | } | |
361 | ||
a22bfc3e | 362 | flushVideoFile (videoFile: VideoFile, destroyRenderer = true) { |
aa8b6df4 | 363 | if (videoFile !== undefined && webtorrent.get(videoFile.magnetUri)) { |
bf5685f0 C |
364 | if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy() |
365 | ||
aa8b6df4 | 366 | webtorrent.remove(videoFile.magnetUri) |
a22bfc3e | 367 | console.log('Removed ' + videoFile.magnetUri) |
aa8b6df4 C |
368 | } |
369 | } | |
370 | ||
3bcfff7f | 371 | setVideoFiles (files: VideoFile[], videoViewUrl: string, videoDuration: number) { |
8cac1b64 | 372 | this.videoViewUrl = videoViewUrl |
3bcfff7f | 373 | this.videoDuration = videoDuration |
a22bfc3e | 374 | this.videoFiles = files |
ed9f9f5f | 375 | |
8cac1b64 | 376 | // Re run view add for the new video |
3bcfff7f | 377 | this.runViewAdd() |
a22bfc3e | 378 | this.updateVideoFile(undefined, () => this.player.play()) |
ed9f9f5f C |
379 | } |
380 | ||
a22bfc3e | 381 | private initializePlayer (options: PeertubePluginOptions) { |
481d3596 C |
382 | if (this.autoplay === true) { |
383 | this.updateVideoFile(undefined, () => this.player.play()) | |
aa8b6df4 | 384 | } else { |
a22bfc3e | 385 | this.player.one('play', () => { |
481d3596 C |
386 | this.player.pause() |
387 | this.updateVideoFile(undefined, () => this.player.play()) | |
4dd551a0 | 388 | }) |
aa8b6df4 | 389 | } |
a22bfc3e | 390 | } |
aa8b6df4 | 391 | |
a22bfc3e | 392 | private runTorrentInfoScheduler () { |
3bcfff7f | 393 | this.torrentInfoInterval = setInterval(() => { |
bf5685f0 C |
394 | // Not initialized yet |
395 | if (this.torrent === undefined) return | |
396 | ||
397 | // Http fallback | |
398 | if (this.torrent === null) return this.trigger('torrentInfo', false) | |
399 | ||
400 | return this.trigger('torrentInfo', { | |
401 | downloadSpeed: this.torrent.downloadSpeed, | |
402 | numPeers: this.torrent.numPeers, | |
403 | uploadSpeed: this.torrent.uploadSpeed | |
404 | }) | |
aa8b6df4 | 405 | }, 1000) |
a22bfc3e | 406 | } |
aa8b6df4 | 407 | |
8cac1b64 C |
408 | private runViewAdd () { |
409 | this.clearVideoViewInterval() | |
410 | ||
411 | // After 30 seconds (or 3/4 of the video), add a view to the video | |
412 | let minSecondsToView = 30 | |
413 | ||
3bcfff7f | 414 | if (this.videoDuration < minSecondsToView) minSecondsToView = (this.videoDuration * 3) / 4 |
8cac1b64 C |
415 | |
416 | let secondsViewed = 0 | |
417 | this.videoViewInterval = setInterval(() => { | |
418 | if (this.player && !this.player.paused()) { | |
419 | secondsViewed += 1 | |
420 | ||
421 | if (secondsViewed > minSecondsToView) { | |
422 | this.clearVideoViewInterval() | |
423 | ||
424 | this.addViewToVideo().catch(err => console.error(err)) | |
425 | } | |
426 | } | |
427 | }, 1000) | |
428 | } | |
429 | ||
430 | private clearVideoViewInterval () { | |
431 | if (this.videoViewInterval !== undefined) { | |
432 | clearInterval(this.videoViewInterval) | |
433 | this.videoViewInterval = undefined | |
434 | } | |
435 | } | |
436 | ||
437 | private addViewToVideo () { | |
438 | return fetch(this.videoViewUrl, { method: 'POST' }) | |
439 | } | |
440 | ||
bf5685f0 C |
441 | private fallbackToHttp () { |
442 | this.flushVideoFile(this.currentVideoFile, true) | |
443 | this.torrent = null | |
444 | ||
445 | // Enable error display now this is our last fallback | |
446 | this.player.one('error', () => this.enableErrorDisplay()) | |
447 | ||
448 | const httpUrl = this.currentVideoFile.fileUrl | |
449 | this.player.src = this.savePlayerSrcFunction | |
450 | this.player.src(httpUrl) | |
451 | this.player.play() | |
452 | } | |
453 | ||
a22bfc3e C |
454 | private handleError (err: Error | string) { |
455 | return this.player.trigger('customError', { err }) | |
aa8b6df4 | 456 | } |
bf5685f0 C |
457 | |
458 | private enableErrorDisplay () { | |
459 | this.player.addClass('vjs-error-display-enabled') | |
460 | } | |
461 | ||
462 | private disableErrorDisplay () { | |
463 | this.player.removeClass('vjs-error-display-enabled') | |
464 | } | |
aa8b6df4 | 465 | } |
a22bfc3e | 466 | videojsUntyped.registerPlugin('peertube', PeerTubePlugin) |