diff options
Diffstat (limited to 'client/src/standalone/videos/embed.ts')
-rw-r--r-- | client/src/standalone/videos/embed.ts | 859 |
1 files changed, 162 insertions, 697 deletions
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' | |||
3 | import '../../assets/player/shared/dock/peertube-dock-plugin' | 3 | import '../../assets/player/shared/dock/peertube-dock-plugin' |
4 | import videojs from 'video.js' | 4 | import videojs from 'video.js' |
5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' | 5 | import { peertubeTranslate } from '../../../../shared/core-utils/i18n' |
6 | import { | 6 | import { 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' | ||
21 | import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player' | ||
22 | import { TranslationsManager } from '../../assets/player/translations-manager' | 7 | import { TranslationsManager } from '../../assets/player/translations-manager' |
23 | import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' | 8 | import { getParamString } from '../../root-helpers' |
24 | import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage' | ||
25 | import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager' | ||
26 | import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users' | ||
27 | import { objectToUrlEncoded } from '../../root-helpers/utils' | ||
28 | import { isP2PEnabled } from '../../root-helpers/video' | ||
29 | import { RegisterClientHelpers } from '../../types/register-client-option.model' | ||
30 | import { PeerTubeEmbedApi } from './embed-api' | 9 | import { PeerTubeEmbedApi } from './embed-api' |
31 | 10 | import { AuthHTTP, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared' | |
32 | type Translations = { [ id: string ]: string } | 11 | import { PlayerHTML } from './shared/player-html' |
12 | import { PeertubePlayerManager } from '../../assets/player' | ||
33 | 13 | ||
34 | export class PeerTubeEmbed { | 14 | export 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 | ||
862 | PeerTubeEmbed.main() | 327 | PeerTubeEmbed.main() |