Handle subtitles in player
authorChocobozzz <me@florianbigard.com>
Fri, 13 Jul 2018 16:21:19 +0000 (18:21 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 16 Jul 2018 09:50:08 +0000 (11:50 +0200)
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/peertube-player.ts
client/src/assets/player/peertube-videojs-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/settings-menu-item.ts
client/src/sass/player/settings-menu.scss
client/src/standalone/videos/embed.ts
scripts/i18n/create-custom-files.ts

index 8d476393f39c47fd867cf484b15d221e3c18165d..c77249a0286544696daba5922d80508d6c38d463 100644 (file)
@@ -213,6 +213,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       servicesTwitterUsername: this.customConfig.services.twitter.username,
       servicesTwitterWhitelisted: this.customConfig.services.twitter.whitelisted,
       cachePreviewsSize: this.customConfig.cache.previews.size,
+      cacheCaptionsSize: this.customConfig.cache.captions.size,
       signupEnabled: this.customConfig.signup.enabled,
       signupLimit: this.customConfig.signup.limit,
       adminEmail: this.customConfig.admin.email,
index 8adf97d480dfcd4bb5d993252e504623f35a9343..601c6a38da342803f9e3d016dc52490e981cdcc6 100644 (file)
@@ -6,11 +6,11 @@ import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
 import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
 import { MetaService } from '@ngx-meta/core'
 import { NotificationsService } from 'angular2-notifications'
-import { Subscription } from 'rxjs'
+import { forkJoin, Subscription } from 'rxjs'
 import * as videojs from 'video.js'
 import 'videojs-hotkeys'
 import * as WebTorrent from 'webtorrent'
-import { UserVideoRateType, VideoPrivacy, VideoRateType, VideoState } from '../../../../../shared'
+import { ResultList, UserVideoRateType, VideoPrivacy, VideoRateType, VideoState } from '../../../../../shared'
 import '../../../assets/player/peertube-videojs-plugin'
 import { AuthService, ConfirmService } from '../../core'
 import { RestExtractor, VideoBlacklistService } from '../../shared'
@@ -26,6 +26,9 @@ import { ServerService } from '@app/core'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { environment } from '../../../environments/environment'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
+import { VideoCaptionService } from '@app/shared/video-caption'
+import { VideoCaption } from '../../../../../shared/models/videos/video-caption.model'
+import { VideoJSCaption } from '../../../assets/player/peertube-videojs-typings'
 
 @Component({
   selector: 'my-video-watch',
@@ -74,6 +77,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     private markdownService: MarkdownService,
     private zone: NgZone,
     private redirectService: RedirectService,
+    private videoCaptionService: VideoCaptionService,
     private i18n: I18n,
     @Inject(LOCALE_ID) private localeId: string
   ) {}
@@ -109,14 +113,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       if (this.player) this.player.pause()
 
       // Video did change
-      this.videoService
-          .getVideo(uuid)
-          .pipe(catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ])))
-          .subscribe(video => {
-            const startTime = this.route.snapshot.queryParams.start
-            this.onVideoFetched(video, startTime)
-                .catch(err => this.handleError(err))
-          })
+      forkJoin(
+        this.videoService.getVideo(uuid),
+        this.videoCaptionService.listCaptions(uuid)
+      )
+        .pipe(
+          catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
+        )
+        .subscribe(([ video, captionsResult ]) => {
+          const startTime = this.route.snapshot.queryParams.start
+          this.onVideoFetched(video, captionsResult.data, startTime)
+              .catch(err => this.handleError(err))
+        })
     })
   }
 
@@ -331,7 +339,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
         )
   }
 
