aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/standalone/videos
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/standalone/videos')
-rw-r--r--client/src/standalone/videos/embed-api.ts10
-rw-r--r--client/src/standalone/videos/embed.ts859
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts105
-rw-r--r--client/src/standalone/videos/shared/index.ts8
-rw-r--r--client/src/standalone/videos/shared/peertube-plugin.ts85
-rw-r--r--client/src/standalone/videos/shared/player-html.ts76
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts323
-rw-r--r--client/src/standalone/videos/shared/playlist-fetcher.ts72
-rw-r--r--client/src/standalone/videos/shared/playlist-tracker.ts93
-rw-r--r--client/src/standalone/videos/shared/translations.ts5
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts63
11 files changed, 997 insertions, 702 deletions
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts
index a28aeeaef..84d664654 100644
--- a/client/src/standalone/videos/embed-api.ts
+++ b/client/src/standalone/videos/embed-api.ts
@@ -27,11 +27,11 @@ export class PeerTubeEmbedApi {
27 } 27 }
28 28
29 private get element () { 29 private get element () {
30 return this.embed.playerElement 30 return this.embed.getPlayerElement()
31 } 31 }
32 32
33 private constructChannel () { 33 private constructChannel () {
34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope }) 34 const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.getScope() })
35 35
36 channel.bind('play', (txn, params) => this.embed.player.play()) 36 channel.bind('play', (txn, params) => this.embed.player.play())
37 channel.bind('pause', (txn, params) => this.embed.player.pause()) 37 channel.bind('pause', (txn, params) => this.embed.player.pause())
@@ -52,9 +52,9 @@ export class PeerTubeEmbedApi {
52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate()) 52 channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate())
53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates) 53 channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates)
54 54
55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextVideo()) 55 channel.bind('playNextVideo', (txn, params) => this.embed.playNextPlaylistVideo())
56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousVideo()) 56 channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPosition()) 57 channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
58 this.channel = channel 58 this.channel = channel
59 } 59 }
60 60
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 1fc8e229b..c5d017d4a 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,83 +3,40 @@ import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import videojs from 'video.js' 4import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 5import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
6import { 6import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models'
7 HTMLServerConfig,
8 HttpStatusCode,
9 LiveVideo,
10 OAuth2ErrorCode,
11 PublicServerSetting,
12 ResultList,
13 UserRefreshToken,
14 Video,
15 VideoCaption,
16 VideoDetails,
17 VideoPlaylist,
18 VideoPlaylistElement,
19 VideoStreamingPlaylistType
20} from '../../../../shared/models'
21import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player'
22import { TranslationsManager } from '../../assets/player/translations-manager' 7import { TranslationsManager } from '../../assets/player/translations-manager'
23import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' 8import { getParamString } from '../../root-helpers'
24import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage'
25import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager'
26import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users'
27import { objectToUrlEncoded } from '../../root-helpers/utils'
28import { isP2PEnabled } from '../../root-helpers/video'
29import { RegisterClientHelpers } from '../../types/register-client-option.model'
30import { PeerTubeEmbedApi } from './embed-api' 9import { PeerTubeEmbedApi } from './embed-api'
31 10import { AuthHTTP, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared'
32type Translations = { [ id: string ]: string } 11import { PlayerHTML } from './shared/player-html'
12import { PeertubePlayerManager } from '../../assets/player'
33 13
34export class PeerTubeEmbed { 14export class PeerTubeEmbed {
35 playerElement: HTMLVideoElement
36 player: videojs.Player 15 player: videojs.Player
37 api: PeerTubeEmbedApi = null 16 api: PeerTubeEmbedApi = null
38 17
39 autoplay: boolean
40
41 controls: boolean
42 controlBar: boolean
43
44 muted: boolean
45 loop: boolean
46 subtitle: string
47 enableApi = false
48 startTime: number | string = 0
49 stopTime: number | string
50
51 title: boolean
52 warningTitle: boolean
53 peertubeLink: boolean
54 p2pEnabled: boolean
55 bigPlayBackgroundColor: string
56 foregroundColor: string
57
58 mode: PlayerMode
59 scope = 'peertube'
60
61 userTokens: UserTokens
62 headers = new Headers()
63 LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
64 CLIENT_ID: 'client_id',
65 CLIENT_SECRET: 'client_secret'
66 }
67
68 config: HTMLServerConfig 18 config: HTMLServerConfig
69 19
70 private translationsPromise: Promise<{ [id: string]: string }> 20 private translationsPromise: Promise<{ [id: string]: string }>
71 private PeertubePlayerManagerModulePromise: Promise<any> 21 private PeertubePlayerManagerModulePromise: Promise<any>
72 22
73 private playlist: VideoPlaylist 23 private readonly http: AuthHTTP
74 private playlistElements: VideoPlaylistElement[] 24 private readonly videoFetcher: VideoFetcher
75 private currentPlaylistElement: VideoPlaylistElement 25 private readonly playlistFetcher: PlaylistFetcher
26 private readonly peertubePlugin: PeerTubePlugin
27 private readonly playerHTML: PlayerHTML
28 private readonly playerManagerOptions: PlayerManagerOptions
76 29
77 private readonly wrapperElement: HTMLElement 30 private playlistTracker: PlaylistTracker
78 31
79 private pluginsManager: PluginsManager 32 constructor (videoWrapperId: string) {
33 this.http = new AuthHTTP()
80 34
81 constructor (private readonly videoWrapperId: string) { 35 this.videoFetcher = new VideoFetcher(this.http)
82 this.wrapperElement = document.getElementById(this.videoWrapperId) 36 this.playlistFetcher = new PlaylistFetcher(this.http)
37 this.peertubePlugin = new PeerTubePlugin(this.http)
38 this.playerHTML = new PlayerHTML(videoWrapperId)
39 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin)
83 40
84 try { 41 try {
85 this.config = JSON.parse(window['PeerTubeServerConfig']) 42 this.config = JSON.parse(window['PeerTubeServerConfig'])
@@ -94,697 +51,268 @@ export class PeerTubeEmbed {
94 await embed.init() 51 await embed.init()
95 } 52 }
96 53
97 getVideoUrl (id: string) { 54 getPlayerElement () {
98 return window.location.origin + '/api/v1/videos/' + id 55 return this.playerHTML.getPlayerElement()
99 }
100
101 getLiveUrl (videoId: string) {
102 return window.location.origin + '/api/v1/videos/live/' + videoId
103 }
104
105 getPluginUrl () {
106 return window.location.origin + '/api/v1/plugins'
107 }
108
109 refreshFetch (url: string, options?: RequestInit) {
110 return fetch(url, options)
111 .then((res: Response) => {
112 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
113
114 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
115 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
116 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
117
118 const headers = new Headers()
119 headers.set('Content-Type', 'application/x-www-form-urlencoded')
120
121 const data = {
122 refresh_token: this.userTokens.refreshToken,
123 client_id: clientId,
124 client_secret: clientSecret,
125 response_type: 'code',
126 grant_type: 'refresh_token'
127 }
128
129 fetch('/api/v1/users/token', {
130 headers,
131 method: 'POST',
132 body: objectToUrlEncoded(data)
133 }).then(res => {
134 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
135
136 return res.json()
137 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
138 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
139 UserTokens.flushLocalStorage(peertubeLocalStorage)
140 this.removeTokensFromHeaders()
141
142 return resolve()
143 }
144
145 this.userTokens.accessToken = obj.access_token
146 this.userTokens.refreshToken = obj.refresh_token
147 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
148
149 this.setHeadersFromTokens()
150
151 resolve()
152 }).catch((refreshTokenError: any) => {
153 reject(refreshTokenError)
154 })
155 })
156
157 return refreshingTokenPromise
158 .catch(() => {
159 UserTokens.flushLocalStorage(peertubeLocalStorage)
160
161 this.removeTokensFromHeaders()
162 }).then(() => fetch(url, {
163 ...options,
164 headers: this.headers
165 }))
166 })
167 }
168
169 getPlaylistUrl (id: string) {
170 return window.location.origin + '/api/v1/video-playlists/' + id
171 } 56 }
172 57
173 loadVideoInfo (videoId: string): Promise<Response> { 58 getScope () {
174 return this.refreshFetch(this.getVideoUrl(videoId), { headers: this.headers }) 59 return this.playerManagerOptions.getScope()
175 } 60 }
176 61
177 loadVideoCaptions (videoId: string): Promise<Response> { 62 // ---------------------------------------------------------------------------
178 return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers })
179 }
180 63
181 loadWithLive (video: VideoDetails) { 64 async init () {
182 return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers }) 65 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
183 .then(res => res.json()) 66 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
184 .then((live: LiveVideo) => ({ video, live }))
185 }
186 67
187 loadPlaylistInfo (playlistId: string): Promise<Response> { 68 // Issue when we parsed config from HTML, fallback to API
188 return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) 69 if (!this.config) {
189 } 70 this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false })
71 .then(res => res.json())
72 }
190 73
191 loadPlaylistElements (playlistId: string, start = 0): Promise<Response> { 74 const videoId = this.isPlaylistEmbed()
192 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos') 75 ? await this.initPlaylist()
193 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString() 76 : this.getResourceId()
194 77
195 return this.refreshFetch(url.toString(), { headers: this.headers }) 78 if (!videoId) return
196 }
197 79
198 removeElement (element: HTMLElement) { 80 return this.loadVideoAndBuildPlayer(videoId)
199 element.parentElement.removeChild(element)
200 } 81 }
201 82
202 displayError (text: string, translations?: Translations) { 83 private async initPlaylist () {
203 // Remove video element 84 const playlistId = this.getResourceId()
204 if (this.playerElement) {
205 this.removeElement(this.playerElement)
206 this.playerElement = undefined
207 }
208
209 const translatedText = peertubeTranslate(text, translations)
210 const translatedSorry = peertubeTranslate('Sorry', translations)
211 85
212 document.title = translatedSorry + ' - ' + translatedText 86 try {
87 const res = await this.playlistFetcher.loadPlaylist(playlistId)
213 88
214 const errorBlock = document.getElementById('error-block') 89 const [ playlist, playlistElementResult ] = await Promise.all([
215 errorBlock.style.display = 'flex' 90 res.playlistResponse.json() as Promise<VideoPlaylist>,
91 res.videosResponse.json() as Promise<ResultList<VideoPlaylistElement>>
92 ])
216 93
217 const errorTitle = document.getElementById('error-title') 94 const allPlaylistElements = await this.playlistFetcher.loadAllPlaylistVideos(playlistId, playlistElementResult)
218 errorTitle.innerHTML = peertubeTranslate('Sorry', translations)
219 95
220 const errorText = document.getElementById('error-content') 96 this.playlistTracker = new PlaylistTracker(playlist, allPlaylistElements)
221 errorText.innerHTML = translatedText
222 97
223 this.wrapperElement.style.display = 'none' 98 const params = new URL(window.location.toString()).searchParams
224 } 99 const playlistPositionParam = getParamString(params, 'playlistPosition')
225
226 videoNotFound (translations?: Translations) {
227 const text = 'This video does not exist.'
228 this.displayError(text, translations)
229 }
230 100
231 videoFetchError (translations?: Translations) { 101 const position = playlistPositionParam
232 const text = 'We cannot fetch the video. Please try again later.' 102 ? parseInt(playlistPositionParam + '', 10)
233 this.displayError(text, translations) 103 : 1
234 }
235 104
236 playlistNotFound (translations?: Translations) { 105 this.playlistTracker.setPosition(position)
237 const text = 'This playlist does not exist.' 106 } catch (err) {
238 this.displayError(text, translations) 107 this.playerHTML.displayError(err.message, await this.translationsPromise)
239 } 108 return undefined
109 }
240 110
241 playlistFetchError (translations?: Translations) { 111 return this.playlistTracker.getCurrentElement().video.uuid
242 const text = 'We cannot fetch the playlist. Please try again later.'
243 this.displayError(text, translations)
244 } 112 }
245 113
246 getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) { 114 private initializeApi () {
247 return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue 115 if (this.playerManagerOptions.hasAPIEnabled()) {
116 this.api = new PeerTubeEmbedApi(this)
117 this.api.initialize()
118 }
248 } 119 }
249 120
250 getParamString (params: URLSearchParams, name: string, defaultValue?: string) { 121 // ---------------------------------------------------------------------------
251 return params.has(name) ? params.get(name) : defaultValue
252 }
253 122
254 async playNextVideo () { 123 async playNextPlaylistVideo () {
255 const next = this.getNextPlaylistElement() 124 const next = this.playlistTracker.getNextPlaylistElement()
256 if (!next) { 125 if (!next) {
257 console.log('Next element not found in playlist.') 126 console.log('Next element not found in playlist.')
258 return 127 return
259 } 128 }
260 129
261 this.currentPlaylistElement = next 130 this.playlistTracker.setCurrentElement(next)
262 131
263 return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) 132 return this.loadVideoAndBuildPlayer(next.video.uuid)
264 } 133 }
265 134
266 async playPreviousVideo () { 135 async playPreviousPlaylistVideo () {
267 const previous = this.getPreviousPlaylistElement() 136 const previous = this.playlistTracker.getPreviousPlaylistElement()
268 if (!previous) { 137 if (!previous) {
269 console.log('Previous element not found in playlist.') 138 console.log('Previous element not found in playlist.')
270 return 139 return
271 } 140 }
272 141
273 this.currentPlaylistElement = previous 142 this.playlistTracker.setCurrentElement(previous)
274 143
275 await this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) 144 await this.loadVideoAndBuildPlayer(previous.video.uuid)
276 } 145 }
277 146
278 getCurrentPosition () { 147 getCurrentPlaylistPosition () {
279 if (!this.currentPlaylistElement) return -1 148 return this.playlistTracker.getCurrentPosition()
280
281 return this.currentPlaylistElement.position
282 }
283
284 async init () {
285 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage)
286 await this.initCore()
287 }
288
289 private initializeApi () {
290 if (!this.enableApi) return
291
292 this.api = new PeerTubeEmbedApi(this)
293 this.api.initialize()
294 } 149 }
295 150
296 private loadParams (video: VideoDetails) { 151 // ---------------------------------------------------------------------------
297 try {
298 const params = new URL(window.location.toString()).searchParams
299
300 this.autoplay = this.getParamToggle(params, 'autoplay', false)
301
302 this.controls = this.getParamToggle(params, 'controls', true)
303 this.controlBar = this.getParamToggle(params, 'controlBar', true)
304
305 this.muted = this.getParamToggle(params, 'muted', undefined)
306 this.loop = this.getParamToggle(params, 'loop', false)
307 this.title = this.getParamToggle(params, 'title', true)
308 this.enableApi = this.getParamToggle(params, 'api', this.enableApi)
309 this.warningTitle = this.getParamToggle(params, 'warningTitle', true)
310 this.peertubeLink = this.getParamToggle(params, 'peertubeLink', true)
311 this.p2pEnabled = this.getParamToggle(params, 'p2p', this.isP2PEnabled(video))
312
313 this.scope = this.getParamString(params, 'scope', this.scope)
314 this.subtitle = this.getParamString(params, 'subtitle')
315 this.startTime = this.getParamString(params, 'start')
316 this.stopTime = this.getParamString(params, 'stop')
317
318 this.bigPlayBackgroundColor = this.getParamString(params, 'bigPlayBackgroundColor')
319 this.foregroundColor = this.getParamString(params, 'foregroundColor')
320
321 const modeParam = this.getParamString(params, 'mode')
322
323 if (modeParam) {
324 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
325 else this.mode = 'webtorrent'
326 } else {
327 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
328 else this.mode = 'webtorrent'
329 }
330 } catch (err) {
331 console.error('Cannot get params from URL.', err)
332 }
333 }
334
335 private async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
336 let elements = baseResult.data
337 let total = baseResult.total
338 let i = 0
339
340 while (total > elements.length && i < 10) {
341 const result = await this.loadPlaylistElements(playlistId, elements.length)
342
343 const json = await result.json()
344 total = json.total
345
346 elements = elements.concat(json.data)
347 i++
348 }
349
350 if (i === 10) {
351 console.error('Cannot fetch all playlists elements, there are too many!')
352 }
353
354 return elements
355 }
356
357 private async loadPlaylist (playlistId: string) {
358 const playlistPromise = this.loadPlaylistInfo(playlistId)
359 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
360
361 let playlistResponse: Response
362 let isResponseOk: boolean
363 152
153 private async loadVideoAndBuildPlayer (uuid: string) {
364 try { 154 try {
365 playlistResponse = await playlistPromise 155 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid)
366 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
367 } catch (err) {
368 console.error(err)
369 isResponseOk = false
370 }
371
372 if (!isResponseOk) {
373 const serverTranslations = await this.translationsPromise
374
375 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
376 this.playlistNotFound(serverTranslations)
377 return undefined
378 }
379
380 this.playlistFetchError(serverTranslations)
381 return undefined
382 }
383 156
384 return { playlistResponse, videosResponse: await playlistElementsPromise } 157 return this.buildVideoPlayer(videoResponse, captionsPromise)
385 }
386
387 private async loadVideo (videoId: string) {
388 const videoPromise = this.loadVideoInfo(videoId)
389
390 let videoResponse: Response
391 let isResponseOk: boolean
392
393 try {
394 videoResponse = await videoPromise
395 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
396 } catch (err) { 158 } catch (err) {
397 console.error(err) 159 this.playerHTML.displayError(err.message, await this.translationsPromise)
398
399 isResponseOk = false
400 } 160 }
401
402 if (!isResponseOk) {
403 const serverTranslations = await this.translationsPromise
404
405 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
406 this.videoNotFound(serverTranslations)
407 return undefined
408 }
409
410 this.videoFetchError(serverTranslations)
411 return undefined
412 }
413
414 const captionsPromise = this.loadVideoCaptions(videoId)
415
416 return { captionsPromise, videoResponse }
417 }
418
419 private async buildPlaylistManager () {
420 const translations = await this.translationsPromise
421
422 this.player.upnext({
423 timeout: 10000, // 10s
424 headText: peertubeTranslate('Up Next', translations),
425 cancelText: peertubeTranslate('Cancel', translations),
426 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
427 getTitle: () => this.nextVideoTitle(),
428 next: () => this.playNextVideo(),
429 condition: () => !!this.getNextPlaylistElement(),
430 suspended: () => false
431 })
432 }
433
434 private async loadVideoAndBuildPlayer (uuid: string) {
435 const res = await this.loadVideo(uuid)
436 if (res === undefined) return
437
438 return this.buildVideoPlayer(res.videoResponse, res.captionsPromise)
439 }
440
441 private nextVideoTitle () {
442 const next = this.getNextPlaylistElement()
443 if (!next) return ''
444
445 return next.video.name
446 }
447
448 private getNextPlaylistElement (position?: number): VideoPlaylistElement {
449 if (!position) position = this.currentPlaylistElement.position + 1
450
451 if (position > this.playlist.videosLength) {
452 return undefined
453 }
454
455 const next = this.playlistElements.find(e => e.position === position)
456
457 if (!next || !next.video) {
458 return this.getNextPlaylistElement(position + 1)
459 }
460
461 return next
462 }
463
464 private getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
465 if (!position) position = this.currentPlaylistElement.position - 1
466
467 if (position < 1) {
468 return undefined
469 }
470
471 const prev = this.playlistElements.find(e => e.position === position)
472
473 if (!prev || !prev.video) {
474 return this.getNextPlaylistElement(position - 1)
475 }
476
477 return prev
478 } 161 }
479 162
480 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) { 163 private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise<Response>) {
481 let alreadyHadPlayer = false 164 const alreadyHadPlayer = this.resetPlayerElement()
482
483 if (this.player) {
484 this.player.dispose()
485 alreadyHadPlayer = true
486 }
487
488 this.playerElement = document.createElement('video')
489 this.playerElement.className = 'video-js vjs-peertube-skin'
490 this.playerElement.setAttribute('playsinline', 'true')
491 this.wrapperElement.appendChild(this.playerElement)
492
493 // Issue when we parsed config from HTML, fallback to API
494 if (!this.config) {
495 this.config = await this.refreshFetch('/api/v1/config')
496 .then(res => res.json())
497 }
498 165
499 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json() 166 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
500 .then((videoInfo: VideoDetails) => { 167 .then((videoInfo: VideoDetails) => {
501 this.loadParams(videoInfo) 168 this.playerManagerOptions.loadParams(this.config, videoInfo)
502 169
503 if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo) 170 if (!alreadyHadPlayer && !this.playerManagerOptions.hasAutoplay()) {
171 this.playerHTML.buildPlaceholder(videoInfo)
172 }
504 173
505 if (!videoInfo.isLive) return { video: videoInfo } 174 if (!videoInfo.isLive) {
175 return { video: videoInfo }
176 }
506 177
507 return this.loadWithLive(videoInfo) 178 return this.videoFetcher.loadVideoWithLive(videoInfo)
508 }) 179 })
509 180
510 const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 181 const [ { video, live }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([
511 videoInfoPromise, 182 videoInfoPromise,
512 this.translationsPromise, 183 this.translationsPromise,
513 captionsPromise, 184 captionsPromise,
514 this.PeertubePlayerManagerModulePromise 185 this.PeertubePlayerManagerModulePromise
515 ]) 186 ])
516 187
517 await this.loadPlugins(serverTranslations) 188 await this.peertubePlugin.loadPlugins(this.config, translations)
518
519 const { video: videoInfo, live } = videoInfoTmp
520
521 const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
522 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
523
524 const liveOptions = videoInfo.isLive
525 ? { latencyMode: live.latencyMode }
526 : undefined
527
528 const playlistPlugin = this.currentPlaylistElement
529 ? {
530 elements: this.playlistElements,
531 playlist: this.playlist,
532
533 getCurrentPosition: () => this.currentPlaylistElement.position,
534
535 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
536 this.currentPlaylistElement = videoPlaylistElement
537
538 this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid)
539 .catch(err => console.error(err))
540 }
541 }
542 : undefined
543
544 const options: PeertubePlayerManagerOptions = {
545 common: {
546 // Autoplay in playlist mode
547 autoplay: alreadyHadPlayer ? true : this.autoplay,
548
549 controls: this.controls,
550 controlBar: this.controlBar,
551
552 muted: this.muted,
553 loop: this.loop,
554 189
555 p2pEnabled: this.p2pEnabled, 190 const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
556 191
557 captions: videoCaptions.length !== 0, 192 const options = await this.playerManagerOptions.getPlayerOptions({
558 subtitle: this.subtitle, 193 video,
194 captionsResponse,
195 alreadyHadPlayer,
196 translations,
197 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer(uuid),
559 198
560 startTime: this.playlist ? this.currentPlaylistElement.startTimestamp : this.startTime, 199 playlistTracker: this.playlistTracker,
561 stopTime: this.playlist ? this.currentPlaylistElement.stopTimestamp : this.stopTime, 200 playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
201 playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
562 202
563 nextVideo: this.playlist ? () => this.playNextVideo() : undefined, 203 live
564 hasNextVideo: this.playlist ? () => !!this.getNextPlaylistElement() : undefined, 204 })
565
566 previousVideo: this.playlist ? () => this.playPreviousVideo() : undefined,
567 hasPreviousVideo: this.playlist ? () => !!this.getPreviousPlaylistElement() : undefined,
568
569 playlist: playlistPlugin,
570
571 videoCaptions,
572 inactivityTimeout: 2500,
573 videoViewUrl: this.getVideoUrl(videoInfo.uuid) + '/views',
574 videoShortUUID: videoInfo.shortUUID,
575 videoUUID: videoInfo.uuid,
576
577 isLive: videoInfo.isLive,
578 liveOptions,
579
580 playerElement: this.playerElement,
581 onPlayerElementChange: (element: HTMLVideoElement) => {
582 this.playerElement = element
583 },
584
585 videoDuration: videoInfo.duration,
586 enableHotkeys: true,
587 peertubeLink: this.peertubeLink,
588 poster: window.location.origin + videoInfo.previewPath,
589 theaterButton: false,
590
591 serverUrl: window.location.origin,
592 language: navigator.language,
593 embedUrl: window.location.origin + videoInfo.embedPath,
594 embedTitle: videoInfo.name,
595
596 errorNotifier: () => {
597 // Empty, we don't have a notifier in the embed
598 }
599 },
600
601 webtorrent: {
602 videoFiles: videoInfo.files
603 },
604
605 pluginsManager: this.pluginsManager
606 }
607
608 if (this.mode === 'p2p-media-loader') {
609 const hlsPlaylist = videoInfo.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
610
611 Object.assign(options, {
612 p2pMediaLoader: {
613 playlistUrl: hlsPlaylist.playlistUrl,
614 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
615 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
616 trackerAnnounce: videoInfo.trackerUrls,
617 videoFiles: hlsPlaylist.files
618 } as P2PMediaLoaderOptions
619 })
620 }
621 205
622 this.player = await PeertubePlayerManager.initialize(this.mode, options, (player: videojs.Player) => { 206 this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), options, (player: videojs.Player) => {
623 this.player = player 207 this.player = player
624 }) 208 })
625 209
626 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) 210 this.player.on('customError', (event: any, data: any) => {
211 const message = data?.err?.message || ''
212 if (!message.includes('from xs param')) return
213
214 this.player.dispose()
215 this.playerHTML.removePlayerElement()
216 this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations)
217 })
627 218
628 window['videojsPlayer'] = this.player 219 window['videojsPlayer'] = this.player
629 220
630 this.buildCSS() 221 this.buildCSS()
631 222 this.buildPlayerDock(video)
632 this.buildDock(videoInfo)
633
634 this.initializeApi() 223 this.initializeApi()
635 224
636 this.removePlaceholder() 225 this.playerHTML.removePlaceholder()
637 226
638 if (this.isPlaylistEmbed()) { 227 if (this.isPlaylistEmbed()) {
639 await this.buildPlaylistManager() 228 await this.buildPlayerPlaylistUpnext()
640 229
641 this.player.playlist().updateSelected() 230 this.player.playlist().updateSelected()
642 231
643 this.player.on('stopped', () => { 232 this.player.on('stopped', () => {
644 this.playNextVideo() 233 this.playNextPlaylistVideo()
645 }) 234 })
646 } 235 }
647 236
648 this.pluginsManager.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo }) 237 this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video })
649 } 238 }
650 239
651 private async initCore () { 240 private resetPlayerElement () {
652 if (this.userTokens) this.setHeadersFromTokens() 241 let alreadyHadPlayer = false
653
654 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
655 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager')
656
657 let videoId: string
658
659 if (this.isPlaylistEmbed()) {
660 const playlistId = this.getResourceId()
661 const res = await this.loadPlaylist(playlistId)
662 if (!res) return undefined
663 242
664 this.playlist = await res.playlistResponse.json() 243 if (this.player) {
244 this.player.dispose()
245 alreadyHadPlayer = true
246 }
665 247
666 const playlistElementResult = await res.videosResponse.json() 248 const playerElement = document.createElement('video')
667 this.playlistElements = await this.loadAllPlaylistVideos(playlistId, playlistElementResult) 249 playerElement.className = 'video-js vjs-peertube-skin'
250 playerElement.setAttribute('playsinline', 'true')
668 251
669 const params = new URL(window.location.toString()).searchParams 252 this.playerHTML.setPlayerElement(playerElement)
670 const playlistPositionParam = this.getParamString(params, 'playlistPosition') 253 this.playerHTML.addPlayerElementToDOM()
671
672 let position = 1
673
674 if (playlistPositionParam) {
675 position = parseInt(playlistPositionParam + '', 10)
676 }
677
678 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
679 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
680 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
681 this.currentPlaylistElement = this.getNextPlaylistElement()
682 }
683
684 if (!this.currentPlaylistElement) {
685 console.error('This playlist does not have any valid element.')
686 const serverTranslations = await this.translationsPromise
687 this.playlistFetchError(serverTranslations)
688 return
689 }
690
691 videoId = this.currentPlaylistElement.video.uuid
692 } else {
693 videoId = this.getResourceId()
694 }
695 254
696 return this.loadVideoAndBuildPlayer(videoId) 255 return alreadyHadPlayer
697 } 256 }
698 257
699 private handleError (err: Error, translations?: { [ id: string ]: string }) { 258 private async buildPlayerPlaylistUpnext () {
700 if (err.message.includes('from xs param')) { 259 const translations = await this.translationsPromise
701 this.player.dispose() 260
702 this.playerElement = null 261 this.player.upnext({
703 this.displayError('This video is not available because the remote instance is not responding.', translations) 262 timeout: 10000, // 10s
704 } 263 headText: peertubeTranslate('Up Next', translations),
264 cancelText: peertubeTranslate('Cancel', translations),
265 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
266 getTitle: () => this.playlistTracker.nextVideoTitle(),
267 next: () => this.playNextPlaylistVideo(),
268 condition: () => !!this.playlistTracker.getNextPlaylistElement(),
269 suspended: () => false
270 })
705 } 271 }
706 272
707 private buildDock (videoInfo: VideoDetails) { 273 private buildPlayerDock (videoInfo: VideoDetails) {
708 if (!this.controls) return 274 if (!this.playerManagerOptions.hasControls()) return
709 275
710 // On webtorrent fallback, player may have been disposed 276 // On webtorrent fallback, player may have been disposed
711 if (!this.player.player_) return 277 if (!this.player.player_) return
712 278
713 const title = this.title ? videoInfo.name : undefined 279 const title = this.playerManagerOptions.hasTitle()
714 const description = this.warningTitle && this.p2pEnabled 280 ? videoInfo.name
281 : undefined
282
283 const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
715 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>' 284 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
716 : undefined 285 : undefined
717 286
287 if (!title && !description) return
288
718 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) 289 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
719 const avatar = availableAvatars.length !== 0 290 const avatar = availableAvatars.length !== 0
720 ? availableAvatars[0] 291 ? availableAvatars[0]
721 : undefined 292 : undefined
722 293
723 if (title || description) { 294 this.player.peertubeDock({
724 this.player.peertubeDock({ 295 title,
725 title, 296 description,
726 description, 297 avatarUrl: title && avatar
727 avatarUrl: title && avatar 298 ? avatar.path
728 ? avatar.path 299 : undefined
729 : undefined 300 })
730 })
731 }
732 } 301 }
733 302
734 private buildCSS () { 303 private buildCSS () {
735 const body = document.getElementById('custom-css') 304 const body = document.getElementById('custom-css')
736 305
737 if (this.bigPlayBackgroundColor) { 306 if (this.playerManagerOptions.hasBigPlayBackgroundColor()) {
738 body.style.setProperty('--embedBigPlayBackgroundColor', this.bigPlayBackgroundColor) 307 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor())
739 } 308 }
740 309
741 if (this.foregroundColor) { 310 if (this.playerManagerOptions.hasForegroundColor()) {
742 body.style.setProperty('--embedForegroundColor', this.foregroundColor) 311 body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor())
743 } 312 }
744 } 313 }
745 314
746 private async buildCaptions (serverTranslations: any, captionsResponse: Response): Promise<VideoJSCaption[]> { 315 // ---------------------------------------------------------------------------
747 if (captionsResponse.ok) {
748 const { data } = await captionsResponse.json()
749
750 return data.map((c: VideoCaption) => ({
751 label: peertubeTranslate(c.language.label, serverTranslations),
752 language: c.language.id,
753 src: window.location.origin + c.captionPath
754 }))
755 }
756
757 return []
758 }
759
760 private buildPlaceholder (video: VideoDetails) {
761 const placeholder = this.getPlaceholderElement()
762
763 const url = window.location.origin + video.previewPath
764 placeholder.style.backgroundImage = `url("${url}")`
765 placeholder.style.display = 'block'
766 }
767
768 private removePlaceholder () {
769 const placeholder = this.getPlaceholderElement()
770 placeholder.style.display = 'none'
771 }
772
773 private getPlaceholderElement () {
774 return document.getElementById('placeholder-preview')
775 }
776
777 private getHeaderTokenValue () {
778 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
779 }
780
781 private setHeadersFromTokens () {
782 this.headers.set('Authorization', this.getHeaderTokenValue())
783 }
784
785 private removeTokensFromHeaders () {
786 this.headers.delete('Authorization')
787 }
788 316
789 private getResourceId () { 317 private getResourceId () {
790 const urlParts = window.location.pathname.split('/') 318 const urlParts = window.location.pathname.split('/')
@@ -794,69 +322,6 @@ export class PeerTubeEmbed {
794 private isPlaylistEmbed () { 322 private isPlaylistEmbed () {
795 return window.location.pathname.split('/')[1] === 'video-playlists' 323 return window.location.pathname.split('/')[1] === 'video-playlists'
796 } 324 }
797
798 private loadPlugins (translations?: { [ id: string ]: string }) {
799 this.pluginsManager = new PluginsManager({
800 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers(pluginInfo, translations)
801 })
802
803 this.pluginsManager.loadPluginsList(this.config)
804
805 return this.pluginsManager.ensurePluginsAreLoaded('embed')
806 }
807
808 private buildPeerTubeHelpers (pluginInfo: PluginInfo, translations?: { [ id: string ]: string }): RegisterClientHelpers {
809 const unimplemented = () => {
810 throw new Error('This helper is not implemented in embed.')
811 }
812
813 return {
814 getBaseStaticRoute: unimplemented,
815 getBaseRouterRoute: unimplemented,
816 getBasePluginClientPath: unimplemented,
817
818 getSettings: () => {
819 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
820
821 return this.refreshFetch(url, { headers: this.headers })
822 .then(res => res.json())
823 .then((obj: PublicServerSetting) => obj.publicSettings)
824 },
825
826 isLoggedIn: () => !!this.userTokens,
827 getAuthHeader: () => {
828 if (!this.userTokens) return undefined
829
830 return { Authorization: this.getHeaderTokenValue() }
831 },
832
833 notifier: {
834 info: unimplemented,
835 error: unimplemented,
836 success: unimplemented
837 },
838
839 showModal: unimplemented,
840
841 getServerConfig: unimplemented,
842
843 markdownRenderer: {
844 textMarkdownToHTML: unimplemented,
845 enhancedMarkdownToHTML: unimplemented
846 },
847
848 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
849 }
850 }
851
852 private isP2PEnabled (video: Video) {
853 const userP2PEnabled = getBoolOrDefault(
854 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
855 this.config.defaults.p2p.embed.enabled
856 )
857
858 return isP2PEnabled(video, this.config, userP2PEnabled)
859 }
860} 325}
861 326
862PeerTubeEmbed.main() 327PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
new file mode 100644
index 000000000..0356ab8a6
--- /dev/null
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -0,0 +1,105 @@
1import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models'
2import { objectToUrlEncoded, UserTokens } from '../../../root-helpers'
3import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage'
4
5export class AuthHTTP {
6 private readonly LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
7 CLIENT_ID: 'client_id',
8 CLIENT_SECRET: 'client_secret'
9 }
10
11 private userTokens: UserTokens
12
13 private headers = new Headers()
14
15 constructor () {
16 this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage)
17
18 if (this.userTokens) this.setHeadersFromTokens()
19 }
20
21 fetch (url: string, { optionalAuth }: { optionalAuth: boolean }) {
22 const refreshFetchOptions = optionalAuth
23 ? { headers: this.headers }
24 : {}
25
26 return this.refreshFetch(url.toString(), refreshFetchOptions)
27 }
28
29 getHeaderTokenValue () {
30 return `${this.userTokens.tokenType} ${this.userTokens.accessToken}`
31 }
32
33 isLoggedIn () {
34 return !!this.userTokens
35 }
36
37 private refreshFetch (url: string, options?: RequestInit) {
38 return fetch(url, options)
39 .then((res: Response) => {
40 if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res
41
42 const refreshingTokenPromise = new Promise<void>((resolve, reject) => {
43 const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID)
44 const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET)
45
46 const headers = new Headers()
47 headers.set('Content-Type', 'application/x-www-form-urlencoded')
48
49 const data = {
50 refresh_token: this.userTokens.refreshToken,
51 client_id: clientId,
52 client_secret: clientSecret,
53 response_type: 'code',
54 grant_type: 'refresh_token'
55 }
56
57 fetch('/api/v1/users/token', {
58 headers,
59 method: 'POST',
60 body: objectToUrlEncoded(data)
61 }).then(res => {
62 if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined
63
64 return res.json()
65 }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => {
66 if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) {
67 UserTokens.flushLocalStorage(peertubeLocalStorage)
68 this.removeTokensFromHeaders()
69
70 return resolve()
71 }
72
73 this.userTokens.accessToken = obj.access_token
74 this.userTokens.refreshToken = obj.refresh_token
75 UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens)
76
77 this.setHeadersFromTokens()
78
79 resolve()
80 }).catch((refreshTokenError: any) => {
81 reject(refreshTokenError)
82 })
83 })
84
85 return refreshingTokenPromise
86 .catch(() => {
87 UserTokens.flushLocalStorage(peertubeLocalStorage)
88
89 this.removeTokensFromHeaders()
90 }).then(() => fetch(url, {
91 ...options,
92
93 headers: this.headers
94 }))
95 })
96 }
97
98 private setHeadersFromTokens () {
99 this.headers.set('Authorization', this.getHeaderTokenValue())
100 }
101
102 private removeTokensFromHeaders () {
103 this.headers.delete('Authorization')
104 }
105}
diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts
new file mode 100644
index 000000000..4b4e05b7c
--- /dev/null
+++ b/client/src/standalone/videos/shared/index.ts
@@ -0,0 +1,8 @@
1export * from './auth-http'
2export * from './peertube-plugin'
3export * from './player-html'
4export * from './player-manager-options'
5export * from './playlist-fetcher'
6export * from './playlist-tracker'
7export * from './translations'
8export * from './video-fetcher'
diff --git a/client/src/standalone/videos/shared/peertube-plugin.ts b/client/src/standalone/videos/shared/peertube-plugin.ts
new file mode 100644
index 000000000..968854ce8
--- /dev/null
+++ b/client/src/standalone/videos/shared/peertube-plugin.ts
@@ -0,0 +1,85 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models'
3import { PluginInfo, PluginsManager } from '../../../root-helpers'
4import { RegisterClientHelpers } from '../../../types'
5import { AuthHTTP } from './auth-http'
6import { Translations } from './translations'
7
8export class PeerTubePlugin {
9
10 private pluginsManager: PluginsManager
11
12 constructor (private readonly http: AuthHTTP) {
13
14 }
15
16 loadPlugins (config: HTMLServerConfig, translations?: Translations) {
17 this.pluginsManager = new PluginsManager({
18 peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers({
19 pluginInfo,
20 translations
21 })
22 })
23
24 this.pluginsManager.loadPluginsList(config)
25
26 return this.pluginsManager.ensurePluginsAreLoaded('embed')
27 }
28
29 getPluginsManager () {
30 return this.pluginsManager
31 }
32
33 private buildPeerTubeHelpers (options: {
34 pluginInfo: PluginInfo
35 translations?: Translations
36 }): RegisterClientHelpers {
37 const { pluginInfo, translations } = options
38
39 const unimplemented = () => {
40 throw new Error('This helper is not implemented in embed.')
41 }
42
43 return {
44 getBaseStaticRoute: unimplemented,
45 getBaseRouterRoute: unimplemented,
46 getBasePluginClientPath: unimplemented,
47
48 getSettings: () => {
49 const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings'
50
51 return this.http.fetch(url, { optionalAuth: true })
52 .then(res => res.json())
53 .then((obj: PublicServerSetting) => obj.publicSettings)
54 },
55
56 isLoggedIn: () => this.http.isLoggedIn(),
57 getAuthHeader: () => {
58 if (!this.http.isLoggedIn()) return undefined
59
60 return { Authorization: this.http.getHeaderTokenValue() }
61 },
62
63 notifier: {
64 info: unimplemented,
65 error: unimplemented,
66 success: unimplemented
67 },
68
69 showModal: unimplemented,
70
71 getServerConfig: unimplemented,
72
73 markdownRenderer: {
74 textMarkdownToHTML: unimplemented,
75 enhancedMarkdownToHTML: unimplemented
76 },
77
78 translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations))
79 }
80 }
81
82 private getPluginUrl () {
83 return window.location.origin + '/api/v1/plugins'
84 }
85}
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
new file mode 100644
index 000000000..110124417
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -0,0 +1,76 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { VideoDetails } from '../../../../../shared/models'
3import { Translations } from './translations'
4
5export class PlayerHTML {
6 private readonly wrapperElement: HTMLElement
7
8 private playerElement: HTMLVideoElement
9
10 constructor (private readonly videoWrapperId: string) {
11 this.wrapperElement = document.getElementById(this.videoWrapperId)
12 }
13
14 getPlayerElement () {
15 return this.playerElement
16 }
17
18 setPlayerElement (playerElement: HTMLVideoElement) {
19 this.playerElement = playerElement
20 }
21
22 removePlayerElement () {
23 this.playerElement = null
24 }
25
26 addPlayerElementToDOM () {
27 this.wrapperElement.appendChild(this.playerElement)
28 }
29
30 displayError (text: string, translations: Translations) {
31 console.error(text)
32
33 // Remove video element
34 if (this.playerElement) {
35 this.removeElement(this.playerElement)
36 this.playerElement = undefined
37 }
38
39 const translatedText = peertubeTranslate(text, translations)
40 const translatedSorry = peertubeTranslate('Sorry', translations)
41
42 document.title = translatedSorry + ' - ' + translatedText
43
44 const errorBlock = document.getElementById('error-block')
45 errorBlock.style.display = 'flex'
46
47 const errorTitle = document.getElementById('error-title')
48 errorTitle.innerHTML = peertubeTranslate('Sorry', translations)
49
50 const errorText = document.getElementById('error-content')
51 errorText.innerHTML = translatedText
52
53 this.wrapperElement.style.display = 'none'
54 }
55
56 buildPlaceholder (video: VideoDetails) {
57 const placeholder = this.getPlaceholderElement()
58
59 const url = window.location.origin + video.previewPath
60 placeholder.style.backgroundImage = `url("${url}")`
61 placeholder.style.display = 'block'
62 }
63
64 removePlaceholder () {
65 const placeholder = this.getPlaceholderElement()
66 placeholder.style.display = 'none'
67 }
68
69 private getPlaceholderElement () {
70 return document.getElementById('placeholder-preview')
71 }
72
73 private removeElement (element: HTMLElement) {
74 element.parentElement.removeChild(element)
75 }
76}
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
new file mode 100644
index 000000000..144d74319
--- /dev/null
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -0,0 +1,323 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import {
3 HTMLServerConfig,
4 LiveVideo,
5 Video,
6 VideoCaption,
7 VideoDetails,
8 VideoPlaylistElement,
9 VideoStreamingPlaylistType
10} from '../../../../../shared/models'
11import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
12import {
13 getBoolOrDefault,
14 getParamString,
15 getParamToggle,
16 isP2PEnabled,
17 peertubeLocalStorage,
18 UserLocalStorageKeys
19} from '../../../root-helpers'
20import { PeerTubePlugin } from './peertube-plugin'
21import { PlayerHTML } from './player-html'
22import { PlaylistTracker } from './playlist-tracker'
23import { Translations } from './translations'
24import { VideoFetcher } from './video-fetcher'
25
26export class PlayerManagerOptions {
27 private autoplay: boolean
28
29 private controls: boolean
30 private controlBar: boolean
31
32 private muted: boolean
33 private loop: boolean
34 private subtitle: string
35 private enableApi = false
36 private startTime: number | string = 0
37 private stopTime: number | string
38
39 private title: boolean
40 private warningTitle: boolean
41 private peertubeLink: boolean
42 private p2pEnabled: boolean
43 private bigPlayBackgroundColor: string
44 private foregroundColor: string
45
46 private mode: PlayerMode
47 private scope = 'peertube'
48
49 constructor (
50 private readonly playerHTML: PlayerHTML,
51 private readonly videoFetcher: VideoFetcher,
52 private readonly peertubePlugin: PeerTubePlugin
53 ) {}
54
55 hasAPIEnabled () {
56 return this.enableApi
57 }
58
59 hasAutoplay () {
60 return this.autoplay
61 }
62
63 hasControls () {
64 return this.controls
65 }
66
67 hasTitle () {
68 return this.title
69 }
70
71 hasWarningTitle () {
72 return this.warningTitle
73 }
74
75 hasP2PEnabled () {
76 return !!this.p2pEnabled
77 }
78
79 hasBigPlayBackgroundColor () {
80 return !!this.bigPlayBackgroundColor
81 }
82
83 getBigPlayBackgroundColor () {
84 return this.bigPlayBackgroundColor
85 }
86
87 hasForegroundColor () {
88 return !!this.foregroundColor
89 }
90
91 getForegroundColor () {
92 return this.foregroundColor
93 }
94
95 getMode () {
96 return this.mode
97 }
98
99 getScope () {
100 return this.scope
101 }
102
103 // ---------------------------------------------------------------------------
104
105 loadParams (config: HTMLServerConfig, video: VideoDetails) {
106 try {
107 const params = new URL(window.location.toString()).searchParams
108
109 this.autoplay = getParamToggle(params, 'autoplay', false)
110
111 this.controls = getParamToggle(params, 'controls', true)
112 this.controlBar = getParamToggle(params, 'controlBar', true)
113
114 this.muted = getParamToggle(params, 'muted', undefined)
115 this.loop = getParamToggle(params, 'loop', false)
116 this.title = getParamToggle(params, 'title', true)
117 this.enableApi = getParamToggle(params, 'api', this.enableApi)
118 this.warningTitle = getParamToggle(params, 'warningTitle', true)
119 this.peertubeLink = getParamToggle(params, 'peertubeLink', true)
120 this.p2pEnabled = getParamToggle(params, 'p2p', this.isP2PEnabled(config, video))
121
122 this.scope = getParamString(params, 'scope', this.scope)
123 this.subtitle = getParamString(params, 'subtitle')
124 this.startTime = getParamString(params, 'start')
125 this.stopTime = getParamString(params, 'stop')
126
127 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
128 this.foregroundColor = getParamString(params, 'foregroundColor')
129
130 const modeParam = getParamString(params, 'mode')
131
132 if (modeParam) {
133 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
134 else this.mode = 'webtorrent'
135 } else {
136 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
137 else this.mode = 'webtorrent'
138 }
139 } catch (err) {
140 console.error('Cannot get params from URL.', err)
141 }
142 }
143
144 // ---------------------------------------------------------------------------
145
146 async getPlayerOptions (options: {
147 video: VideoDetails
148 captionsResponse: Response
149 live?: LiveVideo
150
151 alreadyHadPlayer: boolean
152
153 translations: Translations
154
155 playlistTracker?: PlaylistTracker
156 playNextPlaylistVideo?: () => any
157 playPreviousPlaylistVideo?: () => any
158 onVideoUpdate?: (uuid: string) => any
159 }) {
160 const {
161 video,
162 captionsResponse,
163 alreadyHadPlayer,
164 translations,
165 playlistTracker,
166 live
167 } = options
168
169 const videoCaptions = await this.buildCaptions(captionsResponse, translations)
170
171 const playerOptions: PeertubePlayerManagerOptions = {
172 common: {
173 // Autoplay in playlist mode
174 autoplay: alreadyHadPlayer ? true : this.autoplay,
175
176 controls: this.controls,
177 controlBar: this.controlBar,
178
179 muted: this.muted,
180 loop: this.loop,
181
182 p2pEnabled: this.p2pEnabled,
183
184 captions: videoCaptions.length !== 0,
185 subtitle: this.subtitle,
186
187 startTime: playlistTracker
188 ? playlistTracker.getCurrentElement().startTimestamp
189 : this.startTime,
190 stopTime: playlistTracker
191 ? playlistTracker.getCurrentElement().stopTimestamp
192 : this.stopTime,
193
194 videoCaptions,
195 inactivityTimeout: 2500,
196 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
197
198 videoShortUUID: video.shortUUID,
199 videoUUID: video.uuid,
200
201 playerElement: this.playerHTML.getPlayerElement(),
202 onPlayerElementChange: (element: HTMLVideoElement) => {
203 this.playerHTML.setPlayerElement(element)
204 },
205
206 videoDuration: video.duration,
207 enableHotkeys: true,
208 peertubeLink: this.peertubeLink,
209 poster: window.location.origin + video.previewPath,
210 theaterButton: false,
211
212 serverUrl: window.location.origin,
213 language: navigator.language,
214 embedUrl: window.location.origin + video.embedPath,
215 embedTitle: video.name,
216
217 errorNotifier: () => {
218 // Empty, we don't have a notifier in the embed
219 },
220
221 ...this.buildLiveOptions(video, live),
222
223 ...this.buildPlaylistOptions(options)
224 },
225
226 webtorrent: {
227 videoFiles: video.files
228 },
229
230 ...this.buildP2PMediaLoaderOptions(video),
231
232 pluginsManager: this.peertubePlugin.getPluginsManager()
233 }
234
235 return playerOptions
236 }
237
238 private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
239 if (!video.isLive) return { isLive: false }
240
241 return {
242 isLive: true,
243 liveOptions: {
244 latencyMode: live.latencyMode
245 }
246 }
247 }
248
249 private buildPlaylistOptions (options: {
250 playlistTracker?: PlaylistTracker
251 playNextPlaylistVideo?: () => any
252 playPreviousPlaylistVideo?: () => any
253 onVideoUpdate?: (uuid: string) => any
254 }) {
255 const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options
256
257 if (!playlistTracker) return {}
258
259 return {
260 playlist: {
261 elements: playlistTracker.getPlaylistElements(),
262 playlist: playlistTracker.getPlaylist(),
263
264 getCurrentPosition: () => playlistTracker.getCurrentPosition(),
265
266 onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => {
267 playlistTracker.setCurrentElement(videoPlaylistElement)
268
269 onVideoUpdate(videoPlaylistElement.video.uuid)
270 }
271 },
272
273 nextVideo: () => playNextPlaylistVideo(),
274 hasNextVideo: () => playlistTracker.hasNextPlaylistElement(),
275
276 previousVideo: () => playPreviousPlaylistVideo(),
277 hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement()
278 }
279 }
280
281 private buildP2PMediaLoaderOptions (video: VideoDetails) {
282 if (this.mode !== 'p2p-media-loader') return {}
283
284 const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
285
286 return {
287 p2pMediaLoader: {
288 playlistUrl: hlsPlaylist.playlistUrl,
289 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
290 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
291 trackerAnnounce: video.trackerUrls,
292 videoFiles: hlsPlaylist.files
293 } as P2PMediaLoaderOptions
294 }
295 }
296
297 // ---------------------------------------------------------------------------
298
299 private async buildCaptions (captionsResponse: Response, translations: Translations): Promise<VideoJSCaption[]> {
300 if (captionsResponse.ok) {
301 const { data } = await captionsResponse.json()
302
303 return data.map((c: VideoCaption) => ({
304 label: peertubeTranslate(c.language.label, translations),
305 language: c.language.id,
306 src: window.location.origin + c.captionPath
307 }))
308 }
309
310 return []
311 }
312
313 // ---------------------------------------------------------------------------
314
315 private isP2PEnabled (config: HTMLServerConfig, video: Video) {
316 const userP2PEnabled = getBoolOrDefault(
317 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
318 config.defaults.p2p.embed.enabled
319 )
320
321 return isP2PEnabled(video, config, userP2PEnabled)
322 }
323}
diff --git a/client/src/standalone/videos/shared/playlist-fetcher.ts b/client/src/standalone/videos/shared/playlist-fetcher.ts
new file mode 100644
index 000000000..a7e72c177
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-fetcher.ts
@@ -0,0 +1,72 @@
1import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class PlaylistFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadPlaylist (playlistId: string) {
11 const playlistPromise = this.loadPlaylistInfo(playlistId)
12 const playlistElementsPromise = this.loadPlaylistElements(playlistId)
13
14 let playlistResponse: Response
15 let isResponseOk: boolean
16
17 try {
18 playlistResponse = await playlistPromise
19 isResponseOk = playlistResponse.status === HttpStatusCode.OK_200
20 } catch (err) {
21 console.error(err)
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This playlist does not exist.')
28 }
29
30 throw new Error('We cannot fetch the playlist. Please try again later.')
31 }
32
33 return { playlistResponse, videosResponse: await playlistElementsPromise }
34 }
35
36 async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList<VideoPlaylistElement>) {
37 let elements = baseResult.data
38 let total = baseResult.total
39 let i = 0
40
41 while (total > elements.length && i < 10) {
42 const result = await this.loadPlaylistElements(playlistId, elements.length)
43
44 const json = await result.json()
45 total = json.total
46
47 elements = elements.concat(json.data)
48 i++
49 }
50
51 if (i === 10) {
52 console.error('Cannot fetch all playlists elements, there are too many!')
53 }
54
55 return elements
56 }
57
58 private loadPlaylistInfo (playlistId: string): Promise<Response> {
59 return this.http.fetch(this.getPlaylistUrl(playlistId), { optionalAuth: true })
60 }
61
62 private loadPlaylistElements (playlistId: string, start = 0): Promise<Response> {
63 const url = new URL(this.getPlaylistUrl(playlistId) + '/videos')
64 url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString()
65
66 return this.http.fetch(url.toString(), { optionalAuth: true })
67 }
68
69 private getPlaylistUrl (id: string) {
70 return window.location.origin + '/api/v1/video-playlists/' + id
71 }
72}
diff --git a/client/src/standalone/videos/shared/playlist-tracker.ts b/client/src/standalone/videos/shared/playlist-tracker.ts
new file mode 100644
index 000000000..75d10b4e2
--- /dev/null
+++ b/client/src/standalone/videos/shared/playlist-tracker.ts
@@ -0,0 +1,93 @@
1import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models'
2
3export class PlaylistTracker {
4 private currentPlaylistElement: VideoPlaylistElement
5
6 constructor (
7 private readonly playlist: VideoPlaylist,
8 private readonly playlistElements: VideoPlaylistElement[]
9 ) {
10
11 }
12
13 getPlaylist () {
14 return this.playlist
15 }
16
17 getPlaylistElements () {
18 return this.playlistElements
19 }
20
21 hasNextPlaylistElement (position?: number) {
22 return !!this.getNextPlaylistElement(position)
23 }
24
25 getNextPlaylistElement (position?: number): VideoPlaylistElement {
26 if (!position) position = this.currentPlaylistElement.position + 1
27
28 if (position > this.playlist.videosLength) {
29 return undefined
30 }
31
32 const next = this.playlistElements.find(e => e.position === position)
33
34 if (!next || !next.video) {
35 return this.getNextPlaylistElement(position + 1)
36 }
37
38 return next
39 }
40
41 hasPreviousPlaylistElement (position?: number) {
42 return !!this.getPreviousPlaylistElement(position)
43 }
44
45 getPreviousPlaylistElement (position?: number): VideoPlaylistElement {
46 if (!position) position = this.currentPlaylistElement.position - 1
47
48 if (position < 1) {
49 return undefined
50 }
51
52 const prev = this.playlistElements.find(e => e.position === position)
53
54 if (!prev || !prev.video) {
55 return this.getNextPlaylistElement(position - 1)
56 }
57
58 return prev
59 }
60
61 nextVideoTitle () {
62 const next = this.getNextPlaylistElement()
63 if (!next) return ''
64
65 return next.video.name
66 }
67
68 setPosition (position: number) {
69 this.currentPlaylistElement = this.playlistElements.find(e => e.position === position)
70 if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) {
71 console.error('Current playlist element is not valid.', this.currentPlaylistElement)
72 this.currentPlaylistElement = this.getNextPlaylistElement()
73 }
74
75 if (!this.currentPlaylistElement) {
76 throw new Error('This playlist does not have any valid element')
77 }
78 }
79
80 setCurrentElement (playlistElement: VideoPlaylistElement) {
81 this.currentPlaylistElement = playlistElement
82 }
83
84 getCurrentElement () {
85 return this.currentPlaylistElement
86 }
87
88 getCurrentPosition () {
89 if (!this.currentPlaylistElement) return -1
90
91 return this.currentPlaylistElement.position
92 }
93}
diff --git a/client/src/standalone/videos/shared/translations.ts b/client/src/standalone/videos/shared/translations.ts
new file mode 100644
index 000000000..146732495
--- /dev/null
+++ b/client/src/standalone/videos/shared/translations.ts
@@ -0,0 +1,5 @@
1type Translations = { [ id: string ]: string }
2
3export {
4 Translations
5}
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
new file mode 100644
index 000000000..e78d38536
--- /dev/null
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -0,0 +1,63 @@
1import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models'
2import { AuthHTTP } from './auth-http'
3
4export class VideoFetcher {
5
6 constructor (private readonly http: AuthHTTP) {
7
8 }
9
10 async loadVideo (videoId: string) {
11 const videoPromise = this.loadVideoInfo(videoId)
12
13 let videoResponse: Response
14 let isResponseOk: boolean
15
16 try {
17 videoResponse = await videoPromise
18 isResponseOk = videoResponse.status === HttpStatusCode.OK_200
19 } catch (err) {
20 console.error(err)
21
22 isResponseOk = false
23 }
24
25 if (!isResponseOk) {
26 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
27 throw new Error('This video does not exist.')
28 }
29
30 throw new Error('We cannot fetch the video. Please try again later.')
31 }
32
33 const captionsPromise = this.loadVideoCaptions(videoId)
34
35 return { captionsPromise, videoResponse }
36 }
37
38 loadVideoWithLive (video: VideoDetails) {
39 return this.http.fetch(this.getLiveUrl(video.uuid), { optionalAuth: true })
40 .then(res => res.json())
41 .then((live: LiveVideo) => ({ video, live }))
42 }
43
44 getVideoViewsUrl (videoUUID: string) {
45 return this.getVideoUrl(videoUUID) + '/views'
46 }
47
48 private loadVideoInfo (videoId: string): Promise<Response> {
49 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true })
50 }
51
52 private loadVideoCaptions (videoId: string): Promise<Response> {
53 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true })
54 }
55
56 private getVideoUrl (id: string) {
57 return window.location.origin + '/api/v1/videos/' + id
58 }
59
60 private getLiveUrl (videoId: string) {
61 return window.location.origin + '/api/v1/videos/live/' + videoId
62 }
63}