]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - client/src/app/videos/+video-watch/video-watch.component.ts
Provide native links for description timestamps, and re-clickability for these
[github/Chocobozzz/PeerTube.git] / client / src / app / videos / +video-watch / video-watch.component.ts
index 80aa8ebe47685be3ded7bd7a41524a493585b0bc..3a7629cc64585ee6260b59b771ae5a48e81a92d8 100644 (file)
@@ -2,10 +2,10 @@ import { catchError } from 'rxjs/operators'
 import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { RedirectService } from '@app/core/routing/redirect.service'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage'
 import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
 import { MetaService } from '@ngx-meta/core'
-import { Notifier, ServerService } from '@app/core'
+import { AuthUser, Notifier, ServerService } from '@app/core'
 import { forkJoin, Observable, Subscription } from 'rxjs'
 import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
@@ -37,6 +37,8 @@ import { PluginService } from '@app/core/plugins/plugin.service'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { PlatformLocation } from '@angular/common'
 import { randomInt } from '@shared/core-utils/miscs/miscs'
+import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component'
+import { scrollToTop } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-video-watch',
@@ -69,7 +71,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   likesBarTooltipText = ''
   hasAlreadyAcceptedPrivacyConcern = false
   remoteServerDown = false
-  hotkeys: Hotkey[]
+  hotkeys: Hotkey[] = []
+
+  tooltipLike = ''
+  tooltipDislike = ''
+  tooltipSupport = ''
+  tooltipSaveToPlaylist = ''
 
   private nextVideoUuid = ''
   private currentTime: number
@@ -101,7 +108,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     private hooks: HooksService,
     private location: PlatformLocation,
     @Inject(LOCALE_ID) private localeId: string
-  ) {}
+  ) {
+    this.tooltipLike = this.i18n('Like this video')
+    this.tooltipDislike = this.i18n('Dislike this video')
+    this.tooltipSupport = this.i18n('Support options for this video')
+    this.tooltipSaveToPlaylist = this.i18n('Save to playlist')
+  }
 
   get user () {
     return this.authService.getUser()
@@ -127,9 +139,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       if (playlistId) this.loadPlaylist(playlistId)
     })
 
-    this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
+    this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => {
       const videoId = queryParams[ 'videoId' ]
-      if (videoId) this.loadVideo(videoId)
+      if (videoId) await this.loadVideo(videoId)
+
+      const start = queryParams[ 'start' ]
+      if (this.player && start) this.player.currentTime(parseInt(start, 10))
     })
 
     this.initHotkeys()
@@ -147,7 +162,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
 
     // Unbind hotkeys
-    if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
+    this.hotkeysService.remove(this.hotkeys)
   }
 
   setLike () {
@@ -166,6 +181,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     else this.setRating('dislike')
   }
 
+  getRatePopoverText () {
+    if (this.isUserLoggedIn()) return undefined
+
+    return this.i18n('You need to be connected to rate this content.')
+  }
+
   showMoreDescription () {
     if (this.completeVideoDescription === undefined) {
       return this.loadCompleteDescription()
@@ -203,10 +224,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   }
 
   showSupportModal () {
+    this.pausePlayer()
+
     this.videoSupportModal.show()
   }
 
   showShareModal () {
+    this.pausePlayer()
+
     this.videoShareModal.show(this.currentTime)
   }
 
@@ -227,6 +252,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     }
   }
 
+  onModalOpened () {
+    this.pausePlayer()
+  }
+
   onVideoRemoved () {
     this.redirectService.redirectToHomepage()
   }
@@ -252,6 +281,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     return video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
   }
 
