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