diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 8d476393f..c77249a02 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -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, 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 8adf97d48..601c6a38d 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -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, diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts index baae740fe..bf02ce91c 100644 --- a/client/src/assets/player/peertube-player.ts +++ b/client/src/assets/player/peertube-player.ts @@ -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 } } diff --git a/client/src/assets/player/peertube-videojs-plugin.ts b/client/src/assets/player/peertube-videojs-plugin.ts index 57c894ee6..3f6fc4cc6 100644 --- a/client/src/assets/player/peertube-videojs-plugin.ts +++ b/client/src/assets/player/peertube-videojs-plugin.ts @@ -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') diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 50d6039ea..9c0299237 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -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 } diff --git a/client/src/assets/player/settings-menu-item.ts b/client/src/assets/player/settings-menu-item.ts index 88985e1ae..6e2224e20 100644 --- a/client/src/assets/player/settings-menu-item.ts +++ b/client/src/assets/player/settings-menu-item.ts @@ -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() diff --git a/client/src/sass/player/settings-menu.scss b/client/src/sass/player/settings-menu.scss index 0c064c182..d065e72fb 100644 --- a/client/src/sass/player/settings-menu.scss +++ b/client/src/sass/player/settings-menu.scss @@ -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; + } } } } diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index a4196600a..1275998b8 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -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 { + 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 + 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() }) } diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 7d994a710..a297fa79c 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -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',