+  isAutoPlayEnabled () {
+    return (
+      (this.user && this.user.autoPlayNextVideo) ||
+      peertubeSessionStorage.getItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
+    )
+  }
+
+  handleTimestampClicked (timestamp: number) {
+    if (this.player) this.player.currentTime(timestamp)
+    scrollToTop()
+  }
+
+  isPlaylistAutoPlayEnabled () {
+    return (
+      (this.user && this.user.autoPlayNextVideoPlaylist) ||
+      peertubeSessionStorage.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true'
+    )
+  }
+
   private loadVideo (videoId: string) {
     // Video did not change
     if (this.video && this.video.uuid === videoId) return
@@ -319,7 +367,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
   }
 
   private async setVideoDescriptionHTML () {
-    this.videoHTMLDescription = await this.markdownService.textMarkdownToHTML(this.video.description)
+    const html = await this.markdownService.textMarkdownToHTML(this.video.description)
+    this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
   }
 
   private setVideoLikesBarTooltipText () {
@@ -377,10 +426,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
     this.videoWatchPlaylist.updatePlaylistIndex(video)
 
-    let startTime = timeToInt(urlOptions.startTime) || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
-    // If we are at the end of the video, reset the timer
-    if (this.video.duration - startTime <= 1) startTime = 0
-
     if (this.isVideoBlur(this.video)) {
       const res = await this.confirmService.confirm(
         this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
@@ -399,84 +444,22 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     this.playerElement.setAttribute('playsinline', 'true')
     playerElementWrapper.appendChild(this.playerElement)
 
-    const playerCaptions = videoCaptions.map(c => ({
-      label: c.language.label,
-      language: c.language.id,
-      src: environment.apiUrl + c.captionPath
-    }))
-
-    const options: PeertubePlayerManagerOptions = {
-      common: {
-        autoplay: this.isAutoplay(),
-
-        playerElement: this.playerElement,
-        onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
-
-        videoDuration: this.video.duration,
-        enableHotkeys: true,
-        inactivityTimeout: 2500,
-        poster: this.video.previewUrl,
-
-        startTime,
-        stopTime: urlOptions.stopTime,
-        controls: urlOptions.controls,
-        muted: urlOptions.muted,
-        loop: urlOptions.loop,
-        subtitle: urlOptions.subtitle,
-
-        peertubeLink: urlOptions.peertubeLink,
-
-        theaterMode: true,
-        captions: videoCaptions.length !== 0,
-
-        videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE
-          ? this.videoService.getVideoViewUrl(this.video.uuid)
-          : null,
-        embedUrl: this.video.embedUrl,
-
-        language: this.localeId,
-
-        userWatching: this.user && this.user.videosHistoryEnabled === true ? {
-          url: this.videoService.getUserWatchingVideoUrl(this.video.uuid),
-          authorizationHeader: this.authService.getRequestHeaderValue()
-        } : undefined,
-
-        serverUrl: environment.apiUrl,
-
-        videoCaptions: playerCaptions
-      },
-
-      webtorrent: {
-        videoFiles: this.video.files
-      }
-    }
-
-    let mode: PlayerMode
-
-    if (urlOptions.playerMode) {
-      if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
-      else mode = 'webtorrent'
-    } else {
-      if (this.video.hasHlsPlaylist()) mode = 'p2p-media-loader'
-      else mode = 'webtorrent'
-    }
-
-    if (mode === 'p2p-media-loader') {
-      const hlsPlaylist = this.video.getHlsPlaylist()
-
-      const p2pMediaLoader = {
-        playlistUrl: hlsPlaylist.playlistUrl,
-        segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
-        redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
-        trackerAnnounce: this.video.trackerUrls,
-        videoFiles: hlsPlaylist.files
-      } as P2PMediaLoaderOptions
-
-      Object.assign(options, { p2pMediaLoader })
+    const params = {
+      video: this.video,
+      videoCaptions,
+      urlOptions,
+      user: this.user
     }
+    const { playerMode, playerOptions } = await this.hooks.wrapFun(
+      this.buildPlayerManagerOptions.bind(this),
+      params,
+      'video-watch',
+      'filter:internal.video-watch.player.build-options.params',
+      'filter:internal.video-watch.player.build-options.result'
+    )
 
     this.zone.runOutsideAngular(async () => {
-      this.player = await PeertubePlayerManager.initialize(mode, options, player => this.player = player)
+      this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
       this.player.focus()
 
       this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
@@ -487,15 +470,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
 
       this.player.one('ended', () => {
         if (this.playlist) {
-          this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
-        } else if (this.user && this.user.autoPlayNextVideo) {
+          if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
+        } else if (this.isAutoPlayEnabled()) {
           this.zone.run(() => this.autoplayNext())
         }
       })
 
       this.player.one('stopped', () => {
         if (this.playlist) {
-          this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
+          if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
         }
       })
 
@@ -603,23 +586,155 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     }
   }
 
+  private buildPlayerManagerOptions (params: {
+    video: VideoDetails,
+    videoCaptions: VideoCaption[],
+    urlOptions: CustomizationOptions & { playerMode: PlayerMode },
+    user?: AuthUser
+  }) {
+    const { video, videoCaptions, urlOptions, user } = params
+    const getStartTime = () => {
+      const byUrl = urlOptions.startTime !== undefined
+      const byHistory = video.userHistory && !this.playlist
+
+      if (byUrl) {
+        return timeToInt(urlOptions.startTime)
+      } else if (byHistory) {
+        return video.userHistory.currentTime
+      } else {
+        return 0
+      }
+    }
+
+    let startTime = getStartTime()
+    // If we are at the end of the video, reset the timer
+    if (video.duration - startTime <= 1) startTime = 0
+
+    const playerCaptions = videoCaptions.map(c => ({
+      label: c.language.label,
+      language: c.language.id,
+      src: environment.apiUrl + c.captionPath
+    }))
+
+    const options: PeertubePlayerManagerOptions = {
+      common: {
+        autoplay: this.isAutoplay(),
+
+        playerElement: this.playerElement,
+        onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
+
+        videoDuration: video.duration,
+        enableHotkeys: true,
+        inactivityTimeout: 2500,
+        poster: video.previewUrl,
+
+        startTime,
+        stopTime: urlOptions.stopTime,
+        controls: urlOptions.controls,
+        muted: urlOptions.muted,
+        loop: urlOptions.loop,
+        subtitle: urlOptions.subtitle,
+
+        peertubeLink: urlOptions.peertubeLink,
+
+        theaterButton: true,
+        captions: videoCaptions.length !== 0,
+
+        videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
+          ? this.videoService.getVideoViewUrl(video.uuid)
+          : null,
+        embedUrl: video.embedUrl,
+
+        language: this.localeId,
+
+        userWatching: user && user.videosHistoryEnabled === true ? {
+          url: this.videoService.getUserWatchingVideoUrl(video.uuid),
+          authorizationHeader: this.authService.getRequestHeaderValue()
+        } : undefined,
+
+        serverUrl: environment.apiUrl,
+
+        videoCaptions: playerCaptions
+      },
+
+      webtorrent: {
+        videoFiles: video.files
+      }
+    }
+
+    let mode: PlayerMode
+
+    if (urlOptions.playerMode) {
+      if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
+      else mode = 'webtorrent'
+    } else {
+      if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
+      else mode = 'webtorrent'
+    }
+
+    if (mode === 'p2p-media-loader') {
+      const hlsPlaylist = video.getHlsPlaylist()
+
+      const p2pMediaLoader = {
+        playlistUrl: hlsPlaylist.playlistUrl,
+        segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
+        redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
+        trackerAnnounce: video.trackerUrls,
+        videoFiles: hlsPlaylist.files
+      } as P2PMediaLoaderOptions
+
+      Object.assign(options, { p2pMediaLoader })
+    }
+
+    return { playerMode: mode, playerOptions: options }
+  }
+
+  private pausePlayer () {
+    if (!this.player) return
+
+    this.player.pause()
+  }
+
   private initHotkeys () {
     this.hotkeys = [
-      new Hotkey('shift+l', () => {
-        this.setLike()
-        return false
-      }, undefined, this.i18n('Like the video')),
-
-      new Hotkey('shift+d', () => {
-        this.setDislike()
-        return false
-      }, undefined, this.i18n('Dislike the video')),
-
-      new Hotkey('shift+s', () => {
-        this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe()
-        return false
-      }, undefined, this.i18n('Subscribe to the account'))
+      // These hotkeys are managed by the player
+      new Hotkey('f', e => e, undefined, this.i18n('Enter/exit fullscreen (requires player focus)')),
+      new Hotkey('space', e => e, undefined, this.i18n('Play/Pause the video (requires player focus)')),
+      new Hotkey('m', e => e, undefined, this.i18n('Mute/unmute the video (requires player focus)')),
+
+      new Hotkey('0-9', e => e, undefined, this.i18n('Skip to a percentage of the video: 0 is 0% and 9 is 90% (requires player focus)')),
+
+      new Hotkey('up', e => e, undefined, this.i18n('Increase the volume (requires player focus)')),
+      new Hotkey('down', e => e, undefined, this.i18n('Decrease the volume (requires player focus)')),
+
+      new Hotkey('right', e => e, undefined, this.i18n('Seek the video forward (requires player focus)')),
+      new Hotkey('left', e => e, undefined, this.i18n('Seek the video backward (requires player focus)')),
+
+      new Hotkey('>', e => e, undefined, this.i18n('Increase playback rate (requires player focus)')),
+      new Hotkey('<', e => e, undefined, this.i18n('Decrease playback rate (requires player focus)')),
+
+      new Hotkey('.', e => e, undefined, this.i18n('Navigate in the video frame by frame (requires player focus)'))
     ]
-    if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys)
+
+    if (this.isUserLoggedIn()) {
+      this.hotkeys = this.hotkeys.concat([
+        new Hotkey('shift+l', () => {
+          this.setLike()
+          return false
+        }, undefined, this.i18n('Like the video')),
+
+        new Hotkey('shift+d', () => {
+          this.setDislike()
+          return false
+        }, undefined, this.i18n('Dislike the video')),
+
+        new Hotkey('shift+s', () => {
+          this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe()
+          return false
+        }, undefined, this.i18n('Subscribe to the account'))
+      ])
+    }
+
+    this.hotkeysService.add(this.hotkeys)
   }
 }