-  private async onVideoFetched (video: VideoDetails, startTime = 0) {
+  private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], startTime = 0) {
     this.video = video
 
     // Re init attributes
@@ -358,10 +366,17 @@ 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 videojsOptions = getVideojsOptions({
       autoplay: this.isAutoplay(),
       inactivityTimeout: 2500,
       videoFiles: this.video.files,
+      videoCaptions: playerCaptions,
       playerElement: this.playerElement,
       videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
       videoDuration: this.video.duration,
index baae740fe64846e6b8f2a03d842eee0155473fd5..bf02ce91c695f31fc08285afda98af717fc86b5e 100644 (file)
@@ -11,12 +11,16 @@ import './webtorrent-info-button'
 import './peertube-videojs-plugin'
 import './peertube-load-progress-bar'
 import './theater-button'
-import { videojsUntyped } from './peertube-videojs-typings'
+import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
 import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
 import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
 
 // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
 videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed'
+// Change Captions to Subtitles/CC
+videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitles/CC'
+// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
+videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
 
 function getVideojsOptions (options: {
   autoplay: boolean,
@@ -30,11 +34,14 @@ function getVideojsOptions (options: {
   poster: string,
   startTime: number
   theaterMode: boolean,
+  videoCaptions: VideoJSCaption[],
   controls?: boolean,
   muted?: boolean,
   loop?: boolean
 }) {
   const videojsOptions = {
+    // We don't use text track settings for now
+    textTrackSettings: false,
     controls: options.controls !== undefined ? options.controls : true,
     muted: options.controls !== undefined ? options.muted : false,
     loop: options.loop !== undefined ? options.loop : false,
@@ -45,6 +52,7 @@ function getVideojsOptions (options: {
     plugins: {
       peertube: {
         autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
+        videoCaptions: options.videoCaptions,
         videoFiles: options.videoFiles,
         playerElement: options.playerElement,
         videoViewUrl: options.videoViewUrl,
@@ -71,8 +79,16 @@ function getVideojsOptions (options: {
 
 function getControlBarChildren (options: {
   peertubeLink: boolean
-  theaterMode: boolean
+  theaterMode: boolean,
+  videoCaptions: VideoJSCaption[]
 }) {
+  const settingEntries = []
+
+  // Keep an order
+  settingEntries.push('playbackRateMenuButton')
+  if (options.videoCaptions.length !== 0) settingEntries.push('captionsButton')
+  settingEntries.push('resolutionMenuButton')
+
   const children = {
     'playToggle': {},
     'currentTimeDisplay': {},
@@ -102,10 +118,7 @@ function getControlBarChildren (options: {
       setup: {
         maxHeightOffset: 40
       },
-      entries: [
-        'resolutionMenuButton',
-        'playbackRateMenuButton'
-      ]
+      entries: settingEntries
     }
   }
 
index 57c894ee6eeadc96da8b2b936ce10a0c1808c522..3f6fc4cc6c2b307e2a9c136c47ffd1de25485ed7 100644 (file)
@@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
 import { VideoFile } from '../../../../shared/models/videos/video.model'
 import { renderVideo } from './video-renderer'
 import './settings-menu-button'
-import { PeertubePluginOptions, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
+import { PeertubePluginOptions, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
 import { isMobile, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
 import * as CacheChunkStore from 'cache-chunk-store'
 import { PeertubeChunkStore } from './peertube-chunk-store'
@@ -54,6 +54,7 @@ class PeerTubePlugin extends Plugin {
   private player: any
   private currentVideoFile: VideoFile
   private torrent: WebTorrent.Torrent
+  private videoCaptions: VideoJSCaption[]
   private renderer
   private fakeRenderer
   private autoResolution = true
@@ -79,6 +80,7 @@ class PeerTubePlugin extends Plugin {
     this.videoFiles = options.videoFiles
     this.videoViewUrl = options.videoViewUrl
     this.videoDuration = options.videoDuration
+    this.videoCaptions = options.videoCaptions
 
     this.savePlayerSrcFunction = this.player.src
     // Hack to "simulate" src link in video.js >= 6
@@ -421,6 +423,8 @@ class PeerTubePlugin extends Plugin {
 
     this.initSmoothProgressBar()
 
+    this.initCaptions()
+
     this.alterInactivity()
 
     if (this.autoplay === true) {
@@ -581,7 +585,7 @@ class PeerTubePlugin extends Plugin {
       this.player.options_.inactivityTimeout = 0
     }
     const enableInactivity = () => {
-      this.player.options_.inactivityTimeout = saveInactivityTimeout
+      // this.player.options_.inactivityTimeout = saveInactivityTimeout
     }
 
     const settingsDialog = this.player.children_.find(c => c.name_ === 'SettingsDialog')
@@ -611,6 +615,18 @@ class PeerTubePlugin extends Plugin {
     }
   }
 
+  private initCaptions () {
+    for (const caption of this.videoCaptions) {
+      this.player.addRemoteTextTrack({
+        kind: 'captions',
+        label: caption.label,
+        language: caption.language,
+        id: caption.language,
+        src: caption.src
+      }, false)
+    }
+  }
+
   // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
   private initSmoothProgressBar () {
     const SeekBar = videojsUntyped.getComponent('SeekBar')
index 50d6039ea0fee0da0d27c75c6c988d053098a222..9c029923772beb0452778e80e99bce681bbbe7c4 100644 (file)
@@ -16,13 +16,20 @@ interface VideoJSComponentInterface {
   registerComponent (name: string, obj: any)
 }
 
+type VideoJSCaption = {
+  label: string
+  language: string
+  src: string
+}
+
 type PeertubePluginOptions = {
   videoFiles: VideoFile[]
   playerElement: HTMLVideoElement
   videoViewUrl: string
   videoDuration: number
   startTime: number
-  autoplay: boolean
+  autoplay: boolean,
+  videoCaptions: VideoJSCaption[]
 }
 
 // videojs typings don't have some method we need
@@ -31,5 +38,6 @@ const videojsUntyped = videojs as any
 export {
   VideoJSComponentInterface,
   PeertubePluginOptions,
-  videojsUntyped
+  videojsUntyped,
+  VideoJSCaption
 }
index 88985e1ae5336f32034d74e9bbc2f1149dff3122..6e2224e20f29f0c2a931876d06070a3c4511d4f8 100644 (file)
@@ -32,6 +32,8 @@ class SettingsMenuItem extends MenuItem {
       throw new Error(`Component ${subMenuName} does not exist`)
     }
     this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this)
+    const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0]
+    this.settingsSubMenuEl_.className += ' ' + subMenuClass
 
     this.eventHandlers()
 
index 0c064c18256429196c49960c92422551047ece6e..d065e72fb1178f0225e770cda6b517bbbec98b88 100644 (file)
@@ -52,6 +52,7 @@ $setting-transition-easing: ease-out;
     .vjs-settings-sub-menu-title {
       display: table-cell;
       padding: 0 5px;
+      text-transform: capitalize;
     }
 
     .vjs-settings-sub-menu-title {
@@ -141,15 +142,15 @@ $setting-transition-easing: ease-out;
         .vjs-menu-item {
           outline: 0;
           font-weight: $font-semibold;
-
-          padding: 5px 8px;
           text-align: right;
+          padding: 5px 8px;
 
           &.vjs-back-button {
             background-color: inherit;
-            padding: 8px 8px 13px 8px;
+            padding: 8px 8px 13px 12px;
             margin-bottom: 5px;
             border-bottom: 1px solid grey;
+            text-align: left;
 
             &::before {
               @include chevron-left(9px, 2px);
@@ -174,6 +175,25 @@ $setting-transition-easing: ease-out;
             }
           }
         }
+
+        // Special captions case
+        // Bigger caption button
+        &.vjs-captions-button {
+          width: 200px;
+
+          .vjs-menu-item {
+            text-align: left;
+
+            .vjs-menu-item-text {
+              margin-left: 25px;
+              text-transform: capitalize;
+            }
+          }
+        }
+
+        .vjs-menu {
+          width: inherit;
+        }
       }
     }
   }
index a4196600afa271f2b9ea21af140149c559146305..1275998b87a2830c65602a96116839b8666aa528 100644 (file)
@@ -20,9 +20,11 @@ import 'whatwg-fetch'
 import * as vjs from 'video.js'
 import * as Channel from 'jschannel'
 
-import { VideoDetails } from '../../../../shared'
+import { ResultList, VideoDetails } from '../../../../shared'
 import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player'
 import { PeerTubeResolution } from '../player/definitions'
+import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
+import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
 
 /**
  * Embed API exposes control of the embed player to the outside world via
@@ -178,6 +180,10 @@ class PeerTubeEmbed {
     return fetch(this.getVideoUrl(videoId))
   }
 
+  loadVideoCaptions (videoId: string): Promise<Response> {
+    return fetch(this.getVideoUrl(videoId) + '/captions')
+  }
+
   removeElement (element: HTMLElement) {
     element.parentElement.removeChild(element)
   }
@@ -254,15 +260,27 @@ class PeerTubeEmbed {
     const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[ 0 ]
 
     await loadLocale(window.location.origin, vjs, navigator.language)
-    let response = await this.loadVideoInfo(videoId)
+    const [ videoResponse, captionsResponse ] = await Promise.all([
+      this.loadVideoInfo(videoId),
+      this.loadVideoCaptions(videoId)
+    ])
 
-    if (!response.ok) {
-      if (response.status === 404) return this.videoNotFound(this.videoElement)
+    if (!videoResponse.ok) {
+      if (videoResponse.status === 404) return this.videoNotFound(this.videoElement)
 
       return this.videoFetchError(this.videoElement)
     }
 
-    const videoInfo: VideoDetails = await response.json()
+    const videoInfo: VideoDetails = await videoResponse.json()
+    let videoCaptions: VideoJSCaption[] = []
+    if (captionsResponse.ok) {
+      const { data } = (await captionsResponse.json()) as ResultList<VideoCaption>
+      videoCaptions = data.map(c => ({
+        label: c.language.label,
+        language: c.language.id,
+        src: window.location.origin + c.captionPath
+      }))
+    }
 
     this.loadParams()
 
@@ -273,6 +291,7 @@ class PeerTubeEmbed {
       loop: this.loop,
       startTime: this.startTime,
 
+      videoCaptions,
       inactivityTimeout: 1500,
       videoViewUrl: this.getVideoUrl(videoId) + '/views',
       playerElement: this.videoElement,
@@ -297,6 +316,7 @@ class PeerTubeEmbed {
       }
 
       addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
+
       this.initializeApi()
     })
   }
index 7d994a710356469d2d72447314f616a141dc83f2..a297fa79cf45967017b56de1359d597cf64a70c3 100755 (executable)
@@ -14,6 +14,7 @@ const playerKeys = {
   'Quality': 'Quality',
   'Auto': 'Auto',
   'Speed': 'Speed',
+  'Subtitles/CC': 'Subtitles/CC',
   'peers': 'peers',
   'Go to the video page': 'Go to the video page',
   'Settings': 'Settings',