aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <florian.bigard@gmail.com>2017-10-06 10:40:09 +0200
committerChocobozzz <florian.bigard@gmail.com>2017-10-06 11:03:09 +0200
commitaa8b6df4a51c82eb91e6fd71a090b2128098af6b (patch)
treeb2d6292ceb34ad71a1ce9b671f0d87923f6c7c21
parent127d96b969891a73d76e257581e5fd81cd867480 (diff)
downloadPeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.tar.gz
PeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.tar.zst
PeerTube-aa8b6df4a51c82eb91e6fd71a090b2128098af6b.zip
Client: handle multiple file resolutions
-rw-r--r--client/config/webpack.video-embed.js2
-rw-r--r--client/src/app/videos/shared/video.model.ts15
-rw-r--r--client/src/app/videos/video-watch/index.ts1
-rw-r--r--client/src/app/videos/video-watch/video-magnet.component.html5
-rw-r--r--client/src/app/videos/video-watch/video-watch.component.ts121
-rw-r--r--client/src/app/videos/video-watch/webtorrent.service.ts29
-rw-r--r--client/src/app/videos/videos.module.ts6
-rw-r--r--client/src/assets/player/peertube-videojs-plugin.ts238
-rw-r--r--client/src/assets/player/video-renderer.ts119
-rw-r--r--client/src/sass/video-js-custom.scss32
-rw-r--r--client/src/standalone/videos/embed.scss8
-rw-r--r--client/src/standalone/videos/embed.ts91
-rw-r--r--config/test-1.yaml3
-rw-r--r--config/test-3.yaml3
-rw-r--r--config/test-4.yaml3
-rw-r--r--config/test-5.yaml3
-rw-r--r--config/test-6.yaml3
-rw-r--r--config/test.yaml4
-rw-r--r--server/models/video/video.ts33
-rw-r--r--server/tests/api/multiple-pods.ts10
20 files changed, 509 insertions, 220 deletions
diff --git a/client/config/webpack.video-embed.js b/client/config/webpack.video-embed.js
index a04d5be8b..fe40194cf 100644
--- a/client/config/webpack.video-embed.js
+++ b/client/config/webpack.video-embed.js
@@ -8,7 +8,7 @@ const ExtractTextPlugin = require('extract-text-webpack-plugin')
8const PurifyCSSPlugin = require('purifycss-webpack') 8const PurifyCSSPlugin = require('purifycss-webpack')
9 9
10module.exports = function (options) { 10module.exports = function (options) {
11 const isProd = options.env === 'production' 11 const isProd = options && options.env === 'production'
12 12
13 const configuration = { 13 const configuration = {
14 entry: { 14 entry: {
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 @@
1import { Video as VideoServerModel, VideoFile } from '../../../../../shared' 1import { Video as VideoServerModel, VideoFile } from '../../../../../shared'
2import { User } from '../../shared' 2import { User } from '../../shared'
3import { VideoResolution } from '../../../../../shared/models/videos/video-resolution.enum'
3 4
4export class Video implements VideoServerModel { 5export 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'
2export * from './video-share.component' 2export * from './video-share.component'
3export * from './video-report.component' 3export * from './video-report.component'
4export * from './video-watch.component' 4export * from './video-watch.component'
5export * 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'
4import { Subscription } from 'rxjs/Subscription' 4import { Subscription } from 'rxjs/Subscription'
5 5
6import videojs from 'video.js' 6import videojs from 'video.js'
7import '../../../assets/player/peertube-videojs-plugin'
8
7import { MetaService } from '@ngx-meta/core' 9import { MetaService } from '@ngx-meta/core'
8import { NotificationsService } from 'angular2-notifications' 10import { NotificationsService } from 'angular2-notifications'
9 11
@@ -13,7 +15,7 @@ import { VideoShareComponent } from './video-share.component'
13import { VideoReportComponent } from './video-report.component' 15import { VideoReportComponent } from './video-report.component'
14import { Video, VideoService } from '../shared' 16import { Video, VideoService } from '../shared'
15import { WebTorrentService } from './webtorrent.service' 17import { WebTorrentService } from './webtorrent.service'
16import { UserVideoRateType, VideoRateType, UserVideoRate } from '../../../../../shared' 18import { 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})
23export class VideoWatchComponent implements OnInit, OnDestroy { 25export 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 @@
1import { Injectable } from '@angular/core'
2import { Subject } from 'rxjs/Subject'
3
4import * as WebTorrent from 'webtorrent'
5
6@Injectable()
7export 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'
16import { VideoService } from './shared' 15import { VideoService } from './shared'
17import { SharedModule } from '../shared' 16import { 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})
54export class VideosModule { } 52export 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
3import videojs, { Player } from 'video.js'
4import * as WebTorrent from 'webtorrent'
5
6import { renderVideo } from './video-renderer'
7import { VideoFile } from '../../../../shared'
8
9// videojs typings don't have some method we need
10const videojsUntyped = videojs as any
11const webtorrent = new WebTorrent({ dht: false })
12
13const MenuItem = videojsUntyped.getComponent('MenuItem')
14const 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})
28MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem)
29
30const MenuButton = videojsUntyped.getComponent('MenuButton')
31const 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})
72MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton)
73
74const Button = videojsUntyped.getComponent('Button')
75const 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})
96Button.registerComponent('PeerTubeLinkButton', PeertubeLinkButton)
97
98type PeertubePluginOptions = {
99 videoFiles: VideoFile[]
100 playerElement: HTMLVideoElement
101 autoplay: boolean
102 peerTubeLink: boolean
103}
104const 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
238videojsUntyped.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
4import { extname } from 'path'
5import * as MediaElementWrapper from 'mediasource'
6import * as videostream from 'videostream'
7
8const VIDEOSTREAM_EXTS = [
9 '.m4a',
10 '.m4v',
11 '.mp4'
12]
13
14type RenderMediaOptions = {
15 controls: boolean
16 autoplay: boolean
17}
18
19function 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
30function 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
93function 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
105function 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
117export {
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 @@
1import './embed.scss' 1import './embed.scss'
2 2
3import videojs from 'video.js' 3import videojs from 'video.js'
4import '../../assets/player/peertube-videojs-plugin'
4import 'videojs-dock/dist/videojs-dock.es.js' 5import 'videojs-dock/dist/videojs-dock.es.js'
5import * as WebTorrent from 'webtorrent'
6import { Video } from '../../../../shared' 6import { Video } from '../../../../shared'
7 7
8// videojs typings don't have some method we need 8function loadVideoInfo (videoId: string, callback: (err: Error, res?: Video) => void) {
9const videojsUntyped = videojs as any
10
11function 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
27function 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
51const urlParts = window.location.href.split('/') 24const urlParts = window.location.href.split('/')
52const videoId = urlParts[urlParts.length - 1] 25const videoId = urlParts[urlParts.length - 1]
53 26
54loadVideoInfos(videoId, (err, videoInfos) => { 27loadVideoInfo(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})
diff --git a/config/test-1.yaml b/config/test-1.yaml
index 4e9f29435..d9b4d2b1a 100644
--- a/config/test-1.yaml
+++ b/config/test-1.yaml
@@ -26,3 +26,6 @@ user:
26 26
27signup: 27signup:
28 limit: 4 28 limit: 4
29
30transcoding:
31 enabled: false
diff --git a/config/test-3.yaml b/config/test-3.yaml
index a29225a44..291b43edc 100644
--- a/config/test-3.yaml
+++ b/config/test-3.yaml
@@ -20,3 +20,6 @@ storage:
20 20
21admin: 21admin:
22 email: 'admin3@example.com' 22 email: 'admin3@example.com'
23
24transcoding:
25 enabled: false
diff --git a/config/test-4.yaml b/config/test-4.yaml
index da93e128d..6f80939fc 100644
--- a/config/test-4.yaml
+++ b/config/test-4.yaml
@@ -20,3 +20,6 @@ storage:
20 20
21admin: 21admin:
22 email: 'admin4@example.com' 22 email: 'admin4@example.com'
23
24transcoding:
25 enabled: false
diff --git a/config/test-5.yaml b/config/test-5.yaml
index f95e25eb8..0b5eab72e 100644
--- a/config/test-5.yaml
+++ b/config/test-5.yaml
@@ -20,3 +20,6 @@ storage:
20 20
21admin: 21admin:
22 email: 'admin5@example.com' 22 email: 'admin5@example.com'
23
24transcoding:
25 enabled: false
diff --git a/config/test-6.yaml b/config/test-6.yaml
index 87d054439..5d33e45b9 100644
--- a/config/test-6.yaml
+++ b/config/test-6.yaml
@@ -20,3 +20,6 @@ storage:
20 20
21admin: 21admin:
22 email: 'admin6@example.com' 22 email: 'admin6@example.com'
23
24transcoding:
25 enabled: false
diff --git a/config/test.yaml b/config/test.yaml
index 1a08d5ed1..feecb7883 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -10,3 +10,7 @@ database:
10 10
11signup: 11signup:
12 enabled: true 12 enabled: true
13
14transcoding:
15 enabled: true
16 threads: 4
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index b4a2b0c95..c376d769e 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -477,19 +477,26 @@ toFormattedJSON = function (this: VideoInstance) {
477 files: [] 477 files: []
478 } 478 }
479 479
480 this.VideoFiles.forEach(videoFile => { 480 // Format and sort video files
481 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution] 481 json.files = this.VideoFiles
482 if (!resolutionLabel) resolutionLabel = 'Unknown' 482 .map(videoFile => {
483 483 let resolutionLabel = VIDEO_FILE_RESOLUTIONS[videoFile.resolution]
484 const videoFileJson = { 484 if (!resolutionLabel) resolutionLabel = 'Unknown'
485 resolution: videoFile.resolution, 485
486 resolutionLabel, 486 const videoFileJson = {
487 magnetUri: this.generateMagnetUri(videoFile), 487 resolution: videoFile.resolution,
488 size: videoFile.size 488 resolutionLabel,
489 } 489 magnetUri: this.generateMagnetUri(videoFile),
490 490 size: videoFile.size
491 json.files.push(videoFileJson) 491 }
492 }) 492
493 return videoFileJson
494 })
495 .sort((a, b) => {
496 if (a.resolution < b.resolution) return 1
497 if (a.resolution === b.resolution) return 0
498 return -1
499 })
493 500
494 return json 501 return json
495} 502}
diff --git a/server/tests/api/multiple-pods.ts b/server/tests/api/multiple-pods.ts
index c43793b30..08fa73aa2 100644
--- a/server/tests/api/multiple-pods.ts
+++ b/server/tests/api/multiple-pods.ts
@@ -195,27 +195,27 @@ describe('Test multiple pods', function () {
195 const originalFile = video.files.find(f => f.resolution === 0) 195 const originalFile = video.files.find(f => f.resolution === 0)
196 expect(originalFile).not.to.be.undefined 196 expect(originalFile).not.to.be.undefined
197 expect(originalFile.resolutionLabel).to.equal('original') 197 expect(originalFile.resolutionLabel).to.equal('original')
198 expect(originalFile.size).to.equal(711327) 198 expect(originalFile.size).to.be.above(700000).and.below(720000)
199 199
200 const file240p = video.files.find(f => f.resolution === 240) 200 const file240p = video.files.find(f => f.resolution === 240)
201 expect(file240p).not.to.be.undefined 201 expect(file240p).not.to.be.undefined
202 expect(file240p.resolutionLabel).to.equal('240p') 202 expect(file240p.resolutionLabel).to.equal('240p')
203 expect(file240p.size).to.equal(139953) 203 expect(file240p.size).to.be.above(130000).and.below(150000)
204 204
205 const file360p = video.files.find(f => f.resolution === 360) 205 const file360p = video.files.find(f => f.resolution === 360)
206 expect(file360p).not.to.be.undefined 206 expect(file360p).not.to.be.undefined
207 expect(file360p.resolutionLabel).to.equal('360p') 207 expect(file360p.resolutionLabel).to.equal('360p')
208 expect(file360p.size).to.equal(169926) 208 expect(file360p.size).to.be.above(160000).and.below(180000)
209 209
210 const file480p = video.files.find(f => f.resolution === 480) 210 const file480p = video.files.find(f => f.resolution === 480)
211 expect(file480p).not.to.be.undefined 211 expect(file480p).not.to.be.undefined
212 expect(file480p.resolutionLabel).to.equal('480p') 212 expect(file480p.resolutionLabel).to.equal('480p')
213 expect(file480p.size).to.equal(206758) 213 expect(file480p.size).to.be.above(200000).and.below(220000)
214 214
215 const file720p = video.files.find(f => f.resolution === 720) 215 const file720p = video.files.find(f => f.resolution === 720)
216 expect(file720p).not.to.be.undefined 216 expect(file720p).not.to.be.undefined
217 expect(file720p.resolutionLabel).to.equal('720p') 217 expect(file720p.resolutionLabel).to.equal('720p')
218 expect(file720p.size).to.equal(314913) 218 expect(file720p.size).to.be.above(310000).and.below(320000)
219 219
220 const test = await testVideoImage(server.url, 'video_short2.webm', video.thumbnailPath) 220 const test = await testVideoImage(server.url, 'video_short2.webm', video.thumbnailPath)
221 expect(test).to.equal(true) 221 expect(test).to.equal(true)