From f1a0f3b701e005a9533f09b7913c615376e42f32 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 31 May 2022 08:59:30 +0200 Subject: Refactor embed --- client/src/standalone/videos/embed-api.ts | 10 +- client/src/standalone/videos/embed.ts | 859 ++++----------------- client/src/standalone/videos/shared/auth-http.ts | 105 +++ client/src/standalone/videos/shared/index.ts | 8 + .../standalone/videos/shared/peertube-plugin.ts | 85 ++ client/src/standalone/videos/shared/player-html.ts | 76 ++ .../videos/shared/player-manager-options.ts | 323 ++++++++ .../standalone/videos/shared/playlist-fetcher.ts | 72 ++ .../standalone/videos/shared/playlist-tracker.ts | 93 +++ .../src/standalone/videos/shared/translations.ts | 5 + .../src/standalone/videos/shared/video-fetcher.ts | 63 ++ 11 files changed, 997 insertions(+), 702 deletions(-) create mode 100644 client/src/standalone/videos/shared/auth-http.ts create mode 100644 client/src/standalone/videos/shared/index.ts create mode 100644 client/src/standalone/videos/shared/peertube-plugin.ts create mode 100644 client/src/standalone/videos/shared/player-html.ts create mode 100644 client/src/standalone/videos/shared/player-manager-options.ts create mode 100644 client/src/standalone/videos/shared/playlist-fetcher.ts create mode 100644 client/src/standalone/videos/shared/playlist-tracker.ts create mode 100644 client/src/standalone/videos/shared/translations.ts create mode 100644 client/src/standalone/videos/shared/video-fetcher.ts (limited to 'client/src/standalone/videos') 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 { } private get element () { - return this.embed.playerElement + return this.embed.getPlayerElement() } private constructChannel () { - const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.scope }) + const channel = Channel.build({ window: window.parent, origin: '*', scope: this.embed.getScope() }) channel.bind('play', (txn, params) => this.embed.player.play()) channel.bind('pause', (txn, params) => this.embed.player.pause()) @@ -52,9 +52,9 @@ export class PeerTubeEmbedApi { channel.bind('getPlaybackRate', (txn, params) => this.embed.player.playbackRate()) channel.bind('getPlaybackRates', (txn, params) => this.embed.player.options_.playbackRates) - channel.bind('playNextVideo', (txn, params) => this.embed.playNextVideo()) - channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousVideo()) - channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPosition()) + channel.bind('playNextVideo', (txn, params) => this.embed.playNextPlaylistVideo()) + channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo()) + channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition()) this.channel = channel } 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' import '../../assets/player/shared/dock/peertube-dock-plugin' import videojs from 'video.js' import { peertubeTranslate } from '../../../../shared/core-utils/i18n' -import { - HTMLServerConfig, - HttpStatusCode, - LiveVideo, - OAuth2ErrorCode, - PublicServerSetting, - ResultList, - UserRefreshToken, - Video, - VideoCaption, - VideoDetails, - VideoPlaylist, - VideoPlaylistElement, - VideoStreamingPlaylistType -} from '../../../../shared/models' -import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../assets/player' +import { HTMLServerConfig, LiveVideo, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement } from '../../../../shared/models' import { TranslationsManager } from '../../assets/player/translations-manager' -import { getBoolOrDefault } from '../../root-helpers/local-storage-utils' -import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage' -import { PluginInfo, PluginsManager } from '../../root-helpers/plugins-manager' -import { UserLocalStorageKeys, UserTokens } from '../../root-helpers/users' -import { objectToUrlEncoded } from '../../root-helpers/utils' -import { isP2PEnabled } from '../../root-helpers/video' -import { RegisterClientHelpers } from '../../types/register-client-option.model' +import { getParamString } from '../../root-helpers' import { PeerTubeEmbedApi } from './embed-api' - -type Translations = { [ id: string ]: string } +import { AuthHTTP, PeerTubePlugin, PlayerManagerOptions, PlaylistFetcher, PlaylistTracker, VideoFetcher } from './shared' +import { PlayerHTML } from './shared/player-html' +import { PeertubePlayerManager } from '../../assets/player' export class PeerTubeEmbed { - playerElement: HTMLVideoElement player: videojs.Player api: PeerTubeEmbedApi = null - autoplay: boolean - - controls: boolean - controlBar: boolean - - muted: boolean - loop: boolean - subtitle: string - enableApi = false - startTime: number | string = 0 - stopTime: number | string - - title: boolean - warningTitle: boolean - peertubeLink: boolean - p2pEnabled: boolean - bigPlayBackgroundColor: string - foregroundColor: string - - mode: PlayerMode - scope = 'peertube' - - userTokens: UserTokens - headers = new Headers() - LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { - CLIENT_ID: 'client_id', - CLIENT_SECRET: 'client_secret' - } - config: HTMLServerConfig private translationsPromise: Promise<{ [id: string]: string }> private PeertubePlayerManagerModulePromise: Promise - private playlist: VideoPlaylist - private playlistElements: VideoPlaylistElement[] - private currentPlaylistElement: VideoPlaylistElement + private readonly http: AuthHTTP + private readonly videoFetcher: VideoFetcher + private readonly playlistFetcher: PlaylistFetcher + private readonly peertubePlugin: PeerTubePlugin + private readonly playerHTML: PlayerHTML + private readonly playerManagerOptions: PlayerManagerOptions - private readonly wrapperElement: HTMLElement + private playlistTracker: PlaylistTracker - private pluginsManager: PluginsManager + constructor (videoWrapperId: string) { + this.http = new AuthHTTP() - constructor (private readonly videoWrapperId: string) { - this.wrapperElement = document.getElementById(this.videoWrapperId) + this.videoFetcher = new VideoFetcher(this.http) + this.playlistFetcher = new PlaylistFetcher(this.http) + this.peertubePlugin = new PeerTubePlugin(this.http) + this.playerHTML = new PlayerHTML(videoWrapperId) + this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) try { this.config = JSON.parse(window['PeerTubeServerConfig']) @@ -94,697 +51,268 @@ export class PeerTubeEmbed { await embed.init() } - getVideoUrl (id: string) { - return window.location.origin + '/api/v1/videos/' + id - } - - getLiveUrl (videoId: string) { - return window.location.origin + '/api/v1/videos/live/' + videoId - } - - getPluginUrl () { - return window.location.origin + '/api/v1/plugins' - } - - refreshFetch (url: string, options?: RequestInit) { - return fetch(url, options) - .then((res: Response) => { - if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res - - const refreshingTokenPromise = new Promise((resolve, reject) => { - const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID) - const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET) - - const headers = new Headers() - headers.set('Content-Type', 'application/x-www-form-urlencoded') - - const data = { - refresh_token: this.userTokens.refreshToken, - client_id: clientId, - client_secret: clientSecret, - response_type: 'code', - grant_type: 'refresh_token' - } - - fetch('/api/v1/users/token', { - headers, - method: 'POST', - body: objectToUrlEncoded(data) - }).then(res => { - if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined - - return res.json() - }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => { - if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) { - UserTokens.flushLocalStorage(peertubeLocalStorage) - this.removeTokensFromHeaders() - - return resolve() - } - - this.userTokens.accessToken = obj.access_token - this.userTokens.refreshToken = obj.refresh_token - UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens) - - this.setHeadersFromTokens() - - resolve() - }).catch((refreshTokenError: any) => { - reject(refreshTokenError) - }) - }) - - return refreshingTokenPromise - .catch(() => { - UserTokens.flushLocalStorage(peertubeLocalStorage) - - this.removeTokensFromHeaders() - }).then(() => fetch(url, { - ...options, - headers: this.headers - })) - }) - } - - getPlaylistUrl (id: string) { - return window.location.origin + '/api/v1/video-playlists/' + id + getPlayerElement () { + return this.playerHTML.getPlayerElement() } - loadVideoInfo (videoId: string): Promise { - return this.refreshFetch(this.getVideoUrl(videoId), { headers: this.headers }) + getScope () { + return this.playerManagerOptions.getScope() } - loadVideoCaptions (videoId: string): Promise { - return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers }) - } + // --------------------------------------------------------------------------- - loadWithLive (video: VideoDetails) { - return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers }) - .then(res => res.json()) - .then((live: LiveVideo) => ({ video, live })) - } + async init () { + this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) + this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') - loadPlaylistInfo (playlistId: string): Promise { - return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) - } + // Issue when we parsed config from HTML, fallback to API + if (!this.config) { + this.config = await this.http.fetch('/api/v1/config', { optionalAuth: false }) + .then(res => res.json()) + } - loadPlaylistElements (playlistId: string, start = 0): Promise { - const url = new URL(this.getPlaylistUrl(playlistId) + '/videos') - url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString() + const videoId = this.isPlaylistEmbed() + ? await this.initPlaylist() + : this.getResourceId() - return this.refreshFetch(url.toString(), { headers: this.headers }) - } + if (!videoId) return - removeElement (element: HTMLElement) { - element.parentElement.removeChild(element) + return this.loadVideoAndBuildPlayer(videoId) } - displayError (text: string, translations?: Translations) { - // Remove video element - if (this.playerElement) { - this.removeElement(this.playerElement) - this.playerElement = undefined - } - - const translatedText = peertubeTranslate(text, translations) - const translatedSorry = peertubeTranslate('Sorry', translations) + private async initPlaylist () { + const playlistId = this.getResourceId() - document.title = translatedSorry + ' - ' + translatedText + try { + const res = await this.playlistFetcher.loadPlaylist(playlistId) - const errorBlock = document.getElementById('error-block') - errorBlock.style.display = 'flex' + const [ playlist, playlistElementResult ] = await Promise.all([ + res.playlistResponse.json() as Promise, + res.videosResponse.json() as Promise> + ]) - const errorTitle = document.getElementById('error-title') - errorTitle.innerHTML = peertubeTranslate('Sorry', translations) + const allPlaylistElements = await this.playlistFetcher.loadAllPlaylistVideos(playlistId, playlistElementResult) - const errorText = document.getElementById('error-content') - errorText.innerHTML = translatedText + this.playlistTracker = new PlaylistTracker(playlist, allPlaylistElements) - this.wrapperElement.style.display = 'none' - } - - videoNotFound (translations?: Translations) { - const text = 'This video does not exist.' - this.displayError(text, translations) - } + const params = new URL(window.location.toString()).searchParams + const playlistPositionParam = getParamString(params, 'playlistPosition') - videoFetchError (translations?: Translations) { - const text = 'We cannot fetch the video. Please try again later.' - this.displayError(text, translations) - } + const position = playlistPositionParam + ? parseInt(playlistPositionParam + '', 10) + : 1 - playlistNotFound (translations?: Translations) { - const text = 'This playlist does not exist.' - this.displayError(text, translations) - } + this.playlistTracker.setPosition(position) + } catch (err) { + this.playerHTML.displayError(err.message, await this.translationsPromise) + return undefined + } - playlistFetchError (translations?: Translations) { - const text = 'We cannot fetch the playlist. Please try again later.' - this.displayError(text, translations) + return this.playlistTracker.getCurrentElement().video.uuid } - getParamToggle (params: URLSearchParams, name: string, defaultValue?: boolean) { - return params.has(name) ? (params.get(name) === '1' || params.get(name) === 'true') : defaultValue + private initializeApi () { + if (this.playerManagerOptions.hasAPIEnabled()) { + this.api = new PeerTubeEmbedApi(this) + this.api.initialize() + } } - getParamString (params: URLSearchParams, name: string, defaultValue?: string) { - return params.has(name) ? params.get(name) : defaultValue - } + // --------------------------------------------------------------------------- - async playNextVideo () { - const next = this.getNextPlaylistElement() + async playNextPlaylistVideo () { + const next = this.playlistTracker.getNextPlaylistElement() if (!next) { console.log('Next element not found in playlist.') return } - this.currentPlaylistElement = next + this.playlistTracker.setCurrentElement(next) - return this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) + return this.loadVideoAndBuildPlayer(next.video.uuid) } - async playPreviousVideo () { - const previous = this.getPreviousPlaylistElement() + async playPreviousPlaylistVideo () { + const previous = this.playlistTracker.getPreviousPlaylistElement() if (!previous) { console.log('Previous element not found in playlist.') return } - this.currentPlaylistElement = previous + this.playlistTracker.setCurrentElement(previous) - await this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) + await this.loadVideoAndBuildPlayer(previous.video.uuid) } - getCurrentPosition () { - if (!this.currentPlaylistElement) return -1 - - return this.currentPlaylistElement.position - } - - async init () { - this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage) - await this.initCore() - } - - private initializeApi () { - if (!this.enableApi) return - - this.api = new PeerTubeEmbedApi(this) - this.api.initialize() + getCurrentPlaylistPosition () { + return this.playlistTracker.getCurrentPosition() } - private loadParams (video: VideoDetails) { - try { - const params = new URL(window.location.toString()).searchParams - - this.autoplay = this.getParamToggle(params, 'autoplay', false) - - this.controls = this.getParamToggle(params, 'controls', true) - this.controlBar = this.getParamToggle(params, 'controlBar', true) - - this.muted = this.getParamToggle(params, 'muted', undefined) - this.loop = this.getParamToggle(params, 'loop', false) - this.title = this.getParamToggle(params, 'title', true) - this.enableApi = this.getParamToggle(params, 'api', this.enableApi) - this.warningTitle = this.getParamToggle(params, 'warningTitle', true) - this.peertubeLink = this.getParamToggle(params, 'peertubeLink', true) - this.p2pEnabled = this.getParamToggle(params, 'p2p', this.isP2PEnabled(video)) - - this.scope = this.getParamString(params, 'scope', this.scope) - this.subtitle = this.getParamString(params, 'subtitle') - this.startTime = this.getParamString(params, 'start') - this.stopTime = this.getParamString(params, 'stop') - - this.bigPlayBackgroundColor = this.getParamString(params, 'bigPlayBackgroundColor') - this.foregroundColor = this.getParamString(params, 'foregroundColor') - - const modeParam = this.getParamString(params, 'mode') - - if (modeParam) { - if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' - else this.mode = 'webtorrent' - } else { - if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' - else this.mode = 'webtorrent' - } - } catch (err) { - console.error('Cannot get params from URL.', err) - } - } - - private async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList) { - let elements = baseResult.data - let total = baseResult.total - let i = 0 - - while (total > elements.length && i < 10) { - const result = await this.loadPlaylistElements(playlistId, elements.length) - - const json = await result.json() - total = json.total - - elements = elements.concat(json.data) - i++ - } - - if (i === 10) { - console.error('Cannot fetch all playlists elements, there are too many!') - } - - return elements - } - - private async loadPlaylist (playlistId: string) { - const playlistPromise = this.loadPlaylistInfo(playlistId) - const playlistElementsPromise = this.loadPlaylistElements(playlistId) - - let playlistResponse: Response - let isResponseOk: boolean + // --------------------------------------------------------------------------- + private async loadVideoAndBuildPlayer (uuid: string) { try { - playlistResponse = await playlistPromise - isResponseOk = playlistResponse.status === HttpStatusCode.OK_200 - } catch (err) { - console.error(err) - isResponseOk = false - } - - if (!isResponseOk) { - const serverTranslations = await this.translationsPromise - - if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) { - this.playlistNotFound(serverTranslations) - return undefined - } - - this.playlistFetchError(serverTranslations) - return undefined - } + const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) - return { playlistResponse, videosResponse: await playlistElementsPromise } - } - - private async loadVideo (videoId: string) { - const videoPromise = this.loadVideoInfo(videoId) - - let videoResponse: Response - let isResponseOk: boolean - - try { - videoResponse = await videoPromise - isResponseOk = videoResponse.status === HttpStatusCode.OK_200 + return this.buildVideoPlayer(videoResponse, captionsPromise) } catch (err) { - console.error(err) - - isResponseOk = false + this.playerHTML.displayError(err.message, await this.translationsPromise) } - - if (!isResponseOk) { - const serverTranslations = await this.translationsPromise - - if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { - this.videoNotFound(serverTranslations) - return undefined - } - - this.videoFetchError(serverTranslations) - return undefined - } - - const captionsPromise = this.loadVideoCaptions(videoId) - - return { captionsPromise, videoResponse } - } - - private async buildPlaylistManager () { - const translations = await this.translationsPromise - - this.player.upnext({ - timeout: 10000, // 10s - headText: peertubeTranslate('Up Next', translations), - cancelText: peertubeTranslate('Cancel', translations), - suspendedText: peertubeTranslate('Autoplay is suspended', translations), - getTitle: () => this.nextVideoTitle(), - next: () => this.playNextVideo(), - condition: () => !!this.getNextPlaylistElement(), - suspended: () => false - }) - } - - private async loadVideoAndBuildPlayer (uuid: string) { - const res = await this.loadVideo(uuid) - if (res === undefined) return - - return this.buildVideoPlayer(res.videoResponse, res.captionsPromise) - } - - private nextVideoTitle () { - const next = this.getNextPlaylistElement() - if (!next) return '' - - return next.video.name - } - - private getNextPlaylistElement (position?: number): VideoPlaylistElement { - if (!position) position = this.currentPlaylistElement.position + 1 - - if (position > this.playlist.videosLength) { - return undefined - } - - const next = this.playlistElements.find(e => e.position === position) - - if (!next || !next.video) { - return this.getNextPlaylistElement(position + 1) - } - - return next - } - - private getPreviousPlaylistElement (position?: number): VideoPlaylistElement { - if (!position) position = this.currentPlaylistElement.position - 1 - - if (position < 1) { - return undefined - } - - const prev = this.playlistElements.find(e => e.position === position) - - if (!prev || !prev.video) { - return this.getNextPlaylistElement(position - 1) - } - - return prev } private async buildVideoPlayer (videoResponse: Response, captionsPromise: Promise) { - let alreadyHadPlayer = false - - if (this.player) { - this.player.dispose() - alreadyHadPlayer = true - } - - this.playerElement = document.createElement('video') - this.playerElement.className = 'video-js vjs-peertube-skin' - this.playerElement.setAttribute('playsinline', 'true') - this.wrapperElement.appendChild(this.playerElement) - - // Issue when we parsed config from HTML, fallback to API - if (!this.config) { - this.config = await this.refreshFetch('/api/v1/config') - .then(res => res.json()) - } + const alreadyHadPlayer = this.resetPlayerElement() const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json() .then((videoInfo: VideoDetails) => { - this.loadParams(videoInfo) + this.playerManagerOptions.loadParams(this.config, videoInfo) - if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo) + if (!alreadyHadPlayer && !this.playerManagerOptions.hasAutoplay()) { + this.playerHTML.buildPlaceholder(videoInfo) + } - if (!videoInfo.isLive) return { video: videoInfo } + if (!videoInfo.isLive) { + return { video: videoInfo } + } - return this.loadWithLive(videoInfo) + return this.videoFetcher.loadVideoWithLive(videoInfo) }) - const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ + const [ { video, live }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ videoInfoPromise, this.translationsPromise, captionsPromise, this.PeertubePlayerManagerModulePromise ]) - await this.loadPlugins(serverTranslations) - - const { video: videoInfo, live } = videoInfoTmp - - const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager - const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse) - - const liveOptions = videoInfo.isLive - ? { latencyMode: live.latencyMode } - : undefined - - const playlistPlugin = this.currentPlaylistElement - ? { - elements: this.playlistElements, - playlist: this.playlist, - - getCurrentPosition: () => this.currentPlaylistElement.position, - - onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => { - this.currentPlaylistElement = videoPlaylistElement - - this.loadVideoAndBuildPlayer(this.currentPlaylistElement.video.uuid) - .catch(err => console.error(err)) - } - } - : undefined - - const options: PeertubePlayerManagerOptions = { - common: { - // Autoplay in playlist mode - autoplay: alreadyHadPlayer ? true : this.autoplay, - - controls: this.controls, - controlBar: this.controlBar, - - muted: this.muted, - loop: this.loop, + await this.peertubePlugin.loadPlugins(this.config, translations) - p2pEnabled: this.p2pEnabled, + const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager - captions: videoCaptions.length !== 0, - subtitle: this.subtitle, + const options = await this.playerManagerOptions.getPlayerOptions({ + video, + captionsResponse, + alreadyHadPlayer, + translations, + onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer(uuid), - startTime: this.playlist ? this.currentPlaylistElement.startTimestamp : this.startTime, - stopTime: this.playlist ? this.currentPlaylistElement.stopTimestamp : this.stopTime, + playlistTracker: this.playlistTracker, + playNextPlaylistVideo: () => this.playNextPlaylistVideo(), + playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(), - nextVideo: this.playlist ? () => this.playNextVideo() : undefined, - hasNextVideo: this.playlist ? () => !!this.getNextPlaylistElement() : undefined, - - previousVideo: this.playlist ? () => this.playPreviousVideo() : undefined, - hasPreviousVideo: this.playlist ? () => !!this.getPreviousPlaylistElement() : undefined, - - playlist: playlistPlugin, - - videoCaptions, - inactivityTimeout: 2500, - videoViewUrl: this.getVideoUrl(videoInfo.uuid) + '/views', - videoShortUUID: videoInfo.shortUUID, - videoUUID: videoInfo.uuid, - - isLive: videoInfo.isLive, - liveOptions, - - playerElement: this.playerElement, - onPlayerElementChange: (element: HTMLVideoElement) => { - this.playerElement = element - }, - - videoDuration: videoInfo.duration, - enableHotkeys: true, - peertubeLink: this.peertubeLink, - poster: window.location.origin + videoInfo.previewPath, - theaterButton: false, - - serverUrl: window.location.origin, - language: navigator.language, - embedUrl: window.location.origin + videoInfo.embedPath, - embedTitle: videoInfo.name, - - errorNotifier: () => { - // Empty, we don't have a notifier in the embed - } - }, - - webtorrent: { - videoFiles: videoInfo.files - }, - - pluginsManager: this.pluginsManager - } - - if (this.mode === 'p2p-media-loader') { - const hlsPlaylist = videoInfo.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) - - Object.assign(options, { - p2pMediaLoader: { - playlistUrl: hlsPlaylist.playlistUrl, - segmentsSha256Url: hlsPlaylist.segmentsSha256Url, - redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), - trackerAnnounce: videoInfo.trackerUrls, - videoFiles: hlsPlaylist.files - } as P2PMediaLoaderOptions - }) - } + live + }) - this.player = await PeertubePlayerManager.initialize(this.mode, options, (player: videojs.Player) => { + this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), options, (player: videojs.Player) => { this.player = player }) - this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) + this.player.on('customError', (event: any, data: any) => { + const message = data?.err?.message || '' + if (!message.includes('from xs param')) return + + this.player.dispose() + this.playerHTML.removePlayerElement() + this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations) + }) window['videojsPlayer'] = this.player this.buildCSS() - - this.buildDock(videoInfo) - + this.buildPlayerDock(video) this.initializeApi() - this.removePlaceholder() + this.playerHTML.removePlaceholder() if (this.isPlaylistEmbed()) { - await this.buildPlaylistManager() + await this.buildPlayerPlaylistUpnext() this.player.playlist().updateSelected() this.player.on('stopped', () => { - this.playNextVideo() + this.playNextPlaylistVideo() }) } - this.pluginsManager.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo }) + this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video }) } - private async initCore () { - if (this.userTokens) this.setHeadersFromTokens() - - this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) - this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') - - let videoId: string - - if (this.isPlaylistEmbed()) { - const playlistId = this.getResourceId() - const res = await this.loadPlaylist(playlistId) - if (!res) return undefined + private resetPlayerElement () { + let alreadyHadPlayer = false - this.playlist = await res.playlistResponse.json() + if (this.player) { + this.player.dispose() + alreadyHadPlayer = true + } - const playlistElementResult = await res.videosResponse.json() - this.playlistElements = await this.loadAllPlaylistVideos(playlistId, playlistElementResult) + const playerElement = document.createElement('video') + playerElement.className = 'video-js vjs-peertube-skin' + playerElement.setAttribute('playsinline', 'true') - const params = new URL(window.location.toString()).searchParams - const playlistPositionParam = this.getParamString(params, 'playlistPosition') - - let position = 1 - - if (playlistPositionParam) { - position = parseInt(playlistPositionParam + '', 10) - } - - this.currentPlaylistElement = this.playlistElements.find(e => e.position === position) - if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) { - console.error('Current playlist element is not valid.', this.currentPlaylistElement) - this.currentPlaylistElement = this.getNextPlaylistElement() - } - - if (!this.currentPlaylistElement) { - console.error('This playlist does not have any valid element.') - const serverTranslations = await this.translationsPromise - this.playlistFetchError(serverTranslations) - return - } - - videoId = this.currentPlaylistElement.video.uuid - } else { - videoId = this.getResourceId() - } + this.playerHTML.setPlayerElement(playerElement) + this.playerHTML.addPlayerElementToDOM() - return this.loadVideoAndBuildPlayer(videoId) + return alreadyHadPlayer } - private handleError (err: Error, translations?: { [ id: string ]: string }) { - if (err.message.includes('from xs param')) { - this.player.dispose() - this.playerElement = null - this.displayError('This video is not available because the remote instance is not responding.', translations) - } + private async buildPlayerPlaylistUpnext () { + const translations = await this.translationsPromise + + this.player.upnext({ + timeout: 10000, // 10s + headText: peertubeTranslate('Up Next', translations), + cancelText: peertubeTranslate('Cancel', translations), + suspendedText: peertubeTranslate('Autoplay is suspended', translations), + getTitle: () => this.playlistTracker.nextVideoTitle(), + next: () => this.playNextPlaylistVideo(), + condition: () => !!this.playlistTracker.getNextPlaylistElement(), + suspended: () => false + }) } - private buildDock (videoInfo: VideoDetails) { - if (!this.controls) return + private buildPlayerDock (videoInfo: VideoDetails) { + if (!this.playerManagerOptions.hasControls()) return // On webtorrent fallback, player may have been disposed if (!this.player.player_) return - const title = this.title ? videoInfo.name : undefined - const description = this.warningTitle && this.p2pEnabled + const title = this.playerManagerOptions.hasTitle() + ? videoInfo.name + : undefined + + const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled() ? '' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '' : undefined + if (!title && !description) return + const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50) const avatar = availableAvatars.length !== 0 ? availableAvatars[0] : undefined - if (title || description) { - this.player.peertubeDock({ - title, - description, - avatarUrl: title && avatar - ? avatar.path - : undefined - }) - } + this.player.peertubeDock({ + title, + description, + avatarUrl: title && avatar + ? avatar.path + : undefined + }) } private buildCSS () { const body = document.getElementById('custom-css') - if (this.bigPlayBackgroundColor) { - body.style.setProperty('--embedBigPlayBackgroundColor', this.bigPlayBackgroundColor) + if (this.playerManagerOptions.hasBigPlayBackgroundColor()) { + body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor()) } - if (this.foregroundColor) { - body.style.setProperty('--embedForegroundColor', this.foregroundColor) + if (this.playerManagerOptions.hasForegroundColor()) { + body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor()) } } - private async buildCaptions (serverTranslations: any, captionsResponse: Response): Promise { - if (captionsResponse.ok) { - const { data } = await captionsResponse.json() - - return data.map((c: VideoCaption) => ({ - label: peertubeTranslate(c.language.label, serverTranslations), - language: c.language.id, - src: window.location.origin + c.captionPath - })) - } - - return [] - } - - private buildPlaceholder (video: VideoDetails) { - const placeholder = this.getPlaceholderElement() - - const url = window.location.origin + video.previewPath - placeholder.style.backgroundImage = `url("${url}")` - placeholder.style.display = 'block' - } - - private removePlaceholder () { - const placeholder = this.getPlaceholderElement() - placeholder.style.display = 'none' - } - - private getPlaceholderElement () { - return document.getElementById('placeholder-preview') - } - - private getHeaderTokenValue () { - return `${this.userTokens.tokenType} ${this.userTokens.accessToken}` - } - - private setHeadersFromTokens () { - this.headers.set('Authorization', this.getHeaderTokenValue()) - } - - private removeTokensFromHeaders () { - this.headers.delete('Authorization') - } + // --------------------------------------------------------------------------- private getResourceId () { const urlParts = window.location.pathname.split('/') @@ -794,69 +322,6 @@ export class PeerTubeEmbed { private isPlaylistEmbed () { return window.location.pathname.split('/')[1] === 'video-playlists' } - - private loadPlugins (translations?: { [ id: string ]: string }) { - this.pluginsManager = new PluginsManager({ - peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers(pluginInfo, translations) - }) - - this.pluginsManager.loadPluginsList(this.config) - - return this.pluginsManager.ensurePluginsAreLoaded('embed') - } - - private buildPeerTubeHelpers (pluginInfo: PluginInfo, translations?: { [ id: string ]: string }): RegisterClientHelpers { - const unimplemented = () => { - throw new Error('This helper is not implemented in embed.') - } - - return { - getBaseStaticRoute: unimplemented, - getBaseRouterRoute: unimplemented, - getBasePluginClientPath: unimplemented, - - getSettings: () => { - const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings' - - return this.refreshFetch(url, { headers: this.headers }) - .then(res => res.json()) - .then((obj: PublicServerSetting) => obj.publicSettings) - }, - - isLoggedIn: () => !!this.userTokens, - getAuthHeader: () => { - if (!this.userTokens) return undefined - - return { Authorization: this.getHeaderTokenValue() } - }, - - notifier: { - info: unimplemented, - error: unimplemented, - success: unimplemented - }, - - showModal: unimplemented, - - getServerConfig: unimplemented, - - markdownRenderer: { - textMarkdownToHTML: unimplemented, - enhancedMarkdownToHTML: unimplemented - }, - - translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations)) - } - } - - private isP2PEnabled (video: Video) { - const userP2PEnabled = getBoolOrDefault( - peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), - this.config.defaults.p2p.embed.enabled - ) - - return isP2PEnabled(video, this.config, userP2PEnabled) - } } PeerTubeEmbed.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 @@ +import { HttpStatusCode, OAuth2ErrorCode, UserRefreshToken } from '../../../../../shared/models' +import { objectToUrlEncoded, UserTokens } from '../../../root-helpers' +import { peertubeLocalStorage } from '../../../root-helpers/peertube-web-storage' + +export class AuthHTTP { + private readonly LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { + CLIENT_ID: 'client_id', + CLIENT_SECRET: 'client_secret' + } + + private userTokens: UserTokens + + private headers = new Headers() + + constructor () { + this.userTokens = UserTokens.getUserTokens(peertubeLocalStorage) + + if (this.userTokens) this.setHeadersFromTokens() + } + + fetch (url: string, { optionalAuth }: { optionalAuth: boolean }) { + const refreshFetchOptions = optionalAuth + ? { headers: this.headers } + : {} + + return this.refreshFetch(url.toString(), refreshFetchOptions) + } + + getHeaderTokenValue () { + return `${this.userTokens.tokenType} ${this.userTokens.accessToken}` + } + + isLoggedIn () { + return !!this.userTokens + } + + private refreshFetch (url: string, options?: RequestInit) { + return fetch(url, options) + .then((res: Response) => { + if (res.status !== HttpStatusCode.UNAUTHORIZED_401) return res + + const refreshingTokenPromise = new Promise((resolve, reject) => { + const clientId: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_ID) + const clientSecret: string = peertubeLocalStorage.getItem(this.LOCAL_STORAGE_OAUTH_CLIENT_KEYS.CLIENT_SECRET) + + const headers = new Headers() + headers.set('Content-Type', 'application/x-www-form-urlencoded') + + const data = { + refresh_token: this.userTokens.refreshToken, + client_id: clientId, + client_secret: clientSecret, + response_type: 'code', + grant_type: 'refresh_token' + } + + fetch('/api/v1/users/token', { + headers, + method: 'POST', + body: objectToUrlEncoded(data) + }).then(res => { + if (res.status === HttpStatusCode.UNAUTHORIZED_401) return undefined + + return res.json() + }).then((obj: UserRefreshToken & { code?: OAuth2ErrorCode }) => { + if (!obj || obj.code === OAuth2ErrorCode.INVALID_GRANT) { + UserTokens.flushLocalStorage(peertubeLocalStorage) + this.removeTokensFromHeaders() + + return resolve() + } + + this.userTokens.accessToken = obj.access_token + this.userTokens.refreshToken = obj.refresh_token + UserTokens.saveToLocalStorage(peertubeLocalStorage, this.userTokens) + + this.setHeadersFromTokens() + + resolve() + }).catch((refreshTokenError: any) => { + reject(refreshTokenError) + }) + }) + + return refreshingTokenPromise + .catch(() => { + UserTokens.flushLocalStorage(peertubeLocalStorage) + + this.removeTokensFromHeaders() + }).then(() => fetch(url, { + ...options, + + headers: this.headers + })) + }) + } + + private setHeadersFromTokens () { + this.headers.set('Authorization', this.getHeaderTokenValue()) + } + + private removeTokensFromHeaders () { + this.headers.delete('Authorization') + } +} 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 @@ +export * from './auth-http' +export * from './peertube-plugin' +export * from './player-html' +export * from './player-manager-options' +export * from './playlist-fetcher' +export * from './playlist-tracker' +export * from './translations' +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 @@ +import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' +import { HTMLServerConfig, PublicServerSetting } from '../../../../../shared/models' +import { PluginInfo, PluginsManager } from '../../../root-helpers' +import { RegisterClientHelpers } from '../../../types' +import { AuthHTTP } from './auth-http' +import { Translations } from './translations' + +export class PeerTubePlugin { + + private pluginsManager: PluginsManager + + constructor (private readonly http: AuthHTTP) { + + } + + loadPlugins (config: HTMLServerConfig, translations?: Translations) { + this.pluginsManager = new PluginsManager({ + peertubeHelpersFactory: pluginInfo => this.buildPeerTubeHelpers({ + pluginInfo, + translations + }) + }) + + this.pluginsManager.loadPluginsList(config) + + return this.pluginsManager.ensurePluginsAreLoaded('embed') + } + + getPluginsManager () { + return this.pluginsManager + } + + private buildPeerTubeHelpers (options: { + pluginInfo: PluginInfo + translations?: Translations + }): RegisterClientHelpers { + const { pluginInfo, translations } = options + + const unimplemented = () => { + throw new Error('This helper is not implemented in embed.') + } + + return { + getBaseStaticRoute: unimplemented, + getBaseRouterRoute: unimplemented, + getBasePluginClientPath: unimplemented, + + getSettings: () => { + const url = this.getPluginUrl() + '/' + pluginInfo.plugin.npmName + '/public-settings' + + return this.http.fetch(url, { optionalAuth: true }) + .then(res => res.json()) + .then((obj: PublicServerSetting) => obj.publicSettings) + }, + + isLoggedIn: () => this.http.isLoggedIn(), + getAuthHeader: () => { + if (!this.http.isLoggedIn()) return undefined + + return { Authorization: this.http.getHeaderTokenValue() } + }, + + notifier: { + info: unimplemented, + error: unimplemented, + success: unimplemented + }, + + showModal: unimplemented, + + getServerConfig: unimplemented, + + markdownRenderer: { + textMarkdownToHTML: unimplemented, + enhancedMarkdownToHTML: unimplemented + }, + + translate: (value: string) => Promise.resolve(peertubeTranslate(value, translations)) + } + } + + private getPluginUrl () { + return window.location.origin + '/api/v1/plugins' + } +} 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 @@ +import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' +import { VideoDetails } from '../../../../../shared/models' +import { Translations } from './translations' + +export class PlayerHTML { + private readonly wrapperElement: HTMLElement + + private playerElement: HTMLVideoElement + + constructor (private readonly videoWrapperId: string) { + this.wrapperElement = document.getElementById(this.videoWrapperId) + } + + getPlayerElement () { + return this.playerElement + } + + setPlayerElement (playerElement: HTMLVideoElement) { + this.playerElement = playerElement + } + + removePlayerElement () { + this.playerElement = null + } + + addPlayerElementToDOM () { + this.wrapperElement.appendChild(this.playerElement) + } + + displayError (text: string, translations: Translations) { + console.error(text) + + // Remove video element + if (this.playerElement) { + this.removeElement(this.playerElement) + this.playerElement = undefined + } + + const translatedText = peertubeTranslate(text, translations) + const translatedSorry = peertubeTranslate('Sorry', translations) + + document.title = translatedSorry + ' - ' + translatedText + + const errorBlock = document.getElementById('error-block') + errorBlock.style.display = 'flex' + + const errorTitle = document.getElementById('error-title') + errorTitle.innerHTML = peertubeTranslate('Sorry', translations) + + const errorText = document.getElementById('error-content') + errorText.innerHTML = translatedText + + this.wrapperElement.style.display = 'none' + } + + buildPlaceholder (video: VideoDetails) { + const placeholder = this.getPlaceholderElement() + + const url = window.location.origin + video.previewPath + placeholder.style.backgroundImage = `url("${url}")` + placeholder.style.display = 'block' + } + + removePlaceholder () { + const placeholder = this.getPlaceholderElement() + placeholder.style.display = 'none' + } + + private getPlaceholderElement () { + return document.getElementById('placeholder-preview') + } + + private removeElement (element: HTMLElement) { + element.parentElement.removeChild(element) + } +} 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 @@ +import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' +import { + HTMLServerConfig, + LiveVideo, + Video, + VideoCaption, + VideoDetails, + VideoPlaylistElement, + VideoStreamingPlaylistType +} from '../../../../../shared/models' +import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' +import { + getBoolOrDefault, + getParamString, + getParamToggle, + isP2PEnabled, + peertubeLocalStorage, + UserLocalStorageKeys +} from '../../../root-helpers' +import { PeerTubePlugin } from './peertube-plugin' +import { PlayerHTML } from './player-html' +import { PlaylistTracker } from './playlist-tracker' +import { Translations } from './translations' +import { VideoFetcher } from './video-fetcher' + +export class PlayerManagerOptions { + private autoplay: boolean + + private controls: boolean + private controlBar: boolean + + private muted: boolean + private loop: boolean + private subtitle: string + private enableApi = false + private startTime: number | string = 0 + private stopTime: number | string + + private title: boolean + private warningTitle: boolean + private peertubeLink: boolean + private p2pEnabled: boolean + private bigPlayBackgroundColor: string + private foregroundColor: string + + private mode: PlayerMode + private scope = 'peertube' + + constructor ( + private readonly playerHTML: PlayerHTML, + private readonly videoFetcher: VideoFetcher, + private readonly peertubePlugin: PeerTubePlugin + ) {} + + hasAPIEnabled () { + return this.enableApi + } + + hasAutoplay () { + return this.autoplay + } + + hasControls () { + return this.controls + } + + hasTitle () { + return this.title + } + + hasWarningTitle () { + return this.warningTitle + } + + hasP2PEnabled () { + return !!this.p2pEnabled + } + + hasBigPlayBackgroundColor () { + return !!this.bigPlayBackgroundColor + } + + getBigPlayBackgroundColor () { + return this.bigPlayBackgroundColor + } + + hasForegroundColor () { + return !!this.foregroundColor + } + + getForegroundColor () { + return this.foregroundColor + } + + getMode () { + return this.mode + } + + getScope () { + return this.scope + } + + // --------------------------------------------------------------------------- + + loadParams (config: HTMLServerConfig, video: VideoDetails) { + try { + const params = new URL(window.location.toString()).searchParams + + this.autoplay = getParamToggle(params, 'autoplay', false) + + this.controls = getParamToggle(params, 'controls', true) + this.controlBar = getParamToggle(params, 'controlBar', true) + + this.muted = getParamToggle(params, 'muted', undefined) + this.loop = getParamToggle(params, 'loop', false) + this.title = getParamToggle(params, 'title', true) + this.enableApi = getParamToggle(params, 'api', this.enableApi) + this.warningTitle = getParamToggle(params, 'warningTitle', true) + this.peertubeLink = getParamToggle(params, 'peertubeLink', true) + this.p2pEnabled = getParamToggle(params, 'p2p', this.isP2PEnabled(config, video)) + + this.scope = getParamString(params, 'scope', this.scope) + this.subtitle = getParamString(params, 'subtitle') + this.startTime = getParamString(params, 'start') + this.stopTime = getParamString(params, 'stop') + + this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') + this.foregroundColor = getParamString(params, 'foregroundColor') + + const modeParam = getParamString(params, 'mode') + + if (modeParam) { + if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' + else this.mode = 'webtorrent' + } else { + if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' + else this.mode = 'webtorrent' + } + } catch (err) { + console.error('Cannot get params from URL.', err) + } + } + + // --------------------------------------------------------------------------- + + async getPlayerOptions (options: { + video: VideoDetails + captionsResponse: Response + live?: LiveVideo + + alreadyHadPlayer: boolean + + translations: Translations + + playlistTracker?: PlaylistTracker + playNextPlaylistVideo?: () => any + playPreviousPlaylistVideo?: () => any + onVideoUpdate?: (uuid: string) => any + }) { + const { + video, + captionsResponse, + alreadyHadPlayer, + translations, + playlistTracker, + live + } = options + + const videoCaptions = await this.buildCaptions(captionsResponse, translations) + + const playerOptions: PeertubePlayerManagerOptions = { + common: { + // Autoplay in playlist mode + autoplay: alreadyHadPlayer ? true : this.autoplay, + + controls: this.controls, + controlBar: this.controlBar, + + muted: this.muted, + loop: this.loop, + + p2pEnabled: this.p2pEnabled, + + captions: videoCaptions.length !== 0, + subtitle: this.subtitle, + + startTime: playlistTracker + ? playlistTracker.getCurrentElement().startTimestamp + : this.startTime, + stopTime: playlistTracker + ? playlistTracker.getCurrentElement().stopTimestamp + : this.stopTime, + + videoCaptions, + inactivityTimeout: 2500, + videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), + + videoShortUUID: video.shortUUID, + videoUUID: video.uuid, + + playerElement: this.playerHTML.getPlayerElement(), + onPlayerElementChange: (element: HTMLVideoElement) => { + this.playerHTML.setPlayerElement(element) + }, + + videoDuration: video.duration, + enableHotkeys: true, + peertubeLink: this.peertubeLink, + poster: window.location.origin + video.previewPath, + theaterButton: false, + + serverUrl: window.location.origin, + language: navigator.language, + embedUrl: window.location.origin + video.embedPath, + embedTitle: video.name, + + errorNotifier: () => { + // Empty, we don't have a notifier in the embed + }, + + ...this.buildLiveOptions(video, live), + + ...this.buildPlaylistOptions(options) + }, + + webtorrent: { + videoFiles: video.files + }, + + ...this.buildP2PMediaLoaderOptions(video), + + pluginsManager: this.peertubePlugin.getPluginsManager() + } + + return playerOptions + } + + private buildLiveOptions (video: VideoDetails, live: LiveVideo) { + if (!video.isLive) return { isLive: false } + + return { + isLive: true, + liveOptions: { + latencyMode: live.latencyMode + } + } + } + + private buildPlaylistOptions (options: { + playlistTracker?: PlaylistTracker + playNextPlaylistVideo?: () => any + playPreviousPlaylistVideo?: () => any + onVideoUpdate?: (uuid: string) => any + }) { + const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options + + if (!playlistTracker) return {} + + return { + playlist: { + elements: playlistTracker.getPlaylistElements(), + playlist: playlistTracker.getPlaylist(), + + getCurrentPosition: () => playlistTracker.getCurrentPosition(), + + onItemClicked: (videoPlaylistElement: VideoPlaylistElement) => { + playlistTracker.setCurrentElement(videoPlaylistElement) + + onVideoUpdate(videoPlaylistElement.video.uuid) + } + }, + + nextVideo: () => playNextPlaylistVideo(), + hasNextVideo: () => playlistTracker.hasNextPlaylistElement(), + + previousVideo: () => playPreviousPlaylistVideo(), + hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement() + } + } + + private buildP2PMediaLoaderOptions (video: VideoDetails) { + if (this.mode !== 'p2p-media-loader') return {} + + const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) + + return { + p2pMediaLoader: { + playlistUrl: hlsPlaylist.playlistUrl, + segmentsSha256Url: hlsPlaylist.segmentsSha256Url, + redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), + trackerAnnounce: video.trackerUrls, + videoFiles: hlsPlaylist.files + } as P2PMediaLoaderOptions + } + } + + // --------------------------------------------------------------------------- + + private async buildCaptions (captionsResponse: Response, translations: Translations): Promise { + if (captionsResponse.ok) { + const { data } = await captionsResponse.json() + + return data.map((c: VideoCaption) => ({ + label: peertubeTranslate(c.language.label, translations), + language: c.language.id, + src: window.location.origin + c.captionPath + })) + } + + return [] + } + + // --------------------------------------------------------------------------- + + private isP2PEnabled (config: HTMLServerConfig, video: Video) { + const userP2PEnabled = getBoolOrDefault( + peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), + config.defaults.p2p.embed.enabled + ) + + return isP2PEnabled(video, config, userP2PEnabled) + } +} 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 @@ +import { HttpStatusCode, ResultList, VideoPlaylistElement } from '../../../../../shared/models' +import { AuthHTTP } from './auth-http' + +export class PlaylistFetcher { + + constructor (private readonly http: AuthHTTP) { + + } + + async loadPlaylist (playlistId: string) { + const playlistPromise = this.loadPlaylistInfo(playlistId) + const playlistElementsPromise = this.loadPlaylistElements(playlistId) + + let playlistResponse: Response + let isResponseOk: boolean + + try { + playlistResponse = await playlistPromise + isResponseOk = playlistResponse.status === HttpStatusCode.OK_200 + } catch (err) { + console.error(err) + isResponseOk = false + } + + if (!isResponseOk) { + if (playlistResponse?.status === HttpStatusCode.NOT_FOUND_404) { + throw new Error('This playlist does not exist.') + } + + throw new Error('We cannot fetch the playlist. Please try again later.') + } + + return { playlistResponse, videosResponse: await playlistElementsPromise } + } + + async loadAllPlaylistVideos (playlistId: string, baseResult: ResultList) { + let elements = baseResult.data + let total = baseResult.total + let i = 0 + + while (total > elements.length && i < 10) { + const result = await this.loadPlaylistElements(playlistId, elements.length) + + const json = await result.json() + total = json.total + + elements = elements.concat(json.data) + i++ + } + + if (i === 10) { + console.error('Cannot fetch all playlists elements, there are too many!') + } + + return elements + } + + private loadPlaylistInfo (playlistId: string): Promise { + return this.http.fetch(this.getPlaylistUrl(playlistId), { optionalAuth: true }) + } + + private loadPlaylistElements (playlistId: string, start = 0): Promise { + const url = new URL(this.getPlaylistUrl(playlistId) + '/videos') + url.search = new URLSearchParams({ start: '' + start, count: '100' }).toString() + + return this.http.fetch(url.toString(), { optionalAuth: true }) + } + + private getPlaylistUrl (id: string) { + return window.location.origin + '/api/v1/video-playlists/' + id + } +} 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 @@ +import { VideoPlaylist, VideoPlaylistElement } from '../../../../../shared/models' + +export class PlaylistTracker { + private currentPlaylistElement: VideoPlaylistElement + + constructor ( + private readonly playlist: VideoPlaylist, + private readonly playlistElements: VideoPlaylistElement[] + ) { + + } + + getPlaylist () { + return this.playlist + } + + getPlaylistElements () { + return this.playlistElements + } + + hasNextPlaylistElement (position?: number) { + return !!this.getNextPlaylistElement(position) + } + + getNextPlaylistElement (position?: number): VideoPlaylistElement { + if (!position) position = this.currentPlaylistElement.position + 1 + + if (position > this.playlist.videosLength) { + return undefined + } + + const next = this.playlistElements.find(e => e.position === position) + + if (!next || !next.video) { + return this.getNextPlaylistElement(position + 1) + } + + return next + } + + hasPreviousPlaylistElement (position?: number) { + return !!this.getPreviousPlaylistElement(position) + } + + getPreviousPlaylistElement (position?: number): VideoPlaylistElement { + if (!position) position = this.currentPlaylistElement.position - 1 + + if (position < 1) { + return undefined + } + + const prev = this.playlistElements.find(e => e.position === position) + + if (!prev || !prev.video) { + return this.getNextPlaylistElement(position - 1) + } + + return prev + } + + nextVideoTitle () { + const next = this.getNextPlaylistElement() + if (!next) return '' + + return next.video.name + } + + setPosition (position: number) { + this.currentPlaylistElement = this.playlistElements.find(e => e.position === position) + if (!this.currentPlaylistElement || !this.currentPlaylistElement.video) { + console.error('Current playlist element is not valid.', this.currentPlaylistElement) + this.currentPlaylistElement = this.getNextPlaylistElement() + } + + if (!this.currentPlaylistElement) { + throw new Error('This playlist does not have any valid element') + } + } + + setCurrentElement (playlistElement: VideoPlaylistElement) { + this.currentPlaylistElement = playlistElement + } + + getCurrentElement () { + return this.currentPlaylistElement + } + + getCurrentPosition () { + if (!this.currentPlaylistElement) return -1 + + return this.currentPlaylistElement.position + } +} 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 @@ +type Translations = { [ id: string ]: string } + +export { + Translations +} 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 @@ +import { HttpStatusCode, LiveVideo, VideoDetails } from '../../../../../shared/models' +import { AuthHTTP } from './auth-http' + +export class VideoFetcher { + + constructor (private readonly http: AuthHTTP) { + + } + + async loadVideo (videoId: string) { + const videoPromise = this.loadVideoInfo(videoId) + + let videoResponse: Response + let isResponseOk: boolean + + try { + videoResponse = await videoPromise + isResponseOk = videoResponse.status === HttpStatusCode.OK_200 + } catch (err) { + console.error(err) + + isResponseOk = false + } + + if (!isResponseOk) { + if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { + throw new Error('This video does not exist.') + } + + throw new Error('We cannot fetch the video. Please try again later.') + } + + const captionsPromise = this.loadVideoCaptions(videoId) + + return { captionsPromise, videoResponse } + } + + loadVideoWithLive (video: VideoDetails) { + return this.http.fetch(this.getLiveUrl(video.uuid), { optionalAuth: true }) + .then(res => res.json()) + .then((live: LiveVideo) => ({ video, live })) + } + + getVideoViewsUrl (videoUUID: string) { + return this.getVideoUrl(videoUUID) + '/views' + } + + private loadVideoInfo (videoId: string): Promise { + return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) + } + + private loadVideoCaptions (videoId: string): Promise { + return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) + } + + private getVideoUrl (id: string) { + return window.location.origin + '/api/v1/videos/' + id + } + + private getLiveUrl (videoId: string) { + return window.location.origin + '/api/v1/videos/live/' + videoId + } +} -- cgit v1.2.3