Translate player according to the interface lang
[oweals/peertube.git] / client / src / app / videos / +video-watch / video-watch.component.ts
1 import { catchError } from 'rxjs/operators'
2 import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
3 import { ActivatedRoute, Router } from '@angular/router'
4 import { RedirectService } from '@app/core/routing/redirect.service'
5 import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
6 import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
7 import { MetaService } from '@ngx-meta/core'
8 import { NotificationsService } from 'angular2-notifications'
9 import { forkJoin, Subscription } from 'rxjs'
10 import * as videojs from 'video.js'
11 import 'videojs-hotkeys'
12 import * as WebTorrent from 'webtorrent'
13 import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoRateType, VideoState } from '../../../../../shared'
14 import '../../../assets/player/peertube-videojs-plugin'
15 import { AuthService, ConfirmService } from '../../core'
16 import { RestExtractor, VideoBlacklistService } from '../../shared'
17 import { VideoDetails } from '../../shared/video/video-details.model'
18 import { Video } from '../../shared/video/video.model'
19 import { VideoService } from '../../shared/video/video.service'
20 import { MarkdownService } from '../shared'
21 import { VideoDownloadComponent } from './modal/video-download.component'
22 import { VideoReportComponent } from './modal/video-report.component'
23 import { VideoShareComponent } from './modal/video-share.component'
24 import { VideoBlacklistComponent } from './modal/video-blacklist.component'
25 import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player'
26 import { ServerService } from '@app/core'
27 import { I18n } from '@ngx-translate/i18n-polyfill'
28 import { environment } from '../../../environments/environment'
29 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
30 import { VideoCaptionService } from '@app/shared/video-caption'
31
32 @Component({
33   selector: 'my-video-watch',
34   templateUrl: './video-watch.component.html',
35   styleUrls: [ './video-watch.component.scss' ]
36 })
37 export class VideoWatchComponent implements OnInit, OnDestroy {
38   private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
39
40   @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
41   @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
42   @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
43   @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
44   @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
45
46   otherVideosDisplayed: Video[] = []
47
48   player: videojs.Player
49   playerElement: HTMLVideoElement
50   userRating: UserVideoRateType = null
51   video: VideoDetails = null
52   descriptionLoading = false
53
54   completeDescriptionShown = false
55   completeVideoDescription: string
56   shortVideoDescription: string
57   videoHTMLDescription = ''
58   likesBarTooltipText = ''
59   hasAlreadyAcceptedPrivacyConcern = false
60   remoteServerDown = false
61
62   private videojsLocaleLoaded = false
63   private otherVideos: Video[] = []
64   private paramsSub: Subscription
65
66   constructor (
67     private elementRef: ElementRef,
68     private changeDetector: ChangeDetectorRef,
69     private route: ActivatedRoute,
70     private router: Router,
71     private videoService: VideoService,
72     private videoBlacklistService: VideoBlacklistService,
73     private confirmService: ConfirmService,
74     private metaService: MetaService,
75     private authService: AuthService,
76     private serverService: ServerService,
77     private restExtractor: RestExtractor,
78     private notificationsService: NotificationsService,
79     private markdownService: MarkdownService,
80     private zone: NgZone,
81     private redirectService: RedirectService,
82     private videoCaptionService: VideoCaptionService,
83     private i18n: I18n,
84     @Inject(LOCALE_ID) private localeId: string
85   ) {}
86
87   get user () {
88     return this.authService.getUser()
89   }
90
91   ngOnInit () {
92     if (
93       WebTorrent.WEBRTC_SUPPORT === false ||
94       peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true'
95     ) {
96       this.hasAlreadyAcceptedPrivacyConcern = true
97     }
98
99     this.videoService.getVideos({ currentPage: 1, itemsPerPage: 5 }, '-createdAt')
100         .subscribe(
101           data => {
102             this.otherVideos = data.videos
103             this.updateOtherVideosDisplayed()
104           },
105
106           err => console.error(err)
107         )
108
109     this.paramsSub = this.route.params.subscribe(routeParams => {
110       const uuid = routeParams[ 'uuid' ]
111
112       // Video did not change
113       if (this.video && this.video.uuid === uuid) return
114
115       if (this.player) this.player.pause()
116
117       // Video did change
118       forkJoin(
119         this.videoService.getVideo(uuid),
120         this.videoCaptionService.listCaptions(uuid)
121       )
122         .pipe(
123           // If 401, the video is private or blacklisted so redirect to 404
124           catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 404 ]))
125         )
126         .subscribe(([ video, captionsResult ]) => {
127           const startTime = this.route.snapshot.queryParams.start
128           this.onVideoFetched(video, captionsResult.data, startTime)
129               .catch(err => this.handleError(err))
130         })
131     })
132   }
133
134   ngOnDestroy () {
135     this.flushPlayer()
136
137     // Unsubscribe subscriptions
138     this.paramsSub.unsubscribe()
139   }
140
141   setLike () {
142     if (this.isUserLoggedIn() === false) return
143     if (this.userRating === 'like') {
144       // Already liked this video
145       this.setRating('none')
146     } else {
147       this.setRating('like')
148     }
149   }
150
151   setDislike () {
152     if (this.isUserLoggedIn() === false) return
153     if (this.userRating === 'dislike') {
154       // Already disliked this video
155       this.setRating('none')
156     } else {
157       this.setRating('dislike')
158     }
159   }
160
161   showMoreDescription () {
162     if (this.completeVideoDescription === undefined) {
163       return this.loadCompleteDescription()
164     }
165
166     this.updateVideoDescription(this.completeVideoDescription)
167     this.completeDescriptionShown = true
168   }
169
170   showLessDescription () {
171     this.updateVideoDescription(this.shortVideoDescription)
172     this.completeDescriptionShown = false
173   }
174
175   loadCompleteDescription () {
176     this.descriptionLoading = true
177
178     this.videoService.loadCompleteDescription(this.video.descriptionPath)
179         .subscribe(
180           description => {
181             this.completeDescriptionShown = true
182             this.descriptionLoading = false
183
184             this.shortVideoDescription = this.video.description
185             this.completeVideoDescription = description
186
187             this.updateVideoDescription(this.completeVideoDescription)
188           },
189
190           error => {
191             this.descriptionLoading = false
192             this.notificationsService.error(this.i18n('Error'), error.message)
193           }
194         )
195   }
196
197   showReportModal (event: Event) {
198     event.preventDefault()
199     this.videoReportModal.show()
200   }
201
202   showSupportModal () {
203     this.videoSupportModal.show()
204   }
205
206   showShareModal () {
207     this.videoShareModal.show()
208   }
209
210   showDownloadModal (event: Event) {
211     event.preventDefault()
212     this.videoDownloadModal.show()
213   }
214
215   showBlacklistModal (event: Event) {
216     event.preventDefault()
217     this.videoBlacklistModal.show()
218   }
219
220   async unblacklistVideo (event: Event) {
221     event.preventDefault()
222
223     const confirmMessage = this.i18n(
224       'Do you really want to remove this video from the blacklist? It will be available again in the videos list.'
225     )
226
227     const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblacklist'))
228     if (res === false) return
229
230     this.videoBlacklistService.removeVideoFromBlacklist(this.video.id).subscribe(
231       () => {
232         this.notificationsService.success(
233           this.i18n('Success'),
234           this.i18n('Video {{name}} removed from the blacklist.', { name: this.video.name })
235         )
236
237         this.video.blacklisted = false
238         this.video.blacklistedReason = null
239       },
240
241       err => this.notificationsService.error(this.i18n('Error'), err.message)
242     )
243   }
244
245   isUserLoggedIn () {
246     return this.authService.isLoggedIn()
247   }
248
249   isVideoUpdatable () {
250     return this.video.isUpdatableBy(this.authService.getUser())
251   }
252
253   isVideoBlacklistable () {
254     return this.video.isBlackistableBy(this.user)
255   }
256
257   isVideoUnblacklistable () {
258     return this.video.isUnblacklistableBy(this.user)
259   }
260
261   getVideoPoster () {
262     if (!this.video) return ''
263
264     return this.video.previewUrl
265   }
266
267   getVideoTags () {
268     if (!this.video || Array.isArray(this.video.tags) === false) return []
269
270     return this.video.tags
271   }
272
273   isVideoRemovable () {
274     return this.video.isRemovableBy(this.authService.getUser())
275   }
276
277   async removeVideo (event: Event) {
278     event.preventDefault()
279
280     const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
281     if (res === false) return
282
283     this.videoService.removeVideo(this.video.id)
284         .subscribe(
285           status => {
286             this.notificationsService.success(
287               this.i18n('Success'),
288               this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name })
289             )
290
291             // Go back to the video-list.
292             this.redirectService.redirectToHomepage()
293           },
294
295           error => this.notificationsService.error(this.i18n('Error'), error.message)
296         )
297   }
298
299   acceptedPrivacyConcern () {
300     peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true')
301     this.hasAlreadyAcceptedPrivacyConcern = true
302   }
303
304   isVideoToTranscode () {
305     return this.video && this.video.state.id === VideoState.TO_TRANSCODE
306   }
307
308   isVideoToImport () {
309     return this.video && this.video.state.id === VideoState.TO_IMPORT
310   }
311
312   hasVideoScheduledPublication () {
313     return this.video && this.video.scheduledUpdate !== undefined
314   }
315
316   private updateVideoDescription (description: string) {
317     this.video.description = description
318     this.setVideoDescriptionHTML()
319   }
320
321   private setVideoDescriptionHTML () {
322     this.videoHTMLDescription = this.markdownService.textMarkdownToHTML(this.video.description)
323   }
324
325   private setVideoLikesBarTooltipText () {
326     this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', {
327       likesNumber: this.video.likes,
328       dislikesNumber: this.video.dislikes
329     })
330   }
331
332   private handleError (err: any) {
333     const errorMessage: string = typeof err === 'string' ? err : err.message
334     if (!errorMessage) return
335
336     // Display a message in the video player instead of a notification
337     if (errorMessage.indexOf('from xs param') !== -1) {
338       this.flushPlayer()
339       this.remoteServerDown = true
340       this.changeDetector.detectChanges()
341
342       return
343     }
344
345     this.notificationsService.error(this.i18n('Error'), errorMessage)
346   }
347
348   private checkUserRating () {
349     // Unlogged users do not have ratings
350     if (this.isUserLoggedIn() === false) return
351
352     this.videoService.getUserVideoRating(this.video.id)
353         .subscribe(
354           ratingObject => {
355             if (ratingObject) {
356               this.userRating = ratingObject.rating
357             }
358           },
359
360           err => this.notificationsService.error(this.i18n('Error'), err.message)
361         )
362   }
363
364   private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) {
365     this.video = video
366
367     // Re init attributes
368     this.descriptionLoading = false
369     this.completeDescriptionShown = false
370     this.remoteServerDown = false
371
372     this.updateOtherVideosDisplayed()
373
374     if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) {
375       const res = await this.confirmService.confirm(
376         this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
377         this.i18n('Mature or explicit content')
378       )
379       if (res === false) return this.redirectService.redirectToHomepage()
380     }
381
382     // Flush old player if needed
383     this.flushPlayer()
384
385     // Build video element, because videojs remove it on dispose
386     const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper')
387     this.playerElement = document.createElement('video')
388     this.playerElement.className = 'video-js vjs-peertube-skin'
389     this.playerElement.setAttribute('playsinline', 'true')
390     playerElementWrapper.appendChild(this.playerElement)
391
392     const playerCaptions = videoCaptions.map(c => ({
393       label: c.language.label,
394       language: c.language.id,
395       src: environment.apiUrl + c.captionPath
396     }))
397
398     const videojsOptions = getVideojsOptions({
399       autoplay: this.isAutoplay(),
400       inactivityTimeout: 2500,
401       videoFiles: this.video.files,
402       videoCaptions: playerCaptions,
403       playerElement: this.playerElement,
404       videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
405       videoDuration: this.video.duration,
406       enableHotkeys: true,
407       peertubeLink: false,
408       poster: this.video.previewUrl,
409       startTime,
410       theaterMode: true,
411       language: this.localeId
412     })
413
414     if (this.videojsLocaleLoaded === false) {
415       await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId)
416       this.videojsLocaleLoaded = true
417     }
418
419     const self = this
420     this.zone.runOutsideAngular(async () => {
421       videojs(this.playerElement, videojsOptions, function () {
422         self.player = this
423         this.on('customError', (event, data) => self.handleError(data.err))
424
425         addContextMenu(self.player, self.video.embedUrl)
426       })
427     })
428
429     this.setVideoDescriptionHTML()
430     this.setVideoLikesBarTooltipText()
431
432     this.setOpenGraphTags()
433     this.checkUserRating()
434   }
435
436   private setRating (nextRating) {
437     let method
438     switch (nextRating) {
439       case 'like':
440         method = this.videoService.setVideoLike
441         break
442       case 'dislike':
443         method = this.videoService.setVideoDislike
444         break
445       case 'none':
446         method = this.videoService.unsetVideoLike
447         break
448     }
449
450     method.call(this.videoService, this.video.id)
451           .subscribe(
452             () => {
453               // Update the video like attribute
454               this.updateVideoRating(this.userRating, nextRating)
455               this.userRating = nextRating
456             },
457
458             err => this.notificationsService.error(this.i18n('Error'), err.message)
459           )
460   }
461
462   private updateVideoRating (oldRating: UserVideoRateType, newRating: VideoRateType) {
463     let likesToIncrement = 0
464     let dislikesToIncrement = 0
465
466     if (oldRating) {
467       if (oldRating === 'like') likesToIncrement--
468       if (oldRating === 'dislike') dislikesToIncrement--
469     }
470
471     if (newRating === 'like') likesToIncrement++
472     if (newRating === 'dislike') dislikesToIncrement++
473
474     this.video.likes += likesToIncrement
475     this.video.dislikes += dislikesToIncrement
476
477     this.video.buildLikeAndDislikePercents()
478     this.setVideoLikesBarTooltipText()
479   }
480
481   private updateOtherVideosDisplayed () {
482     if (this.video && this.otherVideos && this.otherVideos.length > 0) {
483       this.otherVideosDisplayed = this.otherVideos.filter(v => v.uuid !== this.video.uuid)
484     }
485   }
486
487   private setOpenGraphTags () {
488     this.metaService.setTitle(this.video.name)
489
490     this.metaService.setTag('og:type', 'video')
491
492     this.metaService.setTag('og:title', this.video.name)
493     this.metaService.setTag('name', this.video.name)
494
495     this.metaService.setTag('og:description', this.video.description)
496     this.metaService.setTag('description', this.video.description)
497
498     this.metaService.setTag('og:image', this.video.previewPath)
499
500     this.metaService.setTag('og:duration', this.video.duration.toString())
501
502     this.metaService.setTag('og:site_name', 'PeerTube')
503
504     this.metaService.setTag('og:url', window.location.href)
505     this.metaService.setTag('url', window.location.href)
506   }
507
508   private isAutoplay () {
509     // We'll jump to the thread id, so do not play the video
510     if (this.route.snapshot.params['threadId']) return false
511
512     // Otherwise true by default
513     if (!this.user) return true
514
515     // Be sure the autoPlay is set to false
516     return this.user.autoPlayVideo !== false
517   }
518
519   private flushPlayer () {
520     // Remove player if it exists
521     if (this.player) {
522       this.player.dispose()
523       this.player = undefined
524     }
525   }
526 }