2022-03-04 12:40:02 +00:00
import { forkJoin , map , Observable , of , Subscription , switchMap } from 'rxjs'
2020-06-23 12:10:17 +00:00
import { PlatformLocation } from '@angular/common'
2021-06-30 07:49:45 +00:00
import { Component , ElementRef , Inject , LOCALE_ID , NgZone , OnDestroy , OnInit , ViewChild } from '@angular/core'
2017-06-16 12:32:15 +00:00
import { ActivatedRoute , Router } from '@angular/router'
2020-09-25 08:04:21 +00:00
import {
AuthService ,
AuthUser ,
ConfirmService ,
Notifier ,
PeerTubeSocket ,
2021-05-15 04:30:24 +00:00
PluginService ,
2020-09-25 08:04:21 +00:00
RestExtractor ,
2021-02-02 09:37:52 +00:00
ScreenService ,
2020-09-25 08:04:21 +00:00
ServerService ,
2023-10-09 13:33:19 +00:00
Hotkey ,
HotkeysService ,
2021-12-15 14:58:10 +00:00
User ,
2020-09-25 08:04:21 +00:00
UserService
} from '@app/core'
2020-06-23 12:10:17 +00:00
import { HooksService } from '@app/core/plugins/hooks.service'
2023-06-29 13:55:00 +00:00
import { isXPercentInViewport , scrollToTop , toBoolean } from '@app/helpers'
2023-08-28 08:55:04 +00:00
import { Video , VideoCaptionService , VideoChapterService , VideoDetails , VideoFileTokenService , VideoService } from '@app/shared/shared-main'
2020-06-23 12:10:17 +00:00
import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
2022-03-04 12:40:02 +00:00
import { LiveVideoService } from '@app/shared/shared-video-live'
2020-06-23 12:10:17 +00:00
import { VideoPlaylist , VideoPlaylistService } from '@app/shared/shared-video-playlist'
2023-07-31 12:34:36 +00:00
import { timeToInt } from '@peertube/peertube-core-utils'
2021-07-16 08:42:24 +00:00
import {
HTMLServerConfig ,
HttpStatusCode ,
2022-03-04 12:40:02 +00:00
LiveVideo ,
2021-07-16 08:42:24 +00:00
PeerTubeProblemDocument ,
ServerErrorCode ,
2023-06-01 12:51:16 +00:00
Storyboard ,
2021-07-16 08:42:24 +00:00
VideoCaption ,
2023-08-28 08:55:04 +00:00
VideoChapter ,
2021-07-16 08:42:24 +00:00
VideoPrivacy ,
2023-07-31 12:34:36 +00:00
VideoState ,
VideoStateType
} from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { isP2PEnabled , videoRequiresFileToken , videoRequiresUserAuth } from '@root-helpers/video'
2019-02-06 09:39:50 +00:00
import {
2023-06-29 13:55:00 +00:00
HLSOptions ,
PeerTubePlayer ,
PeerTubePlayerContructorOptions ,
PeerTubePlayerLoadOptions ,
2020-06-23 12:10:17 +00:00
PlayerMode ,
videojs
2022-02-02 10:16:23 +00:00
} from '../../../assets/player'
import { cleanupVideoWatch , getStoredTheater , getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
2020-06-23 12:10:17 +00:00
import { environment } from '../../../environments/environment'
2021-06-29 15:18:30 +00:00
import { VideoWatchPlaylistComponent } from './shared'
2016-03-14 12:50:19 +00:00
2023-06-29 13:55:00 +00:00
type URLOptions = {
playerMode : PlayerMode
startTime : number | string
stopTime : number | string
controls? : boolean
controlBar? : boolean
muted? : boolean
loop? : boolean
subtitle? : string
resume? : string
peertubeLink : boolean
playbackRate? : number | string
}
2020-09-25 08:04:21 +00:00
2016-03-14 12:50:19 +00:00
@Component ( {
selector : 'my-video-watch' ,
2016-09-19 20:49:31 +00:00
templateUrl : './video-watch.component.html' ,
styleUrls : [ './video-watch.component.scss' ]
2016-03-14 12:50:19 +00:00
} )
2016-07-08 15:15:14 +00:00
export class VideoWatchComponent implements OnInit , OnDestroy {
2019-07-24 14:05:59 +00:00
@ViewChild ( 'videoWatchPlaylist' , { static : true } ) videoWatchPlaylist : VideoWatchPlaylistComponent
2020-02-07 09:00:34 +00:00
@ViewChild ( 'subscribeButton' ) subscribeButton : SubscribeButtonComponent
2023-06-29 13:55:00 +00:00
@ViewChild ( 'playerElement' ) playerElement : ElementRef < HTMLVideoElement >
2017-06-16 12:32:15 +00:00
2023-06-29 13:55:00 +00:00
peertubePlayer : PeerTubePlayer
2021-06-30 07:49:45 +00:00
theaterEnabled = false
2017-10-30 19:26:06 +00:00
2019-06-12 10:40:24 +00:00
video : VideoDetails = null
videoCaptions : VideoCaption [ ] = [ ]
2023-08-28 08:55:04 +00:00
videoChapters : VideoChapter [ ] = [ ]
2022-03-04 12:40:02 +00:00
liveVideo : LiveVideo
2023-06-29 07:48:55 +00:00
videoPassword : string
2023-06-01 12:51:16 +00:00
storyboards : Storyboard [ ] = [ ]
2019-06-12 10:40:24 +00:00
2020-08-19 07:21:46 +00:00
playlistPosition : number
2019-03-13 13:18:58 +00:00
playlist : VideoPlaylist = null
2018-07-16 17:15:20 +00:00
remoteServerDown = false
2022-06-03 12:18:28 +00:00
noPlaylistVideoFound = false
2021-02-22 09:53:25 +00:00
2023-06-29 13:55:00 +00:00
private nextRecommendedVideoUUID = ''
private nextRecommendedVideoTitle = ''
2021-06-30 07:49:45 +00:00
2022-10-12 14:09:02 +00:00
private videoFileToken : string
2019-03-07 16:06:00 +00:00
private currentTime : number
2021-06-30 07:49:45 +00:00
2017-06-16 12:32:15 +00:00
private paramsSub : Subscription
2019-03-13 13:18:58 +00:00
private queryParamsSub : Subscription
2019-04-10 07:23:18 +00:00
private configSub : Subscription
2020-09-25 08:04:21 +00:00
private liveVideosSub : Subscription
2017-06-16 12:32:15 +00:00
2021-06-04 11:31:41 +00:00
private serverConfig : HTMLServerConfig
2019-12-18 14:31:54 +00:00
2021-06-29 15:00:30 +00:00
private hotkeys : Hotkey [ ] = [ ]
2023-02-25 15:18:28 +00:00
private static VIEW_VIDEO_INTERVAL_MS = 5000
2017-06-16 12:32:15 +00:00
constructor (
2016-07-08 15:15:14 +00:00
private route : ActivatedRoute ,
2017-04-04 19:37:03 +00:00
private router : Router ,
2016-05-31 20:39:36 +00:00
private videoService : VideoService ,
2019-03-13 13:18:58 +00:00
private playlistService : VideoPlaylistService ,
2022-03-04 12:40:02 +00:00
private liveVideoService : LiveVideoService ,
2017-04-04 19:37:03 +00:00
private confirmService : ConfirmService ,
2017-01-27 15:14:11 +00:00
private authService : AuthService ,
2020-02-28 12:52:21 +00:00
private userService : UserService ,
2018-04-19 09:01:34 +00:00
private serverService : ServerService ,
2018-05-31 09:35:01 +00:00
private restExtractor : RestExtractor ,
2018-12-19 15:04:34 +00:00
private notifier : Notifier ,
2018-03-01 12:57:29 +00:00
private zone : NgZone ,
2018-07-13 16:21:19 +00:00
private videoCaptionService : VideoCaptionService ,
2023-08-28 08:55:04 +00:00
private videoChapterService : VideoChapterService ,
2018-09-02 18:54:23 +00:00
private hotkeysService : HotkeysService ,
2019-07-22 13:40:13 +00:00
private hooks : HooksService ,
2021-05-15 04:30:24 +00:00
private pluginService : PluginService ,
2020-09-25 08:04:21 +00:00
private peertubeSocket : PeerTubeSocket ,
2021-02-02 09:37:52 +00:00
private screenService : ScreenService ,
2022-10-12 14:09:02 +00:00
private videoFileTokenService : VideoFileTokenService ,
2019-08-22 15:13:58 +00:00
private location : PlatformLocation ,
2018-06-06 12:23:40 +00:00
@Inject ( LOCALE_ID ) private localeId : string
2021-02-02 09:37:52 +00:00
) { }
2016-03-14 12:50:19 +00:00
2017-12-12 13:41:59 +00:00
get user ( ) {
return this . authService . getUser ( )
}
2020-02-28 12:52:21 +00:00
get anonymousUser ( ) {
return this . userService . getAnonymousUser ( )
}
2023-06-29 13:55:00 +00:00
async ngOnInit ( ) {
2021-06-29 15:25:19 +00:00
this . serverConfig = this . serverService . getHTMLConfig ( )
2021-06-30 07:49:45 +00:00
this . loadRouteParams ( )
this . loadRouteQuery ( )
2018-09-02 18:54:23 +00:00
2019-06-11 14:26:48 +00:00
this . theaterEnabled = getStoredTheater ( )
2019-07-08 13:54:08 +00:00
2019-07-23 10:16:34 +00:00
this . hooks . runAction ( 'action:video-watch.init' , 'video-watch' )
2021-03-31 09:26:32 +00:00
setTimeout ( cleanupVideoWatch , 1500 ) // Run in timeout to ensure we're not blocking the UI
2023-06-29 13:55:00 +00:00
const constructorOptions = await this . hooks . wrapFun (
this . buildPeerTubePlayerConstructorOptions . bind ( this ) ,
{ urlOptions : this.getUrlOptions ( ) } ,
'video-watch' ,
'filter:internal.video-watch.player.build-options.params' ,
'filter:internal.video-watch.player.build-options.result'
)
this . peertubePlayer = new PeerTubePlayer ( constructorOptions )
2016-11-04 15:04:50 +00:00
}
2017-06-16 12:32:15 +00:00
ngOnDestroy ( ) {
2023-06-29 13:55:00 +00:00
if ( this . peertubePlayer ) this . peertubePlayer . destroy ( )
2016-11-08 20:17:17 +00:00
2017-01-29 17:35:19 +00:00
// Unsubscribe subscriptions
2019-03-13 13:18:58 +00:00
if ( this . paramsSub ) this . paramsSub . unsubscribe ( )
if ( this . queryParamsSub ) this . queryParamsSub . unsubscribe ( )
2020-08-04 09:42:06 +00:00
if ( this . configSub ) this . configSub . unsubscribe ( )
2020-09-25 08:04:21 +00:00
if ( this . liveVideosSub ) this . liveVideosSub . unsubscribe ( )
2018-09-02 18:54:23 +00:00
// Unbind hotkeys
2019-12-06 10:07:30 +00:00
this . hotkeysService . remove ( this . hotkeys )
2016-03-14 12:50:19 +00:00
}
2016-03-14 21:16:43 +00:00
2021-06-29 15:57:59 +00:00
getCurrentTime ( ) {
return this . currentTime
2020-08-03 19:06:45 +00:00
}
2021-06-29 15:57:59 +00:00
getCurrentPlaylistPosition ( ) {
return this . videoWatchPlaylist . currentPlaylistPosition
2016-11-08 20:11:57 +00:00
}
2019-09-24 06:48:01 +00:00
onRecommendations ( videos : Video [ ] ) {
2021-06-30 07:49:45 +00:00
if ( videos . length === 0 ) return
2019-09-24 06:48:01 +00:00
2021-06-30 07:49:45 +00:00
// The recommended videos's first element should be the next video
const video = videos [ 0 ]
2023-06-29 13:55:00 +00:00
this . nextRecommendedVideoUUID = video . uuid
this . nextRecommendedVideoTitle = video . name
2019-12-16 15:21:42 +00:00
}
handleTimestampClicked ( timestamp : number ) {
2023-06-29 13:55:00 +00:00
if ( ! this . peertubePlayer || this . video . isLive ) return
2020-12-17 13:14:28 +00:00
2023-06-29 13:55:00 +00:00
this . peertubePlayer . getPlayer ( ) . currentTime ( timestamp )
2019-12-16 15:21:42 +00:00
scrollToTop ( )
2019-12-12 17:11:55 +00:00
}
2021-06-30 07:49:45 +00:00
onPlaylistVideoFound ( videoId : string ) {
2022-11-15 10:57:49 +00:00
this . loadVideo ( { videoId , forceAutoplay : false } )
2021-06-30 07:49:45 +00:00
}
2022-06-03 12:18:28 +00:00
onPlaylistNoVideoFound ( ) {
this . noPlaylistVideoFound = true
}
2021-06-30 07:49:45 +00:00
isUserLoggedIn ( ) {
return this . authService . isLoggedIn ( )
}
2023-06-29 07:48:55 +00:00
isUserOwner ( ) {
return this . video . isLocal === true && this . video . account . name === this . user ? . username
}
2021-06-30 07:49:45 +00:00
isVideoBlur ( video : Video ) {
return video . isVideoNSFWForUser ( this . user , this . serverConfig )
2019-12-12 17:11:55 +00:00
}
2020-07-24 06:49:59 +00:00
isChannelDisplayNameGeneric ( ) {
const genericChannelDisplayName = [
` Main ${ this . video . channel . ownerAccount . name } channel ` ,
` Default ${ this . video . channel . ownerAccount . name } channel `
]
return genericChannelDisplayName . includes ( this . video . channel . displayName )
}
2021-04-01 09:10:27 +00:00
displayOtherVideosAsRow ( ) {
// Use the same value as in the SASS file
return this . screenService . getWindowInnerWidth ( ) <= 1100
}
2021-06-30 07:49:45 +00:00
private loadRouteParams ( ) {
this . paramsSub = this . route . params . subscribe ( routeParams = > {
2021-08-17 12:42:53 +00:00
const videoId = routeParams [ 'videoId' ]
2022-11-15 10:57:49 +00:00
if ( videoId ) return this . loadVideo ( { videoId , forceAutoplay : false } )
2021-06-30 07:49:45 +00:00
2021-08-17 12:42:53 +00:00
const playlistId = routeParams [ 'playlistId' ]
2021-06-30 07:49:45 +00:00
if ( playlistId ) return this . loadPlaylist ( playlistId )
} )
}
private loadRouteQuery ( ) {
this . queryParamsSub = this . route . queryParams . subscribe ( queryParams = > {
// Handle the ?playlistPosition
2021-08-17 12:42:53 +00:00
const positionParam = queryParams [ 'playlistPosition' ] ? ? 1
2021-06-30 07:49:45 +00:00
this . playlistPosition = positionParam === 'last'
? - 1 // Handle the "last" index
: parseInt ( positionParam + '' , 10 )
if ( isNaN ( this . playlistPosition ) ) {
2022-07-15 13:30:14 +00:00
logger . error ( ` playlistPosition query param ' ${ positionParam } ' was parsed as NaN, defaulting to 1. ` )
2021-06-30 07:49:45 +00:00
this . playlistPosition = 1
}
this . videoWatchPlaylist . updatePlaylistIndex ( this . playlistPosition )
2021-08-17 12:42:53 +00:00
const start = queryParams [ 'start' ]
2023-06-29 13:55:00 +00:00
if ( this . peertubePlayer && start ) this . peertubePlayer . getPlayer ( ) . currentTime ( parseInt ( start , 10 ) )
2021-06-30 07:49:45 +00:00
} )
}
2022-11-15 10:57:49 +00:00
private loadVideo ( options : {
videoId : string
forceAutoplay : boolean
2023-06-29 07:48:55 +00:00
videoPassword? : string
2022-11-15 10:57:49 +00:00
} ) {
2023-06-29 07:48:55 +00:00
const { videoId , forceAutoplay , videoPassword } = options
2022-11-15 10:57:49 +00:00
2021-06-30 07:49:45 +00:00
if ( this . isSameElement ( this . video , videoId ) ) return
2019-03-13 13:18:58 +00:00
2022-07-29 08:32:56 +00:00
this . video = undefined
2019-07-22 13:40:13 +00:00
const videoObs = this . hooks . wrapObsFun (
this . videoService . getVideo . bind ( this . videoService ) ,
2023-06-29 07:48:55 +00:00
{ videoId , videoPassword } ,
2019-07-22 13:40:13 +00:00
'video-watch' ,
'filter:api.video-watch.video.get.params' ,
'filter:api.video-watch.video.get.result'
)
2022-10-12 14:09:02 +00:00
const videoAndLiveObs : Observable < { video : VideoDetails , live? : LiveVideo , videoFileToken? : string } > = videoObs . pipe (
2022-03-04 12:40:02 +00:00
switchMap ( video = > {
2022-10-12 14:09:02 +00:00
if ( ! video . isLive ) return of ( { video , live : undefined } )
2022-03-04 12:40:02 +00:00
return this . liveVideoService . getVideoLive ( video . uuid )
. pipe ( map ( live = > ( { live , video } ) ) )
2022-10-12 14:09:02 +00:00
} ) ,
switchMap ( ( { video , live } ) = > {
2023-06-29 07:48:55 +00:00
if ( ! videoRequiresFileToken ( video ) ) return of ( { video , live , videoFileToken : undefined } )
2022-10-12 14:09:02 +00:00
2023-06-29 07:48:55 +00:00
return this . videoFileTokenService . getVideoFileToken ( { videoUUID : video.uuid , videoPassword } )
2022-10-12 14:09:02 +00:00
. pipe ( map ( ( { token } ) = > ( { video , live , videoFileToken : token } ) ) )
2022-03-04 12:40:02 +00:00
} )
)
2021-12-15 14:58:10 +00:00
forkJoin ( [
2022-03-04 12:40:02 +00:00
videoAndLiveObs ,
2023-06-29 07:48:55 +00:00
this . videoCaptionService . listCaptions ( videoId , videoPassword ) ,
2023-08-28 08:55:04 +00:00
this . videoChapterService . getChapters ( { videoId , videoPassword } ) ,
2023-06-29 12:22:13 +00:00
this . videoService . getStoryboards ( videoId , videoPassword ) ,
2021-12-15 14:58:10 +00:00
this . userService . getAnonymousOrLoggedUser ( )
] ) . subscribe ( {
2023-08-28 08:55:04 +00:00
next : ( [ { video , live , videoFileToken } , captionsResult , chaptersResult , storyboards , loggedInOrAnonymousUser ] ) = > {
2022-11-15 10:57:49 +00:00
this . onVideoFetched ( {
video ,
live ,
videoCaptions : captionsResult.data ,
2023-08-28 08:55:04 +00:00
videoChapters : chaptersResult.chapters ,
2023-06-01 12:51:16 +00:00
storyboards ,
2022-11-15 10:57:49 +00:00
videoFileToken ,
2023-06-29 07:48:55 +00:00
videoPassword ,
2022-11-15 10:57:49 +00:00
loggedInOrAnonymousUser ,
forceAutoplay
2023-06-29 07:48:55 +00:00
} ) . catch ( err = > {
this . handleGlobalError ( err )
} )
2021-12-15 14:58:10 +00:00
} ,
2023-06-29 07:48:55 +00:00
error : async err = > {
if ( err . body . code === ServerErrorCode . VIDEO_REQUIRES_PASSWORD || err . body . code === ServerErrorCode . INCORRECT_VIDEO_PASSWORD ) {
const { confirmed , password } = await this . handleVideoPasswordError ( err )
if ( confirmed === false ) return this . location . back ( )
2019-05-31 09:48:28 +00:00
2023-06-29 07:48:55 +00:00
this . loadVideo ( { . . . options , videoPassword : password } )
} else {
this . handleRequestError ( err )
}
}
2021-12-15 14:58:10 +00:00
} )
2019-03-13 13:18:58 +00:00
}
private loadPlaylist ( playlistId : string ) {
2021-06-30 07:49:45 +00:00
if ( this . isSameElement ( this . playlist , playlistId ) ) return
2019-03-13 13:18:58 +00:00
2022-06-03 12:18:28 +00:00
this . noPlaylistVideoFound = false
2019-03-13 13:18:58 +00:00
this . playlistService . getVideoPlaylist ( playlistId )
2021-08-17 09:27:47 +00:00
. subscribe ( {
next : playlist = > {
2021-06-30 07:49:45 +00:00
this . playlist = playlist
this . videoWatchPlaylist . loadPlaylistElements ( playlist , ! this . playlistPosition , this . playlistPosition )
} ,
2021-08-17 09:27:47 +00:00
error : err = > this . handleRequestError ( err )
} )
2021-06-30 07:49:45 +00:00
}
2019-03-13 13:18:58 +00:00
2021-06-30 07:49:45 +00:00
private isSameElement ( element : VideoDetails | VideoPlaylist , newId : string ) {
if ( ! element ) return false
return ( element . id + '' ) === newId || element . uuid === newId || element . shortUUID === newId
}
private async handleRequestError ( err : any ) {
const errorBody = err . body as PeerTubeProblemDocument
2021-11-02 10:50:03 +00:00
if ( errorBody ? . code === ServerErrorCode . DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS && errorBody . originUrl ) {
2021-06-30 07:49:45 +00:00
const originUrl = errorBody . originUrl + ( window . location . search ? ? '' )
const res = await this . confirmService . confirm (
2021-08-17 12:42:53 +00:00
// eslint-disable-next-line max-len
2021-06-30 07:49:45 +00:00
$localize ` This video is not available on this instance. Do you want to be redirected on the origin instance: <a href=" ${ originUrl } "> ${ originUrl } </a>? ` ,
$localize ` Redirection `
)
if ( res === true ) return window . location . href = originUrl
}
// If 400, 403 or 404, the video is private or blocked so redirect to 404
return this . restExtractor . redirectTo404IfNotFound ( err , 'video' , [
HttpStatusCode . BAD_REQUEST_400 ,
HttpStatusCode . FORBIDDEN_403 ,
HttpStatusCode . NOT_FOUND_404
] )
2019-03-13 13:18:58 +00:00
}
2021-06-30 07:49:45 +00:00
private handleGlobalError ( err : any ) {
2017-07-23 09:07:30 +00:00
const errorMessage : string = typeof err === 'string' ? err : err.message
2018-02-26 08:55:23 +00:00
if ( ! errorMessage ) return
2018-12-19 15:04:34 +00:00
this . notifier . error ( errorMessage )
2017-07-23 09:07:30 +00:00
}
2023-06-29 07:48:55 +00:00
private handleVideoPasswordError ( err : any ) {
let isIncorrectPassword : boolean
if ( err . body . code === ServerErrorCode . VIDEO_REQUIRES_PASSWORD ) {
isIncorrectPassword = false
} else if ( err . body . code === ServerErrorCode . INCORRECT_VIDEO_PASSWORD ) {
this . videoPassword = undefined
isIncorrectPassword = true
}
return this . confirmService . confirmWithPassword ( {
message : $localize ` You need a password to watch this video ` ,
title : $localize ` This video is password protected ` ,
errorMessage : isIncorrectPassword ? $localize ` Incorrect password, please enter a correct password ` : ''
} )
}
2021-12-15 14:58:10 +00:00
private async onVideoFetched ( options : {
video : VideoDetails
2022-03-04 12:40:02 +00:00
live : LiveVideo
2021-12-15 14:58:10 +00:00
videoCaptions : VideoCaption [ ]
2023-08-28 08:55:04 +00:00
videoChapters : VideoChapter [ ]
2023-06-01 12:51:16 +00:00
storyboards : Storyboard [ ]
2022-10-12 14:09:02 +00:00
videoFileToken : string
2023-06-29 07:48:55 +00:00
videoPassword : string
2022-10-12 14:09:02 +00:00
2021-12-15 14:58:10 +00:00
loggedInOrAnonymousUser : User
2022-11-15 10:57:49 +00:00
forceAutoplay : boolean
2021-12-15 14:58:10 +00:00
} ) {
2023-06-01 12:51:16 +00:00
const {
video ,
live ,
videoCaptions ,
2023-08-28 08:55:04 +00:00
videoChapters ,
2023-06-01 12:51:16 +00:00
storyboards ,
videoFileToken ,
videoPassword ,
loggedInOrAnonymousUser ,
forceAutoplay
} = options
2021-12-15 14:58:10 +00:00
2020-09-25 08:04:21 +00:00
this . subscribeToLiveEventsIfNeeded ( this . video , video )
2017-06-16 12:32:15 +00:00
this . video = video
2019-06-12 10:40:24 +00:00
this . videoCaptions = videoCaptions
2023-08-28 08:55:04 +00:00
this . videoChapters = videoChapters
2022-03-04 12:40:02 +00:00
this . liveVideo = live
2022-10-12 14:09:02 +00:00
this . videoFileToken = videoFileToken
2023-06-29 07:48:55 +00:00
this . videoPassword = videoPassword
2023-06-01 12:51:16 +00:00
this . storyboards = storyboards
2017-04-04 19:37:03 +00:00
2018-04-04 07:04:34 +00:00
// Re init attributes
2018-07-16 17:15:20 +00:00
this . remoteServerDown = false
2019-03-07 16:06:00 +00:00
this . currentTime = undefined
2018-04-04 07:04:34 +00:00
2019-03-13 13:18:58 +00:00
if ( this . isVideoBlur ( this . video ) ) {
2018-02-28 14:33:45 +00:00
const res = await this . confirmService . confirm (
2020-08-12 08:40:04 +00:00
$localize ` This video contains mature or explicit content. Are you sure you want to watch it? ` ,
$localize ` Mature or explicit content `
2017-10-27 06:51:40 +00:00
)
2019-08-22 15:13:58 +00:00
if ( res === false ) return this . location . back ( )
2017-04-04 19:37:03 +00:00
}
2022-12-30 14:54:08 +00:00
this . buildHotkeysHelp ( video )
2023-06-29 13:55:00 +00:00
this . loadPlayer ( { loggedInOrAnonymousUser , forceAutoplay } )
2022-07-15 13:30:14 +00:00
. catch ( err = > logger . error ( 'Cannot build the player' , err ) )
2021-02-22 09:46:52 +00:00
2021-04-09 08:54:34 +00:00
const hookOptions = {
videojs ,
video : this.video ,
playlist : this.playlist
}
this . hooks . runAction ( 'action:video-watch.video.loaded' , 'video-watch' , hookOptions )
2021-02-22 09:46:52 +00:00
}
2023-06-29 13:55:00 +00:00
private async loadPlayer ( options : {
2022-11-15 10:57:49 +00:00
loggedInOrAnonymousUser : User
forceAutoplay : boolean
} ) {
2023-06-29 13:55:00 +00:00
const { loggedInOrAnonymousUser , forceAutoplay } = options
2018-04-03 15:33:39 +00:00
2021-02-22 09:53:25 +00:00
const videoState = this . video . state . id
if ( videoState === VideoState . LIVE_ENDED || videoState === VideoState . WAITING_FOR_LIVE ) {
2023-06-29 13:55:00 +00:00
this . updatePlayerOnNoLive ( )
2021-02-22 09:53:25 +00:00
return
}
2023-06-29 13:55:00 +00:00
this . peertubePlayer ? . enable ( )
2018-04-03 15:33:39 +00:00
2019-12-05 16:06:18 +00:00
const params = {
video : this.video ,
2021-02-22 09:46:52 +00:00
videoCaptions : this.videoCaptions ,
2023-08-28 08:55:04 +00:00
videoChapters : this.videoChapters ,
2023-06-01 12:51:16 +00:00
storyboards : this.storyboards ,
2022-03-04 12:40:02 +00:00
liveVideo : this.liveVideo ,
2022-10-12 14:09:02 +00:00
videoFileToken : this.videoFileToken ,
2023-06-29 07:48:55 +00:00
videoPassword : this.videoPassword ,
2023-06-29 13:55:00 +00:00
urlOptions : this.getUrlOptions ( ) ,
2021-12-15 14:58:10 +00:00
loggedInOrAnonymousUser ,
2022-11-15 10:57:49 +00:00
forceAutoplay ,
2019-12-05 16:06:18 +00:00
user : this.user
2018-06-06 12:23:40 +00:00
}
2023-06-29 13:55:00 +00:00
const loadOptions = await this . hooks . wrapFun (
this . buildPeerTubePlayerLoadOptions . bind ( this ) ,
2019-12-05 16:06:18 +00:00
params ,
2019-12-05 16:26:58 +00:00
'video-watch' ,
2023-06-29 13:55:00 +00:00
'filter:internal.video-watch.player.load-options.params' ,
'filter:internal.video-watch.player.load-options.result'
2019-12-05 16:06:18 +00:00
)
2018-06-06 12:23:40 +00:00
this . zone . runOutsideAngular ( async ( ) = > {
2023-06-29 13:55:00 +00:00
await this . peertubePlayer . load ( loadOptions )
2019-03-18 09:26:53 +00:00
2023-06-29 13:55:00 +00:00
const player = this . peertubePlayer . getPlayer ( )
2019-03-07 16:06:00 +00:00
2023-06-29 13:55:00 +00:00
player . on ( 'timeupdate' , ( ) = > {
2021-06-30 07:49:45 +00:00
// Don't need to trigger angular change for this variable, that is sent to children components on click
2023-06-29 13:55:00 +00:00
this . currentTime = Math . floor ( player . currentTime ( ) )
2019-03-07 16:06:00 +00:00
} )
2019-03-13 13:18:58 +00:00
2023-06-29 13:55:00 +00:00
if ( this . video . isLive ) {
player . one ( 'ended' , ( ) = > {
this . zone . run ( ( ) = > {
// We changed the video, it's not a live anymore
if ( ! this . video . isLive ) return
2021-06-30 07:49:45 +00:00
2023-06-29 13:55:00 +00:00
this . video . state . id = VideoState . LIVE_ENDED
2021-06-30 07:49:45 +00:00
2023-06-29 13:55:00 +00:00
this . updatePlayerOnNoLive ( )
} )
} )
}
2020-12-04 14:29:18 +00:00
2023-06-29 13:55:00 +00:00
player . on ( 'theater-change' , ( _ : any , enabled : boolean ) = > {
2019-03-18 09:26:53 +00:00
this . zone . run ( ( ) = > this . theaterEnabled = enabled )
} )
2019-11-18 08:55:23 +00:00
2021-10-12 11:45:55 +00:00
this . hooks . runAction ( 'action:video-watch.player.loaded' , 'video-watch' , {
2023-06-29 13:55:00 +00:00
player ,
2021-10-12 11:45:55 +00:00
playlist : this.playlist ,
playlistPosition : this.playlistPosition ,
videojs ,
video : this.video
} )
2018-04-03 15:33:39 +00:00
} )
2017-04-04 19:37:03 +00:00
}
2022-01-07 13:25:23 +00:00
private hasNextVideo ( ) {
if ( this . playlist ) {
return this . videoWatchPlaylist . hasNextVideo ( )
}
return true
}
2023-06-29 13:55:00 +00:00
private getNextVideoTitle ( ) {
2020-03-17 14:05:28 +00:00
if ( this . playlist ) {
2023-06-29 13:55:00 +00:00
return this . videoWatchPlaylist . getNextVideo ( ) ? . video ? . name || ''
2019-09-24 06:48:01 +00:00
}
2016-11-04 16:37:44 +00:00
2023-06-29 13:55:00 +00:00
return this . nextRecommendedVideoTitle
}
private playNextVideoInAngularZone ( ) {
this . zone . run ( ( ) = > {
if ( this . playlist ) {
this . videoWatchPlaylist . navigateToNextPlaylistVideo ( )
return
}
if ( this . nextRecommendedVideoUUID ) {
this . router . navigate ( [ '/w' , this . nextRecommendedVideoUUID ] )
}
} )
2016-11-04 16:37:44 +00:00
}
2017-11-30 08:21:11 +00:00
2017-12-19 13:01:34 +00:00
private isAutoplay ( ) {
2018-06-14 09:25:49 +00:00
// We'll jump to the thread id, so do not play the video
if ( this . route . snapshot . params [ 'threadId' ] ) return false
2023-07-13 12:40:06 +00:00
if ( this . user ) return this . user . autoPlayVideo
2017-12-19 13:01:34 +00:00
2023-07-13 12:40:06 +00:00
if ( this . anonymousUser ) return this . anonymousUser . autoPlayVideo
throw new Error ( 'Cannot guess autoplay because user and anonymousUser are not defined' )
2017-12-19 13:01:34 +00:00
}
2018-04-03 16:06:58 +00:00
2021-06-30 07:49:45 +00:00
private isAutoPlayNext ( ) {
return (
2021-08-17 12:42:53 +00:00
( this . user ? . autoPlayNextVideo ) ||
2021-06-30 07:49:45 +00:00
this . anonymousUser . autoPlayNextVideo
)
}
private isPlaylistAutoPlayNext ( ) {
return (
2021-08-17 12:42:53 +00:00
( this . user ? . autoPlayNextVideoPlaylist ) ||
2021-06-30 07:49:45 +00:00
this . anonymousUser . autoPlayNextVideoPlaylist
)
}
2023-06-29 13:55:00 +00:00
private buildPeerTubePlayerConstructorOptions ( options : {
urlOptions : URLOptions
} ) : PeerTubePlayerContructorOptions {
const { urlOptions } = options
return {
playerElement : ( ) = > this . playerElement . nativeElement ,
enableHotkeys : true ,
inactivityTimeout : 2500 ,
theaterButton : true ,
controls : urlOptions.controls ,
controlBar : urlOptions.controlBar ,
muted : urlOptions.muted ,
loop : urlOptions.loop ,
playbackRate : urlOptions.playbackRate ,
instanceName : this.serverConfig.instance.name ,
language : this.localeId ,
metricsUrl : environment.apiUrl + '/api/v1/metrics/playback' ,
videoViewIntervalMs : VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS ,
authorizationHeader : ( ) = > this . authService . getRequestHeaderValue ( ) ,
serverUrl : environment.originServerUrl || window . location . origin ,
2021-06-28 15:30:59 +00:00
2023-06-29 13:55:00 +00:00
errorNotifier : ( message : string ) = > this . notifier . error ( message ) ,
peertubeLink : ( ) = > false ,
2023-08-18 07:48:45 +00:00
pluginsManager : this.pluginService.getPluginsManager ( ) ,
autoPlayerRatio : {
cssRatioVariable : '--player-ratio' ,
cssPlayerPortraitModeVariable : '--player-portrait-mode'
}
2018-04-03 16:06:58 +00:00
}
}
2019-05-17 12:34:21 +00:00
2023-06-29 13:55:00 +00:00
private buildPeerTubePlayerLoadOptions ( options : {
2021-08-17 12:42:53 +00:00
video : VideoDetails
2022-03-04 12:40:02 +00:00
liveVideo : LiveVideo
2021-08-17 12:42:53 +00:00
videoCaptions : VideoCaption [ ]
2023-08-28 08:55:04 +00:00
videoChapters : VideoChapter [ ]
2023-06-01 12:51:16 +00:00
storyboards : Storyboard [ ]
2022-10-12 14:09:02 +00:00
videoFileToken : string
2023-06-29 07:48:55 +00:00
videoPassword : string
2022-10-12 14:09:02 +00:00
2023-06-29 13:55:00 +00:00
urlOptions : URLOptions
2022-10-12 14:09:02 +00:00
2021-12-15 14:58:10 +00:00
loggedInOrAnonymousUser : User
2022-11-15 10:57:49 +00:00
forceAutoplay : boolean
2022-04-05 12:03:52 +00:00
user? : AuthUser // Keep for plugins
2023-06-29 13:55:00 +00:00
} ) : PeerTubePlayerLoadOptions {
2023-06-01 12:51:16 +00:00
const {
video ,
liveVideo ,
videoCaptions ,
2023-08-28 08:55:04 +00:00
videoChapters ,
2023-06-01 12:51:16 +00:00
storyboards ,
videoFileToken ,
videoPassword ,
urlOptions ,
loggedInOrAnonymousUser ,
forceAutoplay
2023-06-29 13:55:00 +00:00
} = options
let mode : PlayerMode
if ( urlOptions . playerMode ) {
if ( urlOptions . playerMode === 'p2p-media-loader' ) mode = 'p2p-media-loader'
else mode = 'web-video'
} else {
if ( video . hasHlsPlaylist ( ) ) mode = 'p2p-media-loader'
else mode = 'web-video'
}
let hlsOptions : HLSOptions
if ( video . hasHlsPlaylist ( ) ) {
const hlsPlaylist = video . getHlsPlaylist ( )
hlsOptions = {
playlistUrl : hlsPlaylist.playlistUrl ,
segmentsSha256Url : hlsPlaylist.segmentsSha256Url ,
redundancyBaseUrls : hlsPlaylist.redundancies.map ( r = > r . baseUrl ) ,
trackerAnnounce : video.trackerUrls ,
videoFiles : hlsPlaylist.files
}
}
2021-06-30 07:49:45 +00:00
2019-12-12 17:11:55 +00:00
const getStartTime = ( ) = > {
const byUrl = urlOptions . startTime !== undefined
2019-12-18 22:39:07 +00:00
const byHistory = video . userHistory && ( ! this . playlist || urlOptions . resume !== undefined )
2021-03-31 09:26:32 +00:00
const byLocalStorage = getStoredVideoWatchHistory ( video . uuid )
2019-12-12 17:11:55 +00:00
2020-08-18 14:04:03 +00:00
if ( byUrl ) return timeToInt ( urlOptions . startTime )
2023-02-25 15:18:28 +00:00
let startTime = 0
if ( byHistory ) startTime = video . userHistory . currentTime
if ( byLocalStorage ) startTime = byLocalStorage . duration
2019-12-05 16:06:18 +00:00
2023-02-25 15:18:28 +00:00
// If we are at the end of the video, reset the timer
if ( video . duration - startTime <= 1 ) startTime = 0
return startTime
}
2020-08-18 14:04:03 +00:00
2023-02-25 15:18:28 +00:00
const startTime = getStartTime ( )
2019-12-05 16:06:18 +00:00
const playerCaptions = videoCaptions . map ( c = > ( {
label : c.language.label ,
language : c.language.id ,
src : environment.apiUrl + c . captionPath
} ) )
2023-06-01 12:51:16 +00:00
const storyboard = storyboards . length !== 0
? {
url : environment.apiUrl + storyboards [ 0 ] . storyboardPath ,
height : storyboards [ 0 ] . spriteHeight ,
width : storyboards [ 0 ] . spriteWidth ,
interval : storyboards [ 0 ] . spriteDuration
}
: undefined
2022-03-04 12:40:02 +00:00
const liveOptions = video . isLive
? { latencyMode : liveVideo.latencyMode }
: undefined
2023-06-29 13:55:00 +00:00
return {
mode ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
autoplay : this.isAutoplay ( ) ,
forceAutoplay ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
duration : this.video.duration ,
poster : video.previewUrl ,
p2pEnabled : isP2PEnabled ( video , this . serverConfig , loggedInOrAnonymousUser . p2pEnabled ) ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
startTime ,
stopTime : urlOptions.stopTime ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
embedUrl : video.embedUrl ,
embedTitle : video.name ,
2020-11-10 13:21:26 +00:00
2023-06-29 13:55:00 +00:00
isLive : video.isLive ,
liveOptions ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
videoViewUrl : video.privacy.id !== VideoPrivacy . PRIVATE
? this . videoService . getVideoViewUrl ( video . uuid )
: null ,
2022-10-12 14:09:02 +00:00
2023-06-29 13:55:00 +00:00
videoFileToken : ( ) = > videoFileToken ,
requiresUserAuth : videoRequiresUserAuth ( video , videoPassword ) ,
requiresPassword : video.privacy.id === VideoPrivacy . PASSWORD_PROTECTED &&
! video . canAccessPasswordProtectedVideoWithoutPassword ( this . user ) ,
videoPassword : ( ) = > videoPassword ,
2022-10-12 14:09:02 +00:00
2023-06-29 13:55:00 +00:00
videoCaptions : playerCaptions ,
2023-08-28 08:55:04 +00:00
videoChapters ,
2023-06-29 13:55:00 +00:00
storyboard ,
2022-10-12 14:09:02 +00:00
2023-06-29 13:55:00 +00:00
videoShortUUID : video.shortUUID ,
videoUUID : video.uuid ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
previousVideo : {
enabled : this.playlist && this . videoWatchPlaylist . hasPreviousVideo ( ) ,
2021-03-31 09:26:32 +00:00
2023-06-29 13:55:00 +00:00
handler : this.playlist
? ( ) = > this . zone . run ( ( ) = > this . videoWatchPlaylist . navigateToPreviousPlaylistVideo ( ) )
: undefined ,
2022-02-02 10:16:23 +00:00
2023-06-29 13:55:00 +00:00
displayControlBarButton : ! ! this . playlist
2019-12-05 16:06:18 +00:00
} ,
2023-06-29 13:55:00 +00:00
nextVideo : {
enabled : this.hasNextVideo ( ) ,
handler : ( ) = > this . playNextVideoInAngularZone ( ) ,
getVideoTitle : ( ) = > this . getNextVideoTitle ( ) ,
displayControlBarButton : this.hasNextVideo ( )
2021-05-15 04:30:24 +00:00
} ,
2023-06-29 13:55:00 +00:00
upnext : {
isEnabled : ( ) = > {
if ( this . playlist ) return this . isPlaylistAutoPlayNext ( )
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
return this . isAutoPlayNext ( )
} ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
isSuspended : ( player : videojs.Player ) = > {
return ! isXPercentInViewport ( player . el ( ) as HTMLElement , 80 )
} ,
2020-05-13 08:39:54 +00:00
2023-06-29 13:55:00 +00:00
timeout : this.playlist
? 0 // Don't wait to play next video in playlist
: 5000 // 5 seconds for a recommended video
} ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
hls : hlsOptions ,
2019-12-05 16:06:18 +00:00
2023-06-29 13:55:00 +00:00
webVideo : {
videoFiles : video.files
}
2019-12-05 16:06:18 +00:00
}
}
2020-09-25 08:04:21 +00:00
private async subscribeToLiveEventsIfNeeded ( oldVideo : VideoDetails , newVideo : VideoDetails ) {
if ( ! this . liveVideosSub ) {
2020-12-09 14:00:02 +00:00
this . liveVideosSub = this . buildLiveEventsSubscription ( )
2020-09-25 08:04:21 +00:00
}
if ( oldVideo && oldVideo . id !== newVideo . id ) {
2021-08-25 14:14:11 +00:00
this . peertubeSocket . unsubscribeLiveVideos ( oldVideo . id )
2020-09-25 08:04:21 +00:00
}
if ( ! newVideo . isLive ) return
await this . peertubeSocket . subscribeToLiveVideosSocket ( newVideo . id )
}
2020-12-09 14:00:02 +00:00
private buildLiveEventsSubscription ( ) {
return this . peertubeSocket . getLiveVideosObservable ( )
. subscribe ( ( { type , payload } ) = > {
if ( type === 'state-change' ) return this . handleLiveStateChange ( payload . state )
2021-11-09 09:11:20 +00:00
if ( type === 'views-change' ) return this . handleLiveViewsChange ( payload . viewers )
2020-12-09 14:00:02 +00:00
} )
}
2023-07-31 12:34:36 +00:00
private handleLiveStateChange ( newState : VideoStateType ) {
2020-12-09 14:00:02 +00:00
if ( newState !== VideoState . PUBLISHED ) return
2022-07-15 13:30:14 +00:00
logger . info ( 'Loading video after live update.' )
2020-12-09 14:00:02 +00:00
const videoUUID = this . video . uuid
2021-06-30 07:49:45 +00:00
// Reset to force refresh the video
2020-12-09 14:00:02 +00:00
this . video = undefined
2022-11-15 10:57:49 +00:00
this . loadVideo ( { videoId : videoUUID , forceAutoplay : true } )
2020-12-09 14:00:02 +00:00
}
2021-11-09 09:11:20 +00:00
private handleLiveViewsChange ( newViewers : number ) {
2020-12-09 14:00:02 +00:00
if ( ! this . video ) {
2022-07-15 13:30:14 +00:00
logger . error ( 'Cannot update video live views because video is no defined.' )
2020-12-09 14:00:02 +00:00
return
}
2022-07-15 13:30:14 +00:00
logger . info ( 'Updating live views.' )
2020-12-10 08:37:53 +00:00
2021-11-09 09:11:20 +00:00
this . video . viewers = newViewers
2020-12-09 14:00:02 +00:00
}
2023-06-29 13:55:00 +00:00
private updatePlayerOnNoLive ( ) {
this . peertubePlayer . unload ( )
this . peertubePlayer . disable ( )
this . peertubePlayer . setPoster ( this . video . previewPath )
}
2022-12-30 14:54:08 +00:00
private buildHotkeysHelp ( video : Video ) {
if ( this . hotkeys . length !== 0 ) {
this . hotkeysService . remove ( this . hotkeys )
}
2019-12-06 08:55:36 +00:00
this . hotkeys = [
// These hotkeys are managed by the player
2023-10-09 13:33:19 +00:00
new Hotkey ( 'f' , e = > e , $localize ` Enter/exit fullscreen ` ) ,
new Hotkey ( 'space' , e = > e , $localize ` Play/Pause the video ` ) ,
new Hotkey ( 'm' , e = > e , $localize ` Mute/unmute the video ` ) ,
2019-12-06 08:55:36 +00:00
2023-10-09 13:33:19 +00:00
new Hotkey ( 'up' , e = > e , $localize ` Increase the volume ` ) ,
new Hotkey ( 'down' , e = > e , $localize ` Decrease the volume ` ) ,
2019-12-06 08:55:36 +00:00
2022-01-12 15:01:39 +00:00
new Hotkey ( 't' , e = > {
this . theaterEnabled = ! this . theaterEnabled
return false
2023-10-09 13:33:19 +00:00
} , $localize ` Toggle theater mode ` )
2019-12-06 08:55:36 +00:00
]
2019-12-06 10:07:30 +00:00
2022-12-30 14:54:08 +00:00
if ( ! video . isLive ) {
this . hotkeys = this . hotkeys . concat ( [
// These hotkeys are also managed by the player but only for VOD
2023-10-09 13:33:19 +00:00
new Hotkey ( '0-9' , e = > e , $localize ` Skip to a percentage of the video: 0 is 0% and 9 is 90% ` ) ,
2022-12-30 14:54:08 +00:00
2023-10-09 13:33:19 +00:00
new Hotkey ( 'right' , e = > e , $localize ` Seek the video forward ` ) ,
new Hotkey ( 'left' , e = > e , $localize ` Seek the video backward ` ) ,
2022-12-30 14:54:08 +00:00
2023-10-09 13:33:19 +00:00
new Hotkey ( '>' , e = > e , $localize ` Increase playback rate ` ) ,
new Hotkey ( '<' , e = > e , $localize ` Decrease playback rate ` ) ,
2022-12-30 14:54:08 +00:00
2023-10-09 13:33:19 +00:00
new Hotkey ( ',' , e = > e , $localize ` Navigate in the video to the previous frame ` ) ,
new Hotkey ( '.' , e = > e , $localize ` Navigate in the video to the next frame ` )
2022-12-30 14:54:08 +00:00
] )
}
2019-12-06 10:07:30 +00:00
if ( this . isUserLoggedIn ( ) ) {
this . hotkeys = this . hotkeys . concat ( [
new Hotkey ( 'shift+s' , ( ) = > {
2021-08-17 12:42:53 +00:00
if ( this . subscribeButton . isSubscribedToAll ( ) ) this . subscribeButton . unsubscribe ( )
else this . subscribeButton . subscribe ( )
2021-07-12 08:03:46 +00:00
2019-12-06 10:07:30 +00:00
return false
2023-10-09 13:33:19 +00:00
} , $localize ` Subscribe to the account ` )
2019-12-06 10:07:30 +00:00
] )
}
this . hotkeysService . add ( this . hotkeys )
2019-12-06 08:55:36 +00:00
}
2021-06-30 07:49:45 +00:00
2023-06-29 13:55:00 +00:00
private getUrlOptions ( ) : URLOptions {
const queryParams = this . route . snapshot . queryParams
return {
resume : queryParams.resume ,
startTime : queryParams.start ,
stopTime : queryParams.stop ,
muted : toBoolean ( queryParams . muted ) ,
loop : toBoolean ( queryParams . loop ) ,
subtitle : queryParams.subtitle ,
playerMode : queryParams.mode ,
playbackRate : queryParams.playbackRate ,
controlBar : toBoolean ( queryParams . controlBar ) ,
peertubeLink : false
}
}
2016-03-14 12:50:19 +00:00
}