]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - client/src/app/+videos/+video-watch/video-watch.component.ts
Reorganize watch components
[github/Chocobozzz/PeerTube.git] / client / src / app / +videos / +video-watch / video-watch.component.ts
CommitLineData
67ed6552 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
6ea59f41 2import { forkJoin, Subscription } from 'rxjs'
e972e046 3import { catchError } from 'rxjs/operators'
67ed6552 4import { PlatformLocation } from '@angular/common'
3b492bff 5import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
df98563e 6import { ActivatedRoute, Router } from '@angular/router'
a5cf76af
C
7import {
8 AuthService,
9 AuthUser,
10 ConfirmService,
0f01a8ba 11 MetaService,
a5cf76af
C
12 Notifier,
13 PeerTubeSocket,
72f611ca 14 PluginService,
a5cf76af 15 RestExtractor,
2666fd7c 16 ScreenService,
a5cf76af
C
17 ServerService,
18 UserService
19} from '@app/core'
67ed6552 20import { HooksService } from '@app/core/plugins/hooks.service'
901637bb 21import { RedirectService } from '@app/core/routing/redirect.service'
4504f09f 22import { isXPercentInViewport, scrollToTop } from '@app/helpers'
67ed6552 23import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
82f443de 24import { VideoShareComponent } from '@app/shared/shared-share-modal'
100d9ce2 25import { SupportModalComponent } from '@app/shared/shared-support-modal'
67ed6552 26import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
f8c00564 27import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
67ed6552 28import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
82f443de 29import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
a800dbf3 30import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
2989628b
C
31import {
32 HTMLServerConfig,
33 PeerTubeProblemDocument,
34 ServerErrorCode,
35 UserVideoRateType,
36 VideoCaption,
37 VideoPrivacy,
38 VideoState
39} from '@shared/models'
cdeddff1
C
40import {
41 cleanupVideoWatch,
42 getStoredP2PEnabled,
43 getStoredTheater,
44 getStoredVideoWatchHistory
45} from '../../../assets/player/peertube-player-local-storage'
6ec0b75b 46import {
5efab546 47 CustomizationOptions,
6ec0b75b
C
48 P2PMediaLoaderOptions,
49 PeertubePlayerManager,
50 PeertubePlayerManagerOptions,
67ed6552
C
51 PlayerMode,
52 videojs
6ec0b75b 53} from '../../../assets/player/peertube-player-manager'
5efab546 54import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
67ed6552 55import { environment } from '../../../environments/environment'
911186da 56import { VideoWatchPlaylistComponent } from './shared'
dc8bc31b 57
a5cf76af
C
58type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
59
dc8bc31b
C
60@Component({
61 selector: 'my-video-watch',
ec8d8440
C
62 templateUrl: './video-watch.component.html',
63 styleUrls: [ './video-watch.component.scss' ]
dc8bc31b 64})
0629423c 65export class VideoWatchComponent implements OnInit, OnDestroy {
22b59e80
C
66 private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
67
f36da21e 68 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
2f5d2ec5 69 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
100d9ce2 70 @ViewChild('supportModal') supportModal: SupportModalComponent
2f5d2ec5 71 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
6863f814 72 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
df98563e 73
2adfc7ea 74 player: any
0826c92d 75 playerElement: HTMLVideoElement
c15d61f5 76
9a18a625 77 theaterEnabled = false
c15d61f5 78
c15d61f5 79 playerPlaceholderImgSrc: string
2de96f4d 80
2f4c784a
C
81 video: VideoDetails = null
82 videoCaptions: VideoCaption[] = []
83
d142c7b9 84 playlistPosition: number
e2f01c47 85 playlist: VideoPlaylist = null
e2f01c47 86
c15d61f5 87 descriptionLoading = false
2de96f4d
C
88 completeDescriptionShown = false
89 completeVideoDescription: string
90 shortVideoDescription: string
9d9597df 91 videoHTMLDescription = ''
c15d61f5 92
e9189001 93 likesBarTooltipText = ''
c15d61f5 94
73e09f27 95 hasAlreadyAcceptedPrivacyConcern = false
6d88de72 96 remoteServerDown = false
c15d61f5 97
94dfca3e
RK
98 tooltipSupport = ''
99 tooltipSaveToPlaylist = ''
100
f8c00564
C
101 videoActionsOptions: VideoActionsDisplayType = {
102 playlist: false,
103 download: true,
104 update: true,
105 blacklist: true,
106 delete: true,
107 report: true,
108 duplicate: true,
109 mute: true,
110 liveInfo: true
111 }
112
6ea59f41
C
113 userRating: UserVideoRateType
114
6aa54148 115 private nextVideoUuid = ''
3bcb4fd7 116 private nextVideoTitle = ''
f0a39880 117 private currentTime: number
df98563e 118 private paramsSub: Subscription
e2f01c47 119 private queryParamsSub: Subscription
31b6ddf8 120 private configSub: Subscription
a5cf76af 121 private liveVideosSub: Subscription
df98563e 122
2989628b 123 private serverConfig: HTMLServerConfig
ba430d75 124
6ea59f41
C
125 private hotkeys: Hotkey[] = []
126
df98563e 127 constructor (
4fd8aa32 128 private elementRef: ElementRef,
3b492bff 129 private changeDetector: ChangeDetectorRef,
0629423c 130 private route: ActivatedRoute,
92fb909c 131 private router: Router,
d3ef341a 132 private videoService: VideoService,
e2f01c47 133 private playlistService: VideoPlaylistService,
92fb909c 134 private confirmService: ConfirmService,
3ec343a4 135 private metaService: MetaService,
7ddd02c9 136 private authService: AuthService,
d3217560 137 private userService: UserService,
0883b324 138 private serverService: ServerService,
a51bad1a 139 private restExtractor: RestExtractor,
f8b2c1b4 140 private notifier: Notifier,
901637bb 141 private zone: NgZone,
989e526a 142 private redirectService: RedirectService,
16f7022b 143 private videoCaptionService: VideoCaptionService,
20d21199 144 private hotkeysService: HotkeysService,
93cae479 145 private hooks: HooksService,
72f611ca 146 private pluginService: PluginService,
a5cf76af 147 private peertubeSocket: PeerTubeSocket,
2666fd7c 148 private screenService: ScreenService,
60c2bc80 149 private location: PlatformLocation,
e945b184 150 @Inject(LOCALE_ID) private localeId: string
2666fd7c 151 ) { }
dc8bc31b 152
b2731bff
C
153 get user () {
154 return this.authService.getUser()
155 }
156
d3217560
RK
157 get anonymousUser () {
158 return this.userService.getAnonymousUser()
159 }
160
18a6f04c 161 async ngOnInit () {
2666fd7c
C
162 // Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover
163 if (this.user || !this.screenService.isInMobileView()) {
2666fd7c
C
164 this.tooltipSupport = $localize`Support options for this video`
165 this.tooltipSaveToPlaylist = $localize`Save to playlist`
166 }
167
1a568b6f
C
168 PeertubePlayerManager.initState()
169
2989628b
C
170 this.serverConfig = this.serverService.getHTMLConfig()
171 if (
172 isWebRTCDisabled() ||
173 this.serverConfig.tracker.enabled === false ||
174 getStoredP2PEnabled() === false ||
175 peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true'
176 ) {
177 this.hasAlreadyAcceptedPrivacyConcern = true
178 }
179
13fc89f4 180 this.paramsSub = this.route.params.subscribe(routeParams => {
e2f01c47
C
181 const videoId = routeParams[ 'videoId' ]
182 if (videoId) this.loadVideo(videoId)
a51bad1a 183
e2f01c47
C
184 const playlistId = routeParams[ 'playlistId' ]
185 if (playlistId) this.loadPlaylist(playlistId)
186 })
bf079b7b 187
d142c7b9 188 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
e771e82d 189 // Handle the ?playlistPosition
e1a5ad70 190 const positionParam = queryParams[ 'playlistPosition' ] ?? 1
e771e82d
FC
191
192 this.playlistPosition = positionParam === 'last'
193 ? -1 // Handle the "last" index
e1a5ad70 194 : parseInt(positionParam + '', 10)
e771e82d
FC
195
196 if (isNaN(this.playlistPosition)) {
197 console.error(`playlistPosition query param '${positionParam}' was parsed as NaN, defaulting to 1.`)
198 this.playlistPosition = 1
199 }
200
d142c7b9 201 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
b29bf61d
RK
202
203 const start = queryParams[ 'start' ]
204 if (this.player && start) this.player.currentTime(parseInt(start, 10))
df98563e 205 })
20d21199 206
1c8ddbfa 207 this.initHotkeys()
011e1e6b
C
208
209 this.theaterEnabled = getStoredTheater()
18a6f04c 210
c9e3eeed 211 this.hooks.runAction('action:video-watch.init', 'video-watch')
58b9ce30 212
213 setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI
d1992b93
C
214 }
215
df98563e 216 ngOnDestroy () {
09edde40 217 this.flushPlayer()
067e3f84 218
13fc89f4 219 // Unsubscribe subscriptions
e2f01c47
C
220 if (this.paramsSub) this.paramsSub.unsubscribe()
221 if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
5abc96fc 222 if (this.configSub) this.configSub.unsubscribe()
a5cf76af 223 if (this.liveVideosSub) this.liveVideosSub.unsubscribe()
20d21199
RK
224
225 // Unbind hotkeys
3d216ea0 226 this.hotkeysService.remove(this.hotkeys)
dc8bc31b 227 }
98b01bac 228
6863f814
RK
229 showDownloadModal () {
230 this.videoDownloadModal.show(this.video, this.videoCaptions)
231 }
232
233 isVideoDownloadable () {
d846d99c 234 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled && !this.video.isLive
6863f814
RK
235 }
236
07fa4c97 237 showSupportModal () {
ca873292 238 this.supportModal.show()
07fa4c97
C
239 }
240
df98563e 241 showShareModal () {
951b582f 242 this.videoShareModal.show(this.currentTime, this.videoWatchPlaylist.currentPlaylistPosition)
99cc4f49
C
243 }
244
df98563e
C
245 isUserLoggedIn () {
246 return this.authService.isLoggedIn()
4f8c0eb0
C
247 }
248
de779034
RK
249 getVideoUrl () {
250 if (!this.video.url) {
d4a8e7a6 251 return this.video.originInstanceUrl + VideoDetails.buildWatchUrl(this.video)
de779034
RK
252 }
253 return this.video.url
254 }
255
b1fa3eba
C
256 getVideoTags () {
257 if (!this.video || Array.isArray(this.video.tags) === false) return []
258
4278710d 259 return this.video.tags
b1fa3eba
C
260 }
261
6aa54148
L
262 onRecommendations (videos: Video[]) {
263 if (videos.length > 0) {
3bcb4fd7
RK
264 // The recommended videos's first element should be the next video
265 const video = videos[0]
266 this.nextVideoUuid = video.uuid
267 this.nextVideoTitle = video.name
6aa54148
L
268 }
269 }
270
3a0fb65c
C
271 onVideoRemoved () {
272 this.redirectService.redirectToHomepage()
6725d05c
C
273 }
274
d3217560
RK
275 declinedPrivacyConcern () {
276 peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false')
277 this.hasAlreadyAcceptedPrivacyConcern = false
278 }
279
73e09f27 280 acceptedPrivacyConcern () {
0bd78bf3 281 peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true')
73e09f27
C
282 this.hasAlreadyAcceptedPrivacyConcern = true
283 }
284
2186386c
C
285 isVideoToTranscode () {
286 return this.video && this.video.state.id === VideoState.TO_TRANSCODE
287 }
288
516df59b
C
289 isVideoToImport () {
290 return this.video && this.video.state.id === VideoState.TO_IMPORT
291 }
292
bbe0f064
C
293 hasVideoScheduledPublication () {
294 return this.video && this.video.scheduledUpdate !== undefined
295 }
296
a5cf76af
C
297 isLive () {
298 return !!(this.video?.isLive)
299 }
300
301 isWaitingForLive () {
302 return this.video?.state.id === VideoState.WAITING_FOR_LIVE
303 }
304
305 isLiveEnded () {
306 return this.video?.state.id === VideoState.LIVE_ENDED
307 }
308
e2f01c47 309 isVideoBlur (video: Video) {
ba430d75 310 return video.isVideoNSFWForUser(this.user, this.serverConfig)
e2f01c47
C
311 }
312
706c5a47
RK
313 isAutoPlayEnabled () {
314 return (
7c93905d 315 (this.user && this.user.autoPlayNextVideo) ||
d3217560 316 this.anonymousUser.autoPlayNextVideo
706c5a47 317 )
b29bf61d
RK
318 }
319
320 handleTimestampClicked (timestamp: number) {
18429d01
C
321 if (!this.player || this.video.isLive) return
322
323 this.player.currentTime(timestamp)
b29bf61d 324 scrollToTop()
706c5a47
RK
325 }
326
327 isPlaylistAutoPlayEnabled () {
328 return (
7c93905d 329 (this.user && this.user.autoPlayNextVideoPlaylist) ||
d3217560 330 this.anonymousUser.autoPlayNextVideoPlaylist
706c5a47
RK
331 )
332 }
333
b40a2193
K
334 isChannelDisplayNameGeneric () {
335 const genericChannelDisplayName = [
336 `Main ${this.video.channel.ownerAccount.name} channel`,
337 `Default ${this.video.channel.ownerAccount.name} channel`
338 ]
339
340 return genericChannelDisplayName.includes(this.video.channel.displayName)
341 }
342
d142c7b9
C
343 onPlaylistVideoFound (videoId: string) {
344 this.loadVideo(videoId)
345 }
346
6ea59f41
C
347 onRateUpdated (userRating: UserVideoRateType) {
348 this.userRating = userRating
349 this.setVideoLikesBarTooltipText()
350 }
351
0f7407d9
C
352 displayOtherVideosAsRow () {
353 // Use the same value as in the SASS file
354 return this.screenService.getWindowInnerWidth() <= 1100
355 }
356
e2f01c47
C
357 private loadVideo (videoId: string) {
358 // Video did not change
d4a8e7a6
C
359 if (
360 this.video &&
361 (this.video.uuid === videoId || this.video.shortUUID === videoId)
362 ) return
e2f01c47
C
363
364 if (this.player) this.player.pause()
365
93cae479
C
366 const videoObs = this.hooks.wrapObsFun(
367 this.videoService.getVideo.bind(this.videoService),
368 { videoId },
369 'video-watch',
370 'filter:api.video-watch.video.get.params',
371 'filter:api.video-watch.video.get.result'
372 )
373
e2f01c47 374 // Video did change
c8861d5d 375 forkJoin([
93cae479 376 videoObs,
e2f01c47 377 this.videoCaptionService.listCaptions(videoId)
c8861d5d 378 ])
e2f01c47 379 .pipe(
ab398a05 380 // If 400, 403 or 404, the video is private or blocked so redirect to 404
e6abf95e 381 catchError(err => {
e030bfb5
C
382 const errorBody = err.body as PeerTubeProblemDocument
383
384 if (errorBody.code === ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS && errorBody.originUrl) {
e6abf95e 385 const search = window.location.search
e030bfb5 386 let originUrl = errorBody.originUrl
e6abf95e
C
387 if (search) originUrl += search
388
389 this.confirmService.confirm(
390 $localize`This video is not available on this instance. Do you want to be redirected on the origin instance: <a href="${originUrl}">${originUrl}</a>?`,
391 $localize`Redirection`
392 ).then(res => {
f2eb23cd 393 if (res === false) {
ab398a05 394 return this.restExtractor.redirectTo404IfNotFound(err, 'video', [
f2eb23cd 395 HttpStatusCode.BAD_REQUEST_400,
f2eb23cd
RK
396 HttpStatusCode.FORBIDDEN_403,
397 HttpStatusCode.NOT_FOUND_404
398 ])
399 }
e6abf95e
C
400
401 return window.location.href = originUrl
402 })
403 }
404
ab398a05 405 return this.restExtractor.redirectTo404IfNotFound(err, 'video', [
f2eb23cd 406 HttpStatusCode.BAD_REQUEST_400,
f2eb23cd
RK
407 HttpStatusCode.FORBIDDEN_403,
408 HttpStatusCode.NOT_FOUND_404
409 ])
e6abf95e 410 })
e2f01c47
C
411 )
412 .subscribe(([ video, captionsResult ]) => {
413 const queryParams = this.route.snapshot.queryParams
e2f01c47 414
4c72c1cd 415 const urlOptions = {
3c6a44a1
C
416 resume: queryParams.resume,
417
4c72c1cd
C
418 startTime: queryParams.start,
419 stopTime: queryParams.stop,
5efab546
C
420
421 muted: queryParams.muted,
422 loop: queryParams.loop,
4c72c1cd 423 subtitle: queryParams.subtitle,
5efab546
C
424
425 playerMode: queryParams.mode,
426 peertubeLink: false
4c72c1cd
C
427 }
428
429 this.onVideoFetched(video, captionsResult.data, urlOptions)
e2f01c47
C
430 .catch(err => this.handleError(err))
431 })
432 }
433
434 private loadPlaylist (playlistId: string) {
435 // Playlist did not change
d4a8e7a6
C
436 if (
437 this.playlist &&
438 (this.playlist.uuid === playlistId || this.playlist.shortUUID === playlistId)
439 ) return
e2f01c47
C
440
441 this.playlistService.getVideoPlaylist(playlistId)
442 .pipe(
ab398a05
RK
443 // If 400 or 403, the video is private or blocked so redirect to 404
444 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'video', [
f2eb23cd 445 HttpStatusCode.BAD_REQUEST_400,
f2eb23cd
RK
446 HttpStatusCode.FORBIDDEN_403,
447 HttpStatusCode.NOT_FOUND_404
448 ]))
e2f01c47
C
449 )
450 .subscribe(playlist => {
451 this.playlist = playlist
452
d142c7b9 453 this.videoWatchPlaylist.loadPlaylistElements(playlist, !this.playlistPosition, this.playlistPosition)
e2f01c47
C
454 })
455 }
456
e9189001 457 private setVideoLikesBarTooltipText () {
66357162 458 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
e9189001
C
459 }
460
0c31c33d
C
461 private handleError (err: any) {
462 const errorMessage: string = typeof err === 'string' ? err : err.message
bf5685f0
C
463 if (!errorMessage) return
464
6d88de72 465 // Display a message in the video player instead of a notification
0f7fedc3 466 if (errorMessage.indexOf('from xs param') !== -1) {
6d88de72
C
467 this.flushPlayer()
468 this.remoteServerDown = true
3b492bff
C
469 this.changeDetector.detectChanges()
470
6d88de72 471 return
0c31c33d
C
472 }
473
f8b2c1b4 474 this.notifier.error(errorMessage)
0c31c33d
C
475 }
476
597a9266
C
477 private async onVideoFetched (
478 video: VideoDetails,
479 videoCaptions: VideoCaption[],
a5cf76af 480 urlOptions: URLOptions
597a9266 481 ) {
a5cf76af
C
482 this.subscribeToLiveEventsIfNeeded(this.video, video)
483
df98563e 484 this.video = video
2f4c784a 485 this.videoCaptions = videoCaptions
92fb909c 486
c448d412 487 // Re init attributes
c15d61f5 488 this.playerPlaceholderImgSrc = undefined
c448d412
C
489 this.descriptionLoading = false
490 this.completeDescriptionShown = false
06bee937 491 this.completeVideoDescription = undefined
6d88de72 492 this.remoteServerDown = false
f0a39880 493 this.currentTime = undefined
c448d412 494
e2f01c47 495 if (this.isVideoBlur(this.video)) {
22b59e80 496 const res = await this.confirmService.confirm(
66357162
C
497 $localize`This video contains mature or explicit content. Are you sure you want to watch it?`,
498 $localize`Mature or explicit content`
d6e32a2e 499 )
60c2bc80 500 if (res === false) return this.location.back()
92fb909c
C
501 }
502
0a6817f0
C
503 this.buildPlayer(urlOptions)
504 .catch(err => console.error('Cannot build the player', err))
505
0a6817f0
C
506 this.setVideoLikesBarTooltipText()
507
508 this.setOpenGraphTags()
0a6817f0 509
55b84d53
C
510 const hookOptions = {
511 videojs,
512 video: this.video,
513 playlist: this.playlist
514 }
515 this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions)
0a6817f0
C
516 }
517
518 private async buildPlayer (urlOptions: URLOptions) {
09edde40
C
519 // Flush old player if needed
520 this.flushPlayer()
b891f9bc 521
c15d61f5
C
522 const videoState = this.video.state.id
523 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
524 this.playerPlaceholderImgSrc = this.video.previewPath
525 return
526 }
527
60c2bc80 528 // Build video element, because videojs removes it on dispose
e2f01c47 529 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
b891f9bc
C
530 this.playerElement = document.createElement('video')
531 this.playerElement.className = 'video-js vjs-peertube-skin'
e7eb5b39 532 this.playerElement.setAttribute('playsinline', 'true')
b891f9bc
C
533 playerElementWrapper.appendChild(this.playerElement)
534
3d9a63d3
C
535 const params = {
536 video: this.video,
0a6817f0 537 videoCaptions: this.videoCaptions,
3d9a63d3
C
538 urlOptions,
539 user: this.user
e945b184 540 }
3d9a63d3
C
541 const { playerMode, playerOptions } = await this.hooks.wrapFun(
542 this.buildPlayerManagerOptions.bind(this),
543 params,
c2023a9f
C
544 'video-watch',
545 'filter:internal.video-watch.player.build-options.params',
3d9a63d3
C
546 'filter:internal.video-watch.player.build-options.result'
547 )
e945b184 548
e945b184 549 this.zone.runOutsideAngular(async () => {
3d9a63d3 550 this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
9a18a625 551
2adfc7ea 552 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
f0a39880
C
553
554 this.player.on('timeupdate', () => {
555 this.currentTime = Math.floor(this.player.currentTime())
556 })
e2f01c47 557
3bcb4fd7
RK
558 /**
559 * replaces this.player.one('ended')
223b24e6
RK
560 * 'condition()': true to make the upnext functionality trigger,
561 * false to disable the upnext functionality
562 * go to the next video in 'condition()' if you don't want of the timer.
563 * 'next': function triggered at the end of the timer.
564 * 'suspended': function used at each clic of the timer checking if we need
565 * to reset progress and wait until 'suspended' becomes truthy again.
3bcb4fd7
RK
566 */
567 this.player.upnext({
ddefb8c9 568 timeout: 10000, // 10s
66357162
C
569 headText: $localize`Up Next`,
570 cancelText: $localize`Cancel`,
571 suspendedText: $localize`Autoplay is suspended`,
3bcb4fd7
RK
572 getTitle: () => this.nextVideoTitle,
573 next: () => this.zone.run(() => this.autoplayNext()),
574 condition: () => {
575 if (this.playlist) {
576 if (this.isPlaylistAutoPlayEnabled()) {
577 // upnext will not trigger, and instead the next video will play immediately
578 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
579 }
580 } else if (this.isAutoPlayEnabled()) {
581 return true // upnext will trigger
582 }
583 return false // upnext will not trigger, and instead leave the video stopping
223b24e6
RK
584 },
585 suspended: () => {
586 return (
587 !isXPercentInViewport(this.player.el(), 80) ||
588 !document.getElementById('content').contains(document.activeElement)
589 )
e2f01c47
C
590 }
591 })
592
593 this.player.one('stopped', () => {
594 if (this.playlist) {
706c5a47 595 if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
e2f01c47
C
596 }
597 })
9a18a625 598
e772bdf1
C
599 this.player.one('ended', () => {
600 if (this.video.isLive) {
ceb8f322 601 this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED)
e772bdf1
C
602 }
603 })
604
9a18a625
C
605 this.player.on('theaterChange', (_: any, enabled: boolean) => {
606 this.zone.run(() => this.theaterEnabled = enabled)
607 })
5f85f8aa 608
781ba981 609 this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player, videojs, video: this.video })
b891f9bc 610 })
92fb909c
C
611 }
612
6aa54148 613 private autoplayNext () {
6dd873d6
RK
614 if (this.playlist) {
615 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
616 } else if (this.nextVideoUuid) {
a1eda903 617 this.router.navigate([ '/w', this.nextVideoUuid ])
6aa54148
L
618 }
619 }
620
df98563e
C
621 private setOpenGraphTags () {
622 this.metaService.setTitle(this.video.name)
758b996d 623
df98563e 624 this.metaService.setTag('og:type', 'video')
3ec343a4 625
df98563e
C
626 this.metaService.setTag('og:title', this.video.name)
627 this.metaService.setTag('name', this.video.name)
3ec343a4 628
df98563e
C
629 this.metaService.setTag('og:description', this.video.description)
630 this.metaService.setTag('description', this.video.description)
3ec343a4 631
d38309c3 632 this.metaService.setTag('og:image', this.video.previewPath)
3ec343a4 633
df98563e 634 this.metaService.setTag('og:duration', this.video.duration.toString())
3ec343a4 635
df98563e 636 this.metaService.setTag('og:site_name', 'PeerTube')
3ec343a4 637
df98563e
C
638 this.metaService.setTag('og:url', window.location.href)
639 this.metaService.setTag('url', window.location.href)
3ec343a4 640 }
1f3e9fec 641
d4c6a3b9 642 private isAutoplay () {
bf079b7b
C
643 // We'll jump to the thread id, so do not play the video
644 if (this.route.snapshot.params['threadId']) return false
645
646 // Otherwise true by default
d4c6a3b9
C
647 if (!this.user) return true
648
649 // Be sure the autoPlay is set to false
650 return this.user.autoPlayVideo !== false
651 }
09edde40
C
652
653 private flushPlayer () {
654 // Remove player if it exists
d4a8e7a6
C
655 if (!this.player) return
656
657 try {
658 this.player.dispose()
659 this.player = undefined
660 } catch (err) {
661 console.error('Cannot dispose player.', err)
09edde40
C
662 }
663 }
1c8ddbfa 664
3d9a63d3
C
665 private buildPlayerManagerOptions (params: {
666 video: VideoDetails,
667 videoCaptions: VideoCaption[],
668 urlOptions: CustomizationOptions & { playerMode: PlayerMode },
669 user?: AuthUser
670 }) {
671 const { video, videoCaptions, urlOptions, user } = params
706c5a47
RK
672 const getStartTime = () => {
673 const byUrl = urlOptions.startTime !== undefined
96f6278f 674 const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined)
58b9ce30 675 const byLocalStorage = getStoredVideoWatchHistory(video.uuid)
706c5a47 676
3c6a44a1
C
677 if (byUrl) return timeToInt(urlOptions.startTime)
678 if (byHistory) return video.userHistory.currentTime
58b9ce30 679 if (byLocalStorage) return byLocalStorage.duration
3c6a44a1
C
680
681 return 0
706c5a47 682 }
3d9a63d3 683
706c5a47 684 let startTime = getStartTime()
3c6a44a1 685
3d9a63d3
C
686 // If we are at the end of the video, reset the timer
687 if (video.duration - startTime <= 1) startTime = 0
688
689 const playerCaptions = videoCaptions.map(c => ({
690 label: c.language.label,
691 language: c.language.id,
692 src: environment.apiUrl + c.captionPath
693 }))
694
695 const options: PeertubePlayerManagerOptions = {
696 common: {
697 autoplay: this.isAutoplay(),
1dc240a9 698 nextVideo: () => this.zone.run(() => this.autoplayNext()),
3d9a63d3
C
699
700 playerElement: this.playerElement,
701 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
702
703 videoDuration: video.duration,
704 enableHotkeys: true,
705 inactivityTimeout: 2500,
706 poster: video.previewUrl,
707
708 startTime,
709 stopTime: urlOptions.stopTime,
710 controls: urlOptions.controls,
711 muted: urlOptions.muted,
712 loop: urlOptions.loop,
713 subtitle: urlOptions.subtitle,
714
715 peertubeLink: urlOptions.peertubeLink,
716
717 theaterButton: true,
718 captions: videoCaptions.length !== 0,
719
720 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
721 ? this.videoService.getVideoViewUrl(video.uuid)
722 : null,
723 embedUrl: video.embedUrl,
4097c6d6 724 embedTitle: video.name,
3d9a63d3 725
25b7c847
C
726 isLive: video.isLive,
727
3d9a63d3
C
728 language: this.localeId,
729
730 userWatching: user && user.videosHistoryEnabled === true ? {
731 url: this.videoService.getUserWatchingVideoUrl(video.uuid),
732 authorizationHeader: this.authService.getRequestHeaderValue()
733 } : undefined,
734
735 serverUrl: environment.apiUrl,
736
58b9ce30 737 videoCaptions: playerCaptions,
738
3e0e8d4a 739 videoUUID: video.uuid
3d9a63d3
C
740 },
741
742 webtorrent: {
743 videoFiles: video.files
72f611ca 744 },
745
746 pluginsManager: this.pluginService.getPluginsManager()
3d9a63d3
C
747 }
748
dfdcbb94 749 // Only set this if we're in a playlist
5bb2ed6b
P
750 if (this.playlist) {
751 options.common.previousVideo = () => {
752 this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
753 }
dfdcbb94
P
754 }
755
3d9a63d3
C
756 let mode: PlayerMode
757
758 if (urlOptions.playerMode) {
759 if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
760 else mode = 'webtorrent'
761 } else {
762 if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
763 else mode = 'webtorrent'
764 }
765
089af69b
C
766 // p2p-media-loader needs TextEncoder, try to fallback on WebTorrent
767 if (typeof TextEncoder === 'undefined') {
768 mode = 'webtorrent'
769 }
770
3d9a63d3
C
771 if (mode === 'p2p-media-loader') {
772 const hlsPlaylist = video.getHlsPlaylist()
773
774 const p2pMediaLoader = {
775 playlistUrl: hlsPlaylist.playlistUrl,
776 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
777 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
778 trackerAnnounce: video.trackerUrls,
779 videoFiles: hlsPlaylist.files
780 } as P2PMediaLoaderOptions
781
782 Object.assign(options, { p2pMediaLoader })
783 }
784
785 return { playerMode: mode, playerOptions: options }
786 }
787
a5cf76af
C
788 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
789 if (!this.liveVideosSub) {
a800dbf3 790 this.liveVideosSub = this.buildLiveEventsSubscription()
a5cf76af
C
791 }
792
793 if (oldVideo && oldVideo.id !== newVideo.id) {
794 await this.peertubeSocket.unsubscribeLiveVideos(oldVideo.id)
795 }
796
797 if (!newVideo.isLive) return
798
799 await this.peertubeSocket.subscribeToLiveVideosSocket(newVideo.id)
800 }
801
a800dbf3
C
802 private buildLiveEventsSubscription () {
803 return this.peertubeSocket.getLiveVideosObservable()
804 .subscribe(({ type, payload }) => {
805 if (type === 'state-change') return this.handleLiveStateChange(payload.state)
806 if (type === 'views-change') return this.handleLiveViewsChange(payload.views)
807 })
808 }
809
810 private handleLiveStateChange (newState: VideoState) {
811 if (newState !== VideoState.PUBLISHED) return
812
813 const videoState = this.video.state.id
814 if (videoState !== VideoState.WAITING_FOR_LIVE && videoState !== VideoState.LIVE_ENDED) return
815
816 console.log('Loading video after live update.')
817
818 const videoUUID = this.video.uuid
819
820 // Reset to refetch the video
821 this.video = undefined
822 this.loadVideo(videoUUID)
823 }
824
825 private handleLiveViewsChange (newViews: number) {
826 if (!this.video) {
827 console.error('Cannot update video live views because video is no defined.')
828 return
829 }
830
e43b5a3f
C
831 console.log('Updating live views.')
832
a800dbf3
C
833 this.video.views = newViews
834 }
835
941c5eac
C
836 private initHotkeys () {
837 this.hotkeys = [
941c5eac 838 // These hotkeys are managed by the player
66357162
C
839 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen (requires player focus)`),
840 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video (requires player focus)`),
841 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video (requires player focus)`),
941c5eac 842
66357162 843 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90% (requires player focus)`),
941c5eac 844
66357162
C
845 new Hotkey('up', e => e, undefined, $localize`Increase the volume (requires player focus)`),
846 new Hotkey('down', e => e, undefined, $localize`Decrease the volume (requires player focus)`),
941c5eac 847
66357162
C
848 new Hotkey('right', e => e, undefined, $localize`Seek the video forward (requires player focus)`),
849 new Hotkey('left', e => e, undefined, $localize`Seek the video backward (requires player focus)`),
941c5eac 850
66357162
C
851 new Hotkey('>', e => e, undefined, $localize`Increase playback rate (requires player focus)`),
852 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate (requires player focus)`),
941c5eac 853
66357162 854 new Hotkey('.', e => e, undefined, $localize`Navigate in the video frame by frame (requires player focus)`)
941c5eac 855 ]
3d216ea0
C
856
857 if (this.isUserLoggedIn()) {
858 this.hotkeys = this.hotkeys.concat([
3d216ea0
C
859 new Hotkey('shift+s', () => {
860 this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe()
861 return false
66357162 862 }, undefined, $localize`Subscribe to the account`)
3d216ea0
C
863 ])
864 }
865
866 this.hotkeysService.add(this.hotkeys)
941c5eac 867 }
dc8bc31b 868}