Handle subtitles in player

This commit is contained in:
Chocobozzz 2018-07-13 18:21:19 +02:00
parent 40e87e9ecc
commit 16f7022b06
9 changed files with 125 additions and 29 deletions

View File

@ -213,6 +213,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
servicesTwitterUsername: this.customConfig.services.twitter.username, servicesTwitterUsername: this.customConfig.services.twitter.username,
servicesTwitterWhitelisted: this.customConfig.services.twitter.whitelisted, servicesTwitterWhitelisted: this.customConfig.services.twitter.whitelisted,
cachePreviewsSize: this.customConfig.cache.previews.size, cachePreviewsSize: this.customConfig.cache.previews.size,
cacheCaptionsSize: this.customConfig.cache.captions.size,
signupEnabled: this.customConfig.signup.enabled, signupEnabled: this.customConfig.signup.enabled,
signupLimit: this.customConfig.signup.limit, signupLimit: this.customConfig.signup.limit,
adminEmail: this.customConfig.admin.email, adminEmail: this.customConfig.admin.email,

View 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 { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
import { MetaService } from '@ngx-meta/core' import { MetaService } from '@ngx-meta/core'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
import { Subscription } from 'rxjs' import { forkJoin, Subscription } from 'rxjs'
import * as videojs from 'video.js' import * as videojs from 'video.js'
import 'videojs-hotkeys' import 'videojs-hotkeys'
import * as WebTorrent from 'webtorrent' 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 '../../../assets/player/peertube-videojs-plugin'
import { AuthService, ConfirmService } from '../../core' import { AuthService, ConfirmService } from '../../core'
import { RestExtractor, VideoBlacklistService } from '../../shared' import { RestExtractor, VideoBlacklistService } from '../../shared'
@ -26,6 +26,9 @@ import { ServerService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 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({ @Component({
selector: 'my-video-watch', selector: 'my-video-watch',
@ -74,6 +77,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private markdownService: MarkdownService, private markdownService: MarkdownService,
private zone: NgZone, private zone: NgZone,
private redirectService: RedirectService, private redirectService: RedirectService,
private videoCaptionService: VideoCaptionService,
private i18n: I18n, private i18n: I18n,
@Inject(LOCALE_ID) private localeId: string @Inject(LOCALE_ID) private localeId: string
) {} ) {}
@ -109,12 +113,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
if (this.player) this.player.pause() if (this.player) this.player.pause()
// Video did change // Video did change
this.videoService forkJoin(
.getVideo(uuid) this.videoService.getVideo(uuid),
.pipe(catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))) this.videoCaptionService.listCaptions(uuid)
.subscribe(video => { )
.pipe(
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 404 ]))
)
.subscribe(([ video, captionsResult ]) => {
const startTime = this.route.snapshot.queryParams.start const startTime = this.route.snapshot.queryParams.start
this.onVideoFetched(video, startTime) this.onVideoFetched(video, captionsResult.data, startTime)
.catch(err => this.handleError(err)) .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 this.video = video
// Re init attributes // Re init attributes
@ -358,10 +366,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.playerElement.setAttribute('playsinline', 'true') this.playerElement.setAttribute('playsinline', 'true')
playerElementWrapper.appendChild(this.playerElement) 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({ const videojsOptions = getVideojsOptions({
autoplay: this.isAutoplay(), autoplay: this.isAutoplay(),
inactivityTimeout: 2500, inactivityTimeout: 2500,
videoFiles: this.video.files, videoFiles: this.video.files,
videoCaptions: playerCaptions,
playerElement: this.playerElement, playerElement: this.playerElement,
videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null, videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null,
videoDuration: this.video.duration, videoDuration: this.video.duration,

View File

@ -11,12 +11,16 @@ import './webtorrent-info-button'
import './peertube-videojs-plugin' import './peertube-videojs-plugin'
import './peertube-load-progress-bar' import './peertube-load-progress-bar'
import './theater-button' import './theater-button'
import { videojsUntyped } from './peertube-videojs-typings' import { VideoJSCaption, videojsUntyped } from './peertube-videojs-typings'
import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils' import { buildVideoEmbed, buildVideoLink, copyToClipboard } from './utils'
import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n' import { getCompleteLocale, getShortLocale, is18nLocale, isDefaultLocale } from '../../../../shared/models/i18n/i18n'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) // Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
videojsUntyped.getComponent('PlaybackRateMenuButton').prototype.controlText_ = 'Speed' 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: { function getVideojsOptions (options: {
autoplay: boolean, autoplay: boolean,
@ -30,11 +34,14 @@ function getVideojsOptions (options: {
poster: string, poster: string,
startTime: number startTime: number
theaterMode: boolean, theaterMode: boolean,
videoCaptions: VideoJSCaption[],
controls?: boolean, controls?: boolean,
muted?: boolean, muted?: boolean,
loop?: boolean loop?: boolean
}) { }) {
const videojsOptions = { const videojsOptions = {
// We don't use text track settings for now
textTrackSettings: false,
controls: options.controls !== undefined ? options.controls : true, controls: options.controls !== undefined ? options.controls : true,
muted: options.controls !== undefined ? options.muted : false, muted: options.controls !== undefined ? options.muted : false,
loop: options.loop !== undefined ? options.loop : false, loop: options.loop !== undefined ? options.loop : false,
@ -45,6 +52,7 @@ function getVideojsOptions (options: {
plugins: { plugins: {
peertube: { peertube: {
autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent autoplay: options.autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
videoCaptions: options.videoCaptions,
videoFiles: options.videoFiles, videoFiles: options.videoFiles,
playerElement: options.playerElement, playerElement: options.playerElement,
videoViewUrl: options.videoViewUrl, videoViewUrl: options.videoViewUrl,
@ -71,8 +79,16 @@ function getVideojsOptions (options: {
function getControlBarChildren (options: { function getControlBarChildren (options: {
peertubeLink: boolean 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 = { const children = {
'playToggle': {}, 'playToggle': {},
'currentTimeDisplay': {}, 'currentTimeDisplay': {},
@ -102,10 +118,7 @@ function getControlBarChildren (options: {
setup: { setup: {
maxHeightOffset: 40 maxHeightOffset: 40
}, },
entries: [ entries: settingEntries
'resolutionMenuButton',
'playbackRateMenuButton'
]
} }
} }

View File

@ -3,7 +3,7 @@ import * as WebTorrent from 'webtorrent'
import { VideoFile } from '../../../../shared/models/videos/video.model' import { VideoFile } from '../../../../shared/models/videos/video.model'
import { renderVideo } from './video-renderer' import { renderVideo } from './video-renderer'
import './settings-menu-button' 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 { isMobile, videoFileMaxByResolution, videoFileMinByResolution } from './utils'
import * as CacheChunkStore from 'cache-chunk-store' import * as CacheChunkStore from 'cache-chunk-store'
import { PeertubeChunkStore } from './peertube-chunk-store' import { PeertubeChunkStore } from './peertube-chunk-store'
@ -54,6 +54,7 @@ class PeerTubePlugin extends Plugin {
private player: any private player: any
private currentVideoFile: VideoFile private currentVideoFile: VideoFile
private torrent: WebTorrent.Torrent private torrent: WebTorrent.Torrent
private videoCaptions: VideoJSCaption[]
private renderer private renderer
private fakeRenderer private fakeRenderer
private autoResolution = true private autoResolution = true
@ -79,6 +80,7 @@ class PeerTubePlugin extends Plugin {
this.videoFiles = options.videoFiles this.videoFiles = options.videoFiles
this.videoViewUrl = options.videoViewUrl this.videoViewUrl = options.videoViewUrl
this.videoDuration = options.videoDuration this.videoDuration = options.videoDuration
this.videoCaptions = options.videoCaptions
this.savePlayerSrcFunction = this.player.src this.savePlayerSrcFunction = this.player.src
// Hack to "simulate" src link in video.js >= 6 // Hack to "simulate" src link in video.js >= 6
@ -421,6 +423,8 @@ class PeerTubePlugin extends Plugin {
this.initSmoothProgressBar() this.initSmoothProgressBar()
this.initCaptions()
this.alterInactivity() this.alterInactivity()
if (this.autoplay === true) { if (this.autoplay === true) {
@ -581,7 +585,7 @@ class PeerTubePlugin extends Plugin {
this.player.options_.inactivityTimeout = 0 this.player.options_.inactivityTimeout = 0
} }
const enableInactivity = () => { const enableInactivity = () => {
this.player.options_.inactivityTimeout = saveInactivityTimeout // this.player.options_.inactivityTimeout = saveInactivityTimeout
} }
const settingsDialog = this.player.children_.find(c => c.name_ === 'SettingsDialog') 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 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
private initSmoothProgressBar () { private initSmoothProgressBar () {
const SeekBar = videojsUntyped.getComponent('SeekBar') const SeekBar = videojsUntyped.getComponent('SeekBar')

View File

@ -16,13 +16,20 @@ interface VideoJSComponentInterface {
registerComponent (name: string, obj: any) registerComponent (name: string, obj: any)
} }
type VideoJSCaption = {
label: string
language: string
src: string
}
type PeertubePluginOptions = { type PeertubePluginOptions = {
videoFiles: VideoFile[] videoFiles: VideoFile[]
playerElement: HTMLVideoElement playerElement: HTMLVideoElement
videoViewUrl: string videoViewUrl: string
videoDuration: number videoDuration: number
startTime: number startTime: number
autoplay: boolean autoplay: boolean,
videoCaptions: VideoJSCaption[]
} }
// videojs typings don't have some method we need // videojs typings don't have some method we need
@ -31,5 +38,6 @@ const videojsUntyped = videojs as any
export { export {
VideoJSComponentInterface, VideoJSComponentInterface,
PeertubePluginOptions, PeertubePluginOptions,
videojsUntyped videojsUntyped,
VideoJSCaption
} }

View File

@ -32,6 +32,8 @@ class SettingsMenuItem extends MenuItem {
throw new Error(`Component ${subMenuName} does not exist`) throw new Error(`Component ${subMenuName} does not exist`)
} }
this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this) this.subMenu = new SubMenuComponent(this.player(), options, menuButton, this)
const subMenuClass = this.subMenu.buildCSSClass().split(' ')[0]
this.settingsSubMenuEl_.className += ' ' + subMenuClass
this.eventHandlers() this.eventHandlers()

View File

@ -52,6 +52,7 @@ $setting-transition-easing: ease-out;
.vjs-settings-sub-menu-title { .vjs-settings-sub-menu-title {
display: table-cell; display: table-cell;
padding: 0 5px; padding: 0 5px;
text-transform: capitalize;
} }
.vjs-settings-sub-menu-title { .vjs-settings-sub-menu-title {
@ -141,15 +142,15 @@ $setting-transition-easing: ease-out;
.vjs-menu-item { .vjs-menu-item {
outline: 0; outline: 0;
font-weight: $font-semibold; font-weight: $font-semibold;
padding: 5px 8px;
text-align: right; text-align: right;
padding: 5px 8px;
&.vjs-back-button { &.vjs-back-button {
background-color: inherit; background-color: inherit;
padding: 8px 8px 13px 8px; padding: 8px 8px 13px 12px;
margin-bottom: 5px; margin-bottom: 5px;
border-bottom: 1px solid grey; border-bottom: 1px solid grey;
text-align: left;
&::before { &::before {
@include chevron-left(9px, 2px); @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;
}
} }
} }
} }

View File

@ -20,9 +20,11 @@ import 'whatwg-fetch'
import * as vjs from 'video.js' import * as vjs from 'video.js'
import * as Channel from 'jschannel' import * as Channel from 'jschannel'
import { VideoDetails } from '../../../../shared' import { ResultList, VideoDetails } from '../../../../shared'
import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player' import { addContextMenu, getVideojsOptions, loadLocale } from '../../assets/player/peertube-player'
import { PeerTubeResolution } from '../player/definitions' 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 * Embed API exposes control of the embed player to the outside world via
@ -178,6 +180,10 @@ class PeerTubeEmbed {
return fetch(this.getVideoUrl(videoId)) return fetch(this.getVideoUrl(videoId))
} }
loadVideoCaptions (videoId: string): Promise<Response> {
return fetch(this.getVideoUrl(videoId) + '/captions')
}
removeElement (element: HTMLElement) { removeElement (element: HTMLElement) {
element.parentElement.removeChild(element) element.parentElement.removeChild(element)
} }
@ -254,15 +260,27 @@ class PeerTubeEmbed {
const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[ 0 ] const videoId = lastPart.indexOf('?') === -1 ? lastPart : lastPart.split('?')[ 0 ]
await loadLocale(window.location.origin, vjs, navigator.language) 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 (!videoResponse.ok) {
if (response.status === 404) return this.videoNotFound(this.videoElement) if (videoResponse.status === 404) return this.videoNotFound(this.videoElement)
return this.videoFetchError(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() this.loadParams()
@ -273,6 +291,7 @@ class PeerTubeEmbed {
loop: this.loop, loop: this.loop,
startTime: this.startTime, startTime: this.startTime,
videoCaptions,
inactivityTimeout: 1500, inactivityTimeout: 1500,
videoViewUrl: this.getVideoUrl(videoId) + '/views', videoViewUrl: this.getVideoUrl(videoId) + '/views',
playerElement: this.videoElement, playerElement: this.videoElement,
@ -297,6 +316,7 @@ class PeerTubeEmbed {
} }
addContextMenu(this.player, window.location.origin + videoInfo.embedPath) addContextMenu(this.player, window.location.origin + videoInfo.embedPath)
this.initializeApi() this.initializeApi()
}) })
} }

View File

@ -14,6 +14,7 @@ const playerKeys = {
'Quality': 'Quality', 'Quality': 'Quality',
'Auto': 'Auto', 'Auto': 'Auto',
'Speed': 'Speed', 'Speed': 'Speed',
'Subtitles/CC': 'Subtitles/CC',
'peers': 'peers', 'peers': 'peers',
'Go to the video page': 'Go to the video page', 'Go to the video page': 'Go to the video page',
'Settings': 'Settings', 'Settings': 'Settings',