diff options
author | Chocobozzz <me@florianbigard.com> | 2022-05-31 08:59:30 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-05-31 10:39:56 +0200 |
commit | f1a0f3b701e005a9533f09b7913c615376e42f32 (patch) | |
tree | 807e5377635bfa30d42ce42bdcd88ff7b1553257 /client/src/standalone/videos/shared | |
parent | e5a781ec25191c0dbb4a991f25307732d798619d (diff) | |
download | PeerTube-f1a0f3b701e005a9533f09b7913c615376e42f32.tar.gz PeerTube-f1a0f3b701e005a9533f09b7913c615376e42f32.tar.zst PeerTube-f1a0f3b701e005a9533f09b7913c615376e42f32.zip |
Refactor embed
Diffstat (limited to 'client/src/standalone/videos/shared')
9 files changed, 830 insertions, 0 deletions
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 @@ | |||
1 | import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models' | ||
2 | import { objectToUrlEncoded, UserTokens } from '../../../root-helpers' | ||
3 | import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage' | ||
4 | |||
5 | export 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 @@ | |||
1 | export * from './auth-http' | ||
2 | export * from './peertube-plugin' | ||
3 | export * from './player-html' | ||
4 | export * from './player-manager-options' | ||
5 | export * from './playlist-fetcher' | ||
6 | export * from './playlist-tracker' | ||
7 | export * from './translations' | ||
8 | export * 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models' | ||
3 | import { PluginInfo, PluginsManager } from '../../../root-helpers' | ||
4 | import { RegisterClientHelpers } from '../../../types' | ||
5 | import { AuthHTTP } from './auth-http' | ||
6 | import { Translations } from './translations' | ||
7 | |||
8 | export 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { VideoDetails } from '../../../../../shared/models' | ||
3 | import { Translations } from './translations' | ||
4 | |||
5 | export 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 @@ | |||
1 | import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' | ||
2 | import { | ||
3 | HTMLServerConfig, | ||
4 | LiveVideo, | ||
5 | Video, | ||
6 | VideoCaption, | ||
7 | VideoDetails, | ||
8 | VideoPlaylistElement, | ||
9 | VideoStreamingPlaylistType | ||
10 | } from '../../../../../shared/models' | ||
11 | import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' | ||
12 | import { | ||
13 | getBoolOrDefault, | ||
14 | getParamString, | ||
15 | getParamToggle, | ||
16 | isP2PEnabled, | ||
17 | peertubeLocalStorage, | ||
18 | UserLocalStorageKeys | ||
19 | } from '../../../root-helpers' | ||
20 | import { PeerTubePlugin } from './peertube-plugin' | ||
21 | import { PlayerHTML } from './player-html' | ||
22 | import { PlaylistTracker } from './playlist-tracker' | ||
23 | import { Translations } from './translations' | ||
24 | import { VideoFetcher } from './video-fetcher' | ||
25 | |||
26 | export 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 @@ | |||
1 | import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models' | ||
2 | import { AuthHTTP } from './auth-http' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | type Translations = { [ id: string ]: string } | ||
2 | |||
3 | export { | ||
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 @@ | |||
1 | import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models' | ||
2 | import { AuthHTTP } from './auth-http' | ||
3 | |||
4 | export 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 | } | ||