diff options
author | Chocobozzz <florian.bigard@gmail.com> | 2017-10-06 10:40:09 +0200 |
---|---|---|
committer | Chocobozzz <florian.bigard@gmail.com> | 2017-10-06 11:03:09 +0200 |
commit | aa8b6df4a51c82eb91e6fd71a090b2128098af6b (patch) | |
tree | b2d6292ceb34ad71a1ce9b671f0d87923f6c7c21 /client/src | |
parent | 127d96b969891a73d76e257581e5fd81cd867480 (diff) | |
download | PeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.tar.gz PeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.tar.zst PeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.zip |
Client: handle multiple file resolutions
Diffstat (limited to 'client/src')
-rw-r--r-- | client/src/app/videos/shared/video.model.ts | 15 | ||||
-rw-r--r-- | client/src/app/videos/video-watch/index.ts | 1 | ||||
-rw-r--r-- | client/src/app/videos/video-watch/video-magnet.component.html | 5 | ||||
-rw-r--r-- | client/src/app/videos/video-watch/video-watch.component.ts | 121 | ||||
-rw-r--r-- | client/src/app/videos/video-watch/webtorrent.service.ts | 29 | ||||
-rw-r--r-- | client/src/app/videos/videos.module.ts | 6 | ||||
-rw-r--r-- | client/src/assets/player/peertube-videojs-plugin.ts | 238 | ||||
-rw-r--r-- | client/src/assets/player/video-renderer.ts | 119 | ||||
-rw-r--r-- | client/src/sass/video-js-custom.scss | 32 | ||||
-rw-r--r-- | client/src/standalone/videos/embed.scss | 8 | ||||
-rw-r--r-- | client/src/standalone/videos/embed.ts | 91 |
11 files changed, 464 insertions, 201 deletions
diff --git a/client/src/app/videos/shared/video.model.ts b/client/src/app/videos/shared/video.model.ts index 17f41059d..b315e59b1 100644 --- a/client/src/app/videos/shared/video.model.ts +++ b/client/src/app/videos/shared/video.model.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | import { Video as VideoServerModel, VideoFile } from '../../../../../shared' | 1 | import { Video as VideoServerModel, VideoFile } from '../../../../../shared' |
2 | import { User } from '../../shared' | 2 | import { User } from '../../shared' |
3 | import { VideoResolution } from '../../../../../shared/models/videos/video-resolution.enum' | ||
3 | 4 | ||
4 | export class Video implements VideoServerModel { | 5 | export class Video implements VideoServerModel { |
5 | author: string | 6 | author: string |
@@ -116,11 +117,19 @@ export class Video implements VideoServerModel { | |||
116 | return (this.nsfw && (!user || user.displayNSFW === false)) | 117 | return (this.nsfw && (!user || user.displayNSFW === false)) |
117 | } | 118 | } |
118 | 119 | ||
119 | getDefaultMagnetUri () { | 120 | getAppropriateMagnetUri (actualDownloadSpeed = 0) { |
120 | if (this.files === undefined || this.files.length === 0) return '' | 121 | if (this.files === undefined || this.files.length === 0) return '' |
122 | if (this.files.length === 1) return this.files[0].magnetUri | ||
121 | 123 | ||
122 | // TODO: choose the original file | 124 | // Find first video that is good for our download speed (remember they are sorted) |
123 | return this.files[0].magnetUri | 125 | let betterResolutionFile = this.files.find(f => actualDownloadSpeed > (f.size / this.duration)) |
126 | |||
127 | // If the download speed is too bad, return the lowest resolution we have | ||
128 | if (betterResolutionFile === undefined) { | ||
129 | betterResolutionFile = this.files.find(f => f.resolution === VideoResolution.H_240P) | ||
130 | } | ||
131 | |||
132 | return betterResolutionFile.magnetUri | ||
124 | } | 133 | } |
125 | 134 | ||
126 | patch (values: Object) { | 135 | patch (values: Object) { |
diff --git a/client/src/app/videos/video-watch/index.ts b/client/src/app/videos/video-watch/index.ts index 6e35262d3..105872469 100644 --- a/client/src/app/videos/video-watch/index.ts +++ b/client/src/app/videos/video-watch/index.ts | |||
@@ -2,4 +2,3 @@ export * from './video-magnet.component' | |||
2 | export * from './video-share.component' | 2 | export * from './video-share.component' |
3 | export * from './video-report.component' | 3 | export * from './video-report.component' |
4 | export * from './video-watch.component' | 4 | export * from './video-watch.component' |
5 | export * from './webtorrent.service' | ||
diff --git a/client/src/app/videos/video-watch/video-magnet.component.html b/client/src/app/videos/video-watch/video-magnet.component.html index 5b0324e37..484280c45 100644 --- a/client/src/app/videos/video-watch/video-magnet.component.html +++ b/client/src/app/videos/video-watch/video-magnet.component.html | |||
@@ -10,7 +10,10 @@ | |||
10 | </div> | 10 | </div> |
11 | 11 | ||
12 | <div class="modal-body"> | 12 | <div class="modal-body"> |
13 | <input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="video.getDefaultMagnetUri()" /> | 13 | <div *ngFor="let file of video.files"> |
14 | <label>{{ file.resolutionLabel }}</label> | ||
15 | <input #magnetUriInput (click)="magnetUriInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="file.magnetUri" /> | ||
16 | </div> | ||
14 | </div> | 17 | </div> |
15 | </div> | 18 | </div> |
16 | </div> | 19 | </div> |
diff --git a/client/src/app/videos/video-watch/video-watch.component.ts b/client/src/app/videos/video-watch/video-watch.component.ts index f5a47199d..dbe391fff 100644 --- a/client/src/app/videos/video-watch/video-watch.component.ts +++ b/client/src/app/videos/video-watch/video-watch.component.ts | |||
@@ -4,6 +4,8 @@ import { Observable } from 'rxjs/Observable' | |||
4 | import { Subscription } from 'rxjs/Subscription' | 4 | import { Subscription } from 'rxjs/Subscription' |
5 | 5 | ||
6 | import videojs from 'video.js' | 6 | import videojs from 'video.js' |
7 | import '../../../assets/player/peertube-videojs-plugin' | ||
8 | |||
7 | import { MetaService } from '@ngx-meta/core' | 9 | import { MetaService } from '@ngx-meta/core' |
8 | import { NotificationsService } from 'angular2-notifications' | 10 | import { NotificationsService } from 'angular2-notifications' |
9 | 11 | ||
@@ -13,7 +15,7 @@ import { VideoShareComponent } from './video-share.component' | |||
13 | import { VideoReportComponent } from './video-report.component' | 15 | import { VideoReportComponent } from './video-report.component' |
14 | import { Video, VideoService } from '../shared' | 16 | import { Video, VideoService } from '../shared' |
15 | import { WebTorrentService } from './webtorrent.service' | 17 | import { WebTorrentService } from './webtorrent.service' |
16 | import { UserVideoRateType, VideoRateType, UserVideoRate } from '../../../../../shared' | 18 | import { UserVideoRateType, VideoRateType } from '../../../../../shared' |
17 | 19 | ||
18 | @Component({ | 20 | @Component({ |
19 | selector: 'my-video-watch', | 21 | selector: 'my-video-watch', |
@@ -21,8 +23,6 @@ import { UserVideoRateType, VideoRateType, UserVideoRate } from '../../../../../ | |||
21 | styleUrls: [ './video-watch.component.scss' ] | 23 | styleUrls: [ './video-watch.component.scss' ] |
22 | }) | 24 | }) |
23 | export class VideoWatchComponent implements OnInit, OnDestroy { | 25 | export class VideoWatchComponent implements OnInit, OnDestroy { |
24 | private static LOADTIME_TOO_LONG = 20000 | ||
25 | |||
26 | @ViewChild('videoMagnetModal') videoMagnetModal: VideoMagnetComponent | 26 | @ViewChild('videoMagnetModal') videoMagnetModal: VideoMagnetComponent |
27 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent | 27 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent |
28 | @ViewChild('videoReportModal') videoReportModal: VideoReportComponent | 28 | @ViewChild('videoReportModal') videoReportModal: VideoReportComponent |
@@ -38,20 +38,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
38 | video: Video = null | 38 | video: Video = null |
39 | videoNotFound = false | 39 | videoNotFound = false |
40 | 40 | ||
41 | private errorTimer: number | ||
42 | private paramsSub: Subscription | 41 | private paramsSub: Subscription |
43 | private errorsSub: Subscription | ||
44 | private torrentInfosInterval: number | ||
45 | 42 | ||
46 | constructor ( | 43 | constructor ( |
47 | private elementRef: ElementRef, | 44 | private elementRef: ElementRef, |
48 | private ngZone: NgZone, | ||
49 | private route: ActivatedRoute, | 45 | private route: ActivatedRoute, |
50 | private router: Router, | 46 | private router: Router, |
51 | private videoService: VideoService, | 47 | private videoService: VideoService, |
52 | private confirmService: ConfirmService, | 48 | private confirmService: ConfirmService, |
53 | private metaService: MetaService, | 49 | private metaService: MetaService, |
54 | private webTorrentService: WebTorrentService, | ||
55 | private authService: AuthService, | 50 | private authService: AuthService, |
56 | private notificationsService: NotificationsService | 51 | private notificationsService: NotificationsService |
57 | ) {} | 52 | ) {} |
@@ -68,81 +63,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
68 | } | 63 | } |
69 | ) | 64 | ) |
70 | }) | 65 | }) |
71 | |||
72 | this.playerElement = this.elementRef.nativeElement.querySelector('#video-container') | ||
73 | |||
74 | const videojsOptions = { | ||
75 | controls: true, | ||
76 | autoplay: true | ||
77 | } | ||
78 | |||
79 | const self = this | ||
80 | videojs(this.playerElement, videojsOptions, function () { | ||
81 | self.player = this | ||
82 | }) | ||
83 | |||
84 | this.errorsSub = this.webTorrentService.errors.subscribe(err => this.handleError(err)) | ||
85 | } | 66 | } |
86 | 67 | ||
87 | ngOnDestroy () { | 68 | ngOnDestroy () { |
88 | // Remove WebTorrent stuff | 69 | // Remove WebTorrent stuff |
89 | console.log('Removing video from webtorrent.') | 70 | console.log('Removing video from webtorrent.') |
90 | window.clearInterval(this.torrentInfosInterval) | ||
91 | window.clearTimeout(this.errorTimer) | ||
92 | |||
93 | if (this.video !== null && this.webTorrentService.has(this.video.getDefaultMagnetUri())) { | ||
94 | this.webTorrentService.remove(this.video.getDefaultMagnetUri()) | ||
95 | } | ||
96 | 71 | ||
97 | // Remove player | 72 | // Remove player |
98 | videojs(this.playerElement).dispose() | 73 | videojs(this.playerElement).dispose() |
99 | 74 | ||
100 | // Unsubscribe subscriptions | 75 | // Unsubscribe subscriptions |
101 | this.paramsSub.unsubscribe() | 76 | this.paramsSub.unsubscribe() |
102 | this.errorsSub.unsubscribe() | ||
103 | } | ||
104 | |||
105 | loadVideo () { | ||
106 | // Reset the error | ||
107 | this.error = false | ||
108 | // We are loading the video | ||
109 | this.loading = true | ||
110 | |||
111 | console.log('Adding ' + this.video.getDefaultMagnetUri() + '.') | ||
112 | |||
113 | // The callback might never return if there are network issues | ||
114 | // So we create a timer to inform the user the load is abnormally long | ||
115 | this.errorTimer = window.setTimeout(() => this.loadTooLong(), VideoWatchComponent.LOADTIME_TOO_LONG) | ||
116 | |||
117 | const torrent = this.webTorrentService.add(this.video.getDefaultMagnetUri(), torrent => { | ||
118 | // Clear the error timer | ||
119 | window.clearTimeout(this.errorTimer) | ||
120 | // Maybe the error was fired by the timer, so reset it | ||
121 | this.error = false | ||
122 | |||
123 | // We are not loading the video anymore | ||
124 | this.loading = false | ||
125 | |||
126 | console.log('Added ' + this.video.getDefaultMagnetUri() + '.') | ||
127 | torrent.files[0].renderTo(this.playerElement, (err) => { | ||
128 | if (err) { | ||
129 | this.notificationsService.error('Error', 'Cannot append the file in the video element.') | ||
130 | console.error(err) | ||
131 | } | ||
132 | |||
133 | // Hack to "simulate" src link in video.js >= 6 | ||
134 | // If no, we can't play the video after pausing it | ||
135 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | ||
136 | (this.player as any).src = () => true | ||
137 | |||
138 | this.player.play() | ||
139 | }) | ||
140 | |||
141 | this.runInProgress(torrent) | ||
142 | }) | ||
143 | |||
144 | torrent.on('error', err => this.handleError(err)) | ||
145 | torrent.on('warning', err => this.handleError(err)) | ||
146 | } | 77 | } |
147 | 78 | ||
148 | setLike () { | 79 | setLike () { |
@@ -295,8 +226,36 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
295 | return this.router.navigate([ '/videos/list' ]) | 226 | return this.router.navigate([ '/videos/list' ]) |
296 | } | 227 | } |
297 | 228 | ||
229 | this.playerElement = this.elementRef.nativeElement.querySelector('#video-container') | ||
230 | |||
231 | const videojsOptions = { | ||
232 | controls: true, | ||
233 | autoplay: true, | ||
234 | plugins: { | ||
235 | peertube: { | ||
236 | videoFiles: this.video.files, | ||
237 | playerElement: this.playerElement, | ||
238 | autoplay: true, | ||
239 | peerTubeLink: false | ||
240 | } | ||
241 | } | ||
242 | } | ||
243 | |||
244 | const self = this | ||
245 | videojs(this.playerElement, videojsOptions, function () { | ||
246 | self.player = this | ||
247 | this.on('customError', (event, data) => { | ||
248 | self.handleError(data.err) | ||
249 | }) | ||
250 | |||
251 | this.on('torrentInfo', (event, data) => { | ||
252 | self.downloadSpeed = data.downloadSpeed | ||
253 | self.numPeers = data.numPeers | ||
254 | self.uploadSpeed = data.uploadSpeed | ||
255 | }) | ||
256 | }) | ||
257 | |||
298 | this.setOpenGraphTags() | 258 | this.setOpenGraphTags() |
299 | this.loadVideo() | ||
300 | this.checkUserRating() | 259 | this.checkUserRating() |
301 | } | 260 | } |
302 | ) | 261 | ) |
@@ -318,11 +277,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
318 | this.video.dislikes += dislikesToIncrement | 277 | this.video.dislikes += dislikesToIncrement |
319 | } | 278 | } |
320 | 279 | ||
321 | private loadTooLong () { | ||
322 | this.error = true | ||
323 | console.error('The video load seems to be abnormally long.') | ||
324 | } | ||
325 | |||
326 | private setOpenGraphTags () { | 280 | private setOpenGraphTags () { |
327 | this.metaService.setTitle(this.video.name) | 281 | this.metaService.setTitle(this.video.name) |
328 | 282 | ||
@@ -343,15 +297,4 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
343 | this.metaService.setTag('og:url', window.location.href) | 297 | this.metaService.setTag('og:url', window.location.href) |
344 | this.metaService.setTag('url', window.location.href) | 298 | this.metaService.setTag('url', window.location.href) |
345 | } | 299 | } |
346 | |||
347 | private runInProgress (torrent: any) { | ||
348 | // Refresh each second | ||
349 | this.torrentInfosInterval = window.setInterval(() => { | ||
350 | this.ngZone.run(() => { | ||
351 | this.downloadSpeed = torrent.downloadSpeed | ||
352 | this.numPeers = torrent.numPeers | ||
353 | this.uploadSpeed = torrent.uploadSpeed | ||
354 | }) | ||
355 | }, 1000) | ||
356 | } | ||
357 | } | 300 | } |
diff --git a/client/src/app/videos/video-watch/webtorrent.service.ts b/client/src/app/videos/video-watch/webtorrent.service.ts deleted file mode 100644 index 8819e17d4..000000000 --- a/client/src/app/videos/video-watch/webtorrent.service.ts +++ /dev/null | |||
@@ -1,29 +0,0 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Subject } from 'rxjs/Subject' | ||
3 | |||
4 | import * as WebTorrent from 'webtorrent' | ||
5 | |||
6 | @Injectable() | ||
7 | export class WebTorrentService { | ||
8 | errors = new Subject<string | Error>() | ||
9 | |||
10 | private client: WebTorrent.Instance | ||
11 | |||
12 | constructor () { | ||
13 | this.client = new WebTorrent({ dht: false }) | ||
14 | |||
15 | this.client.on('error', err => this.errors.next(err)) | ||
16 | } | ||
17 | |||
18 | add (magnetUri: string, callback: (torrent: WebTorrent.Torrent) => any) { | ||
19 | return this.client.add(magnetUri, callback) | ||
20 | } | ||
21 | |||
22 | remove (magnetUri: string) { | ||
23 | return this.client.remove(magnetUri) | ||
24 | } | ||
25 | |||
26 | has (magnetUri: string) { | ||
27 | return this.client.get(magnetUri) !== null | ||
28 | } | ||
29 | } | ||
diff --git a/client/src/app/videos/videos.module.ts b/client/src/app/videos/videos.module.ts index 7d2451de7..bc86118cc 100644 --- a/client/src/app/videos/videos.module.ts +++ b/client/src/app/videos/videos.module.ts | |||
@@ -10,8 +10,7 @@ import { | |||
10 | VideoWatchComponent, | 10 | VideoWatchComponent, |
11 | VideoMagnetComponent, | 11 | VideoMagnetComponent, |
12 | VideoReportComponent, | 12 | VideoReportComponent, |
13 | VideoShareComponent, | 13 | VideoShareComponent |
14 | WebTorrentService | ||
15 | } from './video-watch' | 14 | } from './video-watch' |
16 | import { VideoService } from './shared' | 15 | import { VideoService } from './shared' |
17 | import { SharedModule } from '../shared' | 16 | import { SharedModule } from '../shared' |
@@ -47,8 +46,7 @@ import { SharedModule } from '../shared' | |||
47 | ], | 46 | ], |
48 | 47 | ||
49 | providers: [ | 48 | providers: [ |
50 | VideoService, | 49 | VideoService |
51 | WebTorrentService | ||
52 | ] | 50 | ] |
53 | }) | 51 | }) |
54 | export class VideosModule { } | 52 | export class VideosModule { } |
diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts new file mode 100644 index 000000000..090cc53ba --- /dev/null +++ b/client/src/assets/player/peertube-videojs-plugin.ts | |||
@@ -0,0 +1,238 @@ | |||
1 | // Big thanks to: https://github.com/kmoskwiak/videojs-resolution-switcher | ||
2 | |||
3 | import videojs, { Player } from 'video.js' | ||
4 | import * as WebTorrent from 'webtorrent' | ||
5 | |||
6 | import { renderVideo } from './video-renderer' | ||
7 | import { VideoFile } from '../../../../shared' | ||
8 | |||
9 | // videojs typings don't have some method we need | ||
10 | const videojsUntyped = videojs as any | ||
11 | const webtorrent = new WebTorrent({ dht: false }) | ||
12 | |||
13 | const MenuItem = videojsUntyped.getComponent('MenuItem') | ||
14 | const ResolutionMenuItem = videojsUntyped.extend(MenuItem, { | ||
15 | constructor: function (player: Player, options) { | ||
16 | options.selectable = true | ||
17 | MenuItem.call(this, player, options) | ||
18 | |||
19 | const currentResolution = this.player_.getCurrentResolution() | ||
20 | this.selected(this.options_.id === currentResolution) | ||
21 | }, | ||
22 | |||
23 | handleClick: function (event) { | ||
24 | MenuItem.prototype.handleClick.call(this, event) | ||
25 | this.player_.updateResolution(this.options_.id) | ||
26 | } | ||
27 | }) | ||
28 | MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem) | ||
29 | |||
30 | const MenuButton = videojsUntyped.getComponent('MenuButton') | ||
31 | const ResolutionMenuButton = videojsUntyped.extend(MenuButton, { | ||
32 | constructor: function (player, options) { | ||
33 | this.label = document.createElement('span') | ||
34 | options.label = 'Quality' | ||
35 | |||
36 | MenuButton.call(this, player, options) | ||
37 | this.el().setAttribute('aria-label', 'Quality') | ||
38 | this.controlText('Quality') | ||
39 | |||
40 | videojsUntyped.dom.addClass(this.label, 'vjs-resolution-button-label') | ||
41 | this.el().appendChild(this.label) | ||
42 | |||
43 | player.on('videoFileUpdate', videojs.bind(this, this.update)) | ||
44 | }, | ||
45 | |||
46 | createItems: function () { | ||
47 | const menuItems = [] | ||
48 | for (const videoFile of this.player_.videoFiles) { | ||
49 | menuItems.push(new ResolutionMenuItem( | ||
50 | this.player_, | ||
51 | { | ||
52 | id: videoFile.resolution, | ||
53 | label: videoFile.resolutionLabel, | ||
54 | src: videoFile.magnetUri, | ||
55 | selected: videoFile.resolution === this.currentSelection | ||
56 | }) | ||
57 | ) | ||
58 | } | ||
59 | |||
60 | return menuItems | ||
61 | }, | ||
62 | |||
63 | update: function () { | ||
64 | this.label.innerHTML = this.player_.getCurrentResolutionLabel() | ||
65 | return MenuButton.prototype.update.call(this) | ||
66 | }, | ||
67 | |||
68 | buildCSSClass: function () { | ||
69 | return MenuButton.prototype.buildCSSClass.call(this) + ' vjs-resolution-button' | ||
70 | } | ||
71 | }) | ||
72 | MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton) | ||
73 | |||
74 | const Button = videojsUntyped.getComponent('Button') | ||
75 | const PeertubeLinkButton = videojsUntyped.extend(Button, { | ||
76 | constructor: function (player) { | ||
77 | Button.apply(this, arguments) | ||
78 | this.player = player | ||
79 | }, | ||
80 | |||
81 | createEl: function () { | ||
82 | const link = document.createElement('a') | ||
83 | link.href = window.location.href.replace('embed', 'watch') | ||
84 | link.innerHTML = 'PeerTube' | ||
85 | link.title = 'Go to the video page' | ||
86 | link.className = 'vjs-peertube-link' | ||
87 | link.target = '_blank' | ||
88 | |||
89 | return link | ||
90 | }, | ||
91 | |||
92 | handleClick: function () { | ||
93 | this.player.pause() | ||
94 | } | ||
95 | }) | ||
96 | Button.registerComponent('PeerTubeLinkButton', PeertubeLinkButton) | ||
97 | |||
98 | type PeertubePluginOptions = { | ||
99 | videoFiles: VideoFile[] | ||
100 | playerElement: HTMLVideoElement | ||
101 | autoplay: boolean | ||
102 | peerTubeLink: boolean | ||
103 | } | ||
104 | const peertubePlugin = function (options: PeertubePluginOptions) { | ||
105 | const player = this | ||
106 | let currentVideoFile: VideoFile = undefined | ||
107 | const playerElement = options.playerElement | ||
108 | player.videoFiles = options.videoFiles | ||
109 | |||
110 | // Hack to "simulate" src link in video.js >= 6 | ||
111 | // Without this, we can't play the video after pausing it | ||
112 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | ||
113 | player.src = function () { | ||
114 | return true | ||
115 | } | ||
116 | |||
117 | player.getCurrentResolution = function () { | ||
118 | return currentVideoFile ? currentVideoFile.resolution : -1 | ||
119 | } | ||
120 | |||
121 | player.getCurrentResolutionLabel = function () { | ||
122 | return currentVideoFile ? currentVideoFile.resolutionLabel : '' | ||
123 | } | ||
124 | |||
125 | player.updateVideoFile = function (videoFile: VideoFile, done: () => void) { | ||
126 | if (done === undefined) { | ||
127 | done = () => { /* empty */ } | ||
128 | } | ||
129 | |||
130 | // Pick the first one | ||
131 | if (videoFile === undefined) { | ||
132 | videoFile = player.videoFiles[0] | ||
133 | } | ||
134 | |||
135 | // Don't add the same video file once again | ||
136 | if (currentVideoFile !== undefined && currentVideoFile.magnetUri === videoFile.magnetUri) { | ||
137 | return | ||
138 | } | ||
139 | |||
140 | const previousVideoFile = currentVideoFile | ||
141 | currentVideoFile = videoFile | ||
142 | |||
143 | console.log('Adding ' + videoFile.magnetUri + '.') | ||
144 | player.torrent = webtorrent.add(videoFile.magnetUri, torrent => { | ||
145 | console.log('Added ' + videoFile.magnetUri + '.') | ||
146 | |||
147 | this.flushVideoFile(previousVideoFile) | ||
148 | |||
149 | const options = { autoplay: true, controls: true } | ||
150 | renderVideo(torrent.files[0], playerElement, options,(err, renderer) => { | ||
151 | if (err) return handleError(err) | ||
152 | |||
153 | this.renderer = renderer | ||
154 | player.play() | ||
155 | |||
156 | return done() | ||
157 | }) | ||
158 | }) | ||
159 | |||
160 | player.torrent.on('error', err => handleError(err)) | ||
161 | player.torrent.on('warning', err => handleError(err)) | ||
162 | |||
163 | player.trigger('videoFileUpdate') | ||
164 | |||
165 | return player | ||
166 | } | ||
167 | |||
168 | player.updateResolution = function (resolution) { | ||
169 | // Remember player state | ||
170 | const currentTime = player.currentTime() | ||
171 | const isPaused = player.paused() | ||
172 | |||
173 | // Hide bigPlayButton | ||
174 | if (!isPaused && this.player_.options_.bigPlayButton) { | ||
175 | this.player_.bigPlayButton.hide() | ||
176 | } | ||
177 | |||
178 | const newVideoFile = player.videoFiles.find(f => f.resolution === resolution) | ||
179 | player.updateVideoFile(newVideoFile, () => { | ||
180 | player.currentTime(currentTime) | ||
181 | player.handleTechSeeked_() | ||
182 | }) | ||
183 | } | ||
184 | |||
185 | player.flushVideoFile = function (videoFile: VideoFile, destroyRenderer = true) { | ||
186 | if (videoFile !== undefined && webtorrent.get(videoFile.magnetUri)) { | ||
187 | if (destroyRenderer === true) this.renderer.destroy() | ||
188 | webtorrent.remove(videoFile.magnetUri) | ||
189 | } | ||
190 | } | ||
191 | |||
192 | player.ready(function () { | ||
193 | const controlBar = player.controlBar | ||
194 | |||
195 | const menuButton = new ResolutionMenuButton(player, options) | ||
196 | const fullscreenElement = controlBar.fullscreenToggle.el() | ||
197 | controlBar.resolutionSwitcher = controlBar.el().insertBefore(menuButton.el(), fullscreenElement) | ||
198 | controlBar.resolutionSwitcher.dispose = function () { | ||
199 | this.parentNode.removeChild(this) | ||
200 | } | ||
201 | |||
202 | player.dispose = function () { | ||
203 | // Don't need to destroy renderer, video player will be destroyed | ||
204 | player.flushVideoFile(currentVideoFile, false) | ||
205 | } | ||
206 | |||
207 | if (options.peerTubeLink === true) { | ||
208 | const peerTubeLinkButton = new PeertubeLinkButton(player) | ||
209 | controlBar.peerTubeLink = controlBar.el().insertBefore(peerTubeLinkButton.el(), fullscreenElement) | ||
210 | |||
211 | controlBar.peerTubeLink.dispose = function () { | ||
212 | this.parentNode.removeChild(this) | ||
213 | } | ||
214 | } | ||
215 | |||
216 | if (options.autoplay === true) { | ||
217 | player.updateVideoFile() | ||
218 | } else { | ||
219 | player.one('play', () => player.updateVideoFile()) | ||
220 | } | ||
221 | |||
222 | setInterval(() => { | ||
223 | if (player.torrent !== undefined) { | ||
224 | player.trigger('torrentInfo', { | ||
225 | downloadSpeed: player.torrent.downloadSpeed, | ||
226 | numPeers: player.torrent.numPeers, | ||
227 | uploadSpeed: player.torrent.uploadSpeed | ||
228 | }) | ||
229 | } | ||
230 | }, 1000) | ||
231 | }) | ||
232 | |||
233 | function handleError (err: Error|string) { | ||
234 | return player.trigger('customError', { err }) | ||
235 | } | ||
236 | } | ||
237 | |||
238 | videojsUntyped.registerPlugin('peertube', peertubePlugin) | ||
diff --git a/client/src/assets/player/video-renderer.ts b/client/src/assets/player/video-renderer.ts new file mode 100644 index 000000000..8baa42533 --- /dev/null +++ b/client/src/assets/player/video-renderer.ts | |||
@@ -0,0 +1,119 @@ | |||
1 | // Thanks: https://github.com/feross/render-media | ||
2 | // TODO: use render-media once https://github.com/feross/render-media/issues/32 is fixed | ||
3 | |||
4 | import { extname } from 'path' | ||
5 | import * as MediaElementWrapper from 'mediasource' | ||
6 | import * as videostream from '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, | ||
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, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer: any) => void) { | ||
31 | const extension = extname(file.name).toLowerCase() | ||
32 | let preparedElem = undefined | ||
33 | let currentTime = 0 | ||
34 | let renderer | ||
35 | |||
36 | if (VIDEOSTREAM_EXTS.indexOf(extension) >= 0) { | ||
37 | renderer = useVideostream() | ||
38 | } else { | ||
39 | renderer = useMediaSource() | ||
40 | } | ||
41 | |||
42 | function useVideostream () { | ||
43 | prepareElem() | ||
44 | preparedElem.addEventListener('error', fallbackToMediaSource) | ||
45 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
46 | preparedElem.addEventListener('canplay', onCanPlay) | ||
47 | return videostream(file, preparedElem) | ||
48 | } | ||
49 | |||
50 | function useMediaSource () { | ||
51 | prepareElem() | ||
52 | preparedElem.addEventListener('error', callback) | ||
53 | preparedElem.addEventListener('loadstart', onLoadStart) | ||
54 | preparedElem.addEventListener('canplay', onCanPlay) | ||
55 | |||
56 | const wrapper = new MediaElementWrapper(preparedElem) | ||
57 | const writable = wrapper.createWriteStream(getCodec(file.name)) | ||
58 | file.createReadStream().pipe(writable) | ||
59 | |||
60 | if (currentTime) preparedElem.currentTime = currentTime | ||
61 | |||
62 | return wrapper | ||
63 | } | ||
64 | |||
65 | function fallbackToMediaSource () { | ||
66 | preparedElem.removeEventListener('error', fallbackToMediaSource) | ||
67 | preparedElem.removeEventListener('canplay', onCanPlay) | ||
68 | |||
69 | useMediaSource() | ||
70 | } | ||
71 | |||
72 | function prepareElem () { | ||
73 | if (preparedElem === undefined) { | ||
74 | preparedElem = elem | ||
75 | |||
76 | preparedElem.addEventListener('progress', function () { | ||
77 | currentTime = elem.currentTime | ||
78 | }) | ||
79 | } | ||
80 | } | ||
81 | |||
82 | function onLoadStart () { | ||
83 | preparedElem.removeEventListener('loadstart', onLoadStart) | ||
84 | if (opts.autoplay) preparedElem.play() | ||
85 | } | ||
86 | |||
87 | function onCanPlay () { | ||
88 | preparedElem.removeEventListener('canplay', onCanPlay) | ||
89 | callback(null, renderer) | ||
90 | } | ||
91 | } | ||
92 | |||
93 | function validateFile (file) { | ||
94 | if (file == null) { | ||
95 | throw new Error('file cannot be null or undefined') | ||
96 | } | ||
97 | if (typeof file.name !== 'string') { | ||
98 | throw new Error('missing or invalid file.name property') | ||
99 | } | ||
100 | if (typeof file.createReadStream !== 'function') { | ||
101 | throw new Error('missing or invalid file.createReadStream property') | ||
102 | } | ||
103 | } | ||
104 | |||
105 | function getCodec (name: string) { | ||
106 | const ext = extname(name).toLowerCase() | ||
107 | return { | ||
108 | '.m4a': 'audio/mp4; codecs="mp4a.40.5"', | ||
109 | '.m4v': 'video/mp4; codecs="avc1.640029, mp4a.40.5"', | ||
110 | '.mkv': 'video/webm; codecs="avc1.640029, mp4a.40.5"', | ||
111 | '.mp3': 'audio/mpeg', | ||
112 | '.mp4': 'video/mp4; codecs="avc1.640029, mp4a.40.5"', | ||
113 | '.webm': 'video/webm; codecs="vorbis, vp8"' | ||
114 | }[ext] | ||
115 | } | ||
116 | |||
117 | export { | ||
118 | renderVideo | ||
119 | } | ||
diff --git a/client/src/sass/video-js-custom.scss b/client/src/sass/video-js-custom.scss index eb5b8f869..4e3aceaab 100644 --- a/client/src/sass/video-js-custom.scss +++ b/client/src/sass/video-js-custom.scss | |||
@@ -1,3 +1,33 @@ | |||
1 | // Thanks: https://github.com/kmoskwiak/videojs-resolution-switcher/pull/92/files | ||
2 | .vjs-resolution-button-label { | ||
3 | font-size: 1em; | ||
4 | line-height: 3em; | ||
5 | position: absolute; | ||
6 | top: 0; | ||
7 | left: -1px; | ||
8 | width: 100%; | ||
9 | height: 100%; | ||
10 | text-align: center; | ||
11 | box-sizing: inherit; | ||
12 | } | ||
13 | |||
14 | .vjs-resolution-button { | ||
15 | outline: 0 !important; | ||
16 | |||
17 | .vjs-menu { | ||
18 | .vjs-menu-content { | ||
19 | width: 4em; | ||
20 | left: 50%; /* Center the menu, in it's parent */ | ||
21 | margin-left: -2em; /* half of width, to center */ | ||
22 | } | ||
23 | |||
24 | li { | ||
25 | text-transform: none; | ||
26 | font-size: 1em; | ||
27 | } | ||
28 | } | ||
29 | } | ||
30 | |||
1 | // Thanks: https://github.com/zanechua/videojs-sublime-inspired-skin | 31 | // Thanks: https://github.com/zanechua/videojs-sublime-inspired-skin |
2 | 32 | ||
3 | // Video JS Sublime Skin | 33 | // Video JS Sublime Skin |
@@ -210,7 +240,7 @@ $slider-bg-color: lighten($primary-background-color, 33%); | |||
210 | width: 6em; | 240 | width: 6em; |
211 | position: absolute; | 241 | position: absolute; |
212 | right: 0; | 242 | right: 0; |
213 | margin-right: 30px; | 243 | margin-right: 65px; |
214 | } | 244 | } |
215 | 245 | ||
216 | .vjs-sublime-skin .vjs-volume-menu-button .vjs-menu-content, | 246 | .vjs-sublime-skin .vjs-volume-menu-button .vjs-menu-content, |
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss index 938a6e48c..b76f09677 100644 --- a/client/src/standalone/videos/embed.scss +++ b/client/src/standalone/videos/embed.scss | |||
@@ -29,7 +29,11 @@ html, body { | |||
29 | line-height: 2.20; | 29 | line-height: 2.20; |
30 | transition: all .4s; | 30 | transition: all .4s; |
31 | position: relative; | 31 | position: relative; |
32 | right: 6px; | 32 | right: 8px; |
33 | } | ||
34 | |||
35 | .vjs-resolution-button-label { | ||
36 | left: -7px; | ||
33 | } | 37 | } |
34 | 38 | ||
35 | .vjs-peertube-link:hover { | 39 | .vjs-peertube-link:hover { |
@@ -38,5 +42,5 @@ html, body { | |||
38 | 42 | ||
39 | // Fix volume panel because we added a new component (PeerTube link) | 43 | // Fix volume panel because we added a new component (PeerTube link) |
40 | .vjs-volume-panel { | 44 | .vjs-volume-panel { |
41 | margin-right: 90px !important; | 45 | margin-right: 130px !important; |
42 | } | 46 | } |
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 0698344b0..f2f339bcc 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts | |||
@@ -1,14 +1,11 @@ | |||
1 | import './embed.scss' | 1 | import './embed.scss' |
2 | 2 | ||
3 | import videojs from 'video.js' | 3 | import videojs from 'video.js' |
4 | import '../../assets/player/peertube-videojs-plugin' | ||
4 | import 'videojs-dock/dist/videojs-dock.es.js' | 5 | import 'videojs-dock/dist/videojs-dock.es.js' |
5 | import * as WebTorrent from 'webtorrent' | ||
6 | import { Video } from '../../../../shared' | 6 | import { Video } from '../../../../shared' |
7 | 7 | ||
8 | // videojs typings don't have some method we need | 8 | function loadVideoInfo (videoId: string, callback: (err: Error, res?: Video) => void) { |
9 | const videojsUntyped = videojs as any | ||
10 | |||
11 | function loadVideoInfos (videoId: string, callback: (err: Error, res?: Video) => void) { | ||
12 | const xhttp = new XMLHttpRequest() | 9 | const xhttp = new XMLHttpRequest() |
13 | xhttp.onreadystatechange = function () { | 10 | xhttp.onreadystatechange = function () { |
14 | if (this.readyState === 4 && this.status === 200) { | 11 | if (this.readyState === 4 && this.status === 200) { |
@@ -24,84 +21,36 @@ function loadVideoInfos (videoId: string, callback: (err: Error, res?: Video) => | |||
24 | xhttp.send() | 21 | xhttp.send() |
25 | } | 22 | } |
26 | 23 | ||
27 | function loadVideoTorrent (magnetUri: string, player: videojs.Player) { | ||
28 | console.log('Loading video ' + videoId) | ||
29 | const client = new WebTorrent() | ||
30 | |||
31 | console.log('Adding magnet ' + magnetUri) | ||
32 | client.add(magnetUri, torrent => { | ||
33 | const file = torrent.files[0] | ||
34 | |||
35 | file.renderTo('video', err => { | ||
36 | if (err) { | ||
37 | console.error(err) | ||
38 | return | ||
39 | } | ||
40 | |||
41 | // Hack to "simulate" src link in video.js >= 6 | ||
42 | // If no, we can't play the video after pausing it | ||
43 | // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633 | ||
44 | (player as any).src = () => true | ||
45 | |||
46 | player.play() | ||
47 | }) | ||
48 | }) | ||
49 | } | ||
50 | |||
51 | const urlParts = window.location.href.split('/') | 24 | const urlParts = window.location.href.split('/') |
52 | const videoId = urlParts[urlParts.length - 1] | 25 | const videoId = urlParts[urlParts.length - 1] |
53 | 26 | ||
54 | loadVideoInfos(videoId, (err, videoInfos) => { | 27 | loadVideoInfo(videoId, (err, videoInfo) => { |
55 | if (err) { | 28 | if (err) { |
56 | console.error(err) | 29 | console.error(err) |
57 | return | 30 | return |
58 | } | 31 | } |
59 | 32 | ||
60 | let magnetUri = '' | 33 | const videoElement = document.getElementById('video-container') as HTMLVideoElement |
61 | if (videoInfos.files !== undefined && videoInfos.files.length !== 0) { | 34 | const previewUrl = window.location.origin + videoInfo.previewPath |
62 | magnetUri = videoInfos.files[0].magnetUri | 35 | videoElement.poster = previewUrl |
36 | |||
37 | const videojsOptions = { | ||
38 | controls: true, | ||
39 | autoplay: false, | ||
40 | plugins: { | ||
41 | peertube: { | ||
42 | videoFiles: videoInfo.files, | ||
43 | playerElement: videoElement, | ||
44 | autoplay: false, | ||
45 | peerTubeLink: true | ||
46 | } | ||
47 | } | ||
63 | } | 48 | } |
64 | 49 | videojs('video-container', videojsOptions, function () { | |
65 | const videoContainer = document.getElementById('video-container') as HTMLVideoElement | ||
66 | const previewUrl = window.location.origin + videoInfos.previewPath | ||
67 | videoContainer.poster = previewUrl | ||
68 | |||
69 | videojs('video-container', { controls: true, autoplay: false }, function () { | ||
70 | const player = this | 50 | const player = this |
71 | 51 | ||
72 | const Button = videojsUntyped.getComponent('Button') | ||
73 | const peertubeLinkButton = videojsUntyped.extend(Button, { | ||
74 | constructor: function () { | ||
75 | Button.apply(this, arguments) | ||
76 | }, | ||
77 | |||
78 | createEl: function () { | ||
79 | const link = document.createElement('a') | ||
80 | link.href = window.location.href.replace('embed', 'watch') | ||
81 | link.innerHTML = 'PeerTube' | ||
82 | link.title = 'Go to the video page' | ||
83 | link.className = 'vjs-peertube-link' | ||
84 | link.target = '_blank' | ||
85 | |||
86 | return link | ||
87 | }, | ||
88 | |||
89 | handleClick: function () { | ||
90 | player.pause() | ||
91 | } | ||
92 | }) | ||
93 | videojsUntyped.registerComponent('PeerTubeLinkButton', peertubeLinkButton) | ||
94 | |||
95 | const controlBar = player.getChild('controlBar') | ||
96 | const addedLink = controlBar.addChild('PeerTubeLinkButton', {}) | ||
97 | controlBar.el().insertBefore(addedLink.el(), controlBar.fullscreenToggle.el()) | ||
98 | |||
99 | player.dock({ | 52 | player.dock({ |
100 | title: videoInfos.name | 53 | title: videoInfo.name |
101 | }) | 54 | }) |
102 | |||
103 | document.querySelector('.vjs-big-play-button').addEventListener('click', () => { | ||
104 | loadVideoTorrent(magnetUri, player) | ||
105 | }, false) | ||
106 | }) | 55 | }) |
107 | }) | 56 | }) |