diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts index dc115c0e1..871613b89 100644 --- a/client/src/app/core/plugins/plugin.service.ts +++ b/client/src/app/core/plugins/plugin.service.ts @@ -7,38 +7,22 @@ import { Notifier } from '@app/core/notification' import { MarkdownService } from '@app/core/renderer' import { RestExtractor } from '@app/core/rest' import { ServerService } from '@app/core/server/server.service' -import { getDevLocale, importModule, isOnDevLocale } from '@app/helpers' +import { getDevLocale, isOnDevLocale } from '@app/helpers' import { CustomModalComponent } from '@app/modal/custom-modal.component' +import { Hooks, loadPlugin, PluginInfo, runHook } from '@root-helpers/plugins' import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n' -import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' import { ClientHook, ClientHookName, - clientHookObject, - ClientScript, PluginClientScope, PluginTranslation, PluginType, PublicServerSetting, - RegisterClientHookOptions, ServerConfigPlugin } from '@shared/models' import { environment } from '../../../environments/environment' -import { ClientScript as ClientScriptModule } from '../../../types/client-script.model' import { RegisterClientHelpers } from '../../../types/register-client-option.model' -interface HookStructValue extends RegisterClientHookOptions { - plugin: ServerConfigPlugin - clientScript: ClientScript -} - -type PluginInfo = { - plugin: ServerConfigPlugin - clientScript: ClientScript - pluginType: PluginType - isTheme: boolean -} - @Injectable() export class PluginService implements ClientHook { private static BASE_PLUGIN_API_URL = environment.apiUrl + '/api/v1/plugins' @@ -51,7 +35,8 @@ export class PluginService implements ClientHook { search: new ReplaySubject(1), 'video-watch': new ReplaySubject(1), signup: new ReplaySubject(1), - login: new ReplaySubject(1) + login: new ReplaySubject(1), + embed: new ReplaySubject(1) } translationsObservable: Observable @@ -64,7 +49,7 @@ export class PluginService implements ClientHook { private loadedScopes: PluginClientScope[] = [] private loadingScopes: { [id in PluginClientScope]?: boolean } = {} - private hooks: { [ name: string ]: HookStructValue[] } = {} + private hooks: Hooks = {} constructor ( private authService: AuthService, @@ -120,7 +105,7 @@ export class PluginService implements ClientHook { this.scopes[scope].push({ plugin, clientScript: { - script: environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`, + script: `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`, scopes: clientScript.scopes }, pluginType: isTheme ? PluginType.THEME : PluginType.PLUGIN, @@ -184,20 +169,8 @@ export class PluginService implements ClientHook { } runHook (hookName: ClientHookName, result?: T, params?: any): Promise { - return this.zone.runOutsideAngular(async () => { - if (!this.hooks[ hookName ]) return result - - const hookType = getHookType(hookName) - - for (const hook of this.hooks[ hookName ]) { - console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name) - - result = await internalRunHook(hook.handler, hookType, result, params, err => { - console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err) - }) - } - - return result + return this.zone.runOutsideAngular(() => { + return runHook(this.hooks, hookName, result, params) }) } @@ -216,34 +189,8 @@ export class PluginService implements ClientHook { } private loadPlugin (pluginInfo: PluginInfo) { - const { plugin, clientScript } = pluginInfo - - const registerHook = (options: RegisterClientHookOptions) => { - if (clientHookObject[options.target] !== true) { - console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name) - return - } - - if (!this.hooks[options.target]) this.hooks[options.target] = [] - - this.hooks[options.target].push({ - plugin, - clientScript, - target: options.target, - handler: options.handler, - priority: options.priority || 0 - }) - } - - const peertubeHelpers = this.buildPeerTubeHelpers(pluginInfo) - - console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name) - return this.zone.runOutsideAngular(() => { - return importModule(clientScript.script) - .then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers })) - .then(() => this.sortHooksByPriority()) - .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err)) + return loadPlugin(this.hooks, pluginInfo, pluginInfo => this.buildPeerTubeHelpers(pluginInfo)) }) } @@ -253,14 +200,6 @@ export class PluginService implements ClientHook { } } - private sortHooksByPriority () { - for (const hookName of Object.keys(this.hooks)) { - this.hooks[hookName].sort((a, b) => { - return b.priority - a.priority - }) - } - } - private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers { const { plugin } = pluginInfo const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType) diff --git a/client/src/app/helpers/utils.ts b/client/src/app/helpers/utils.ts index d05541ca9..d9007dd77 100644 --- a/client/src/app/helpers/utils.ts +++ b/client/src/app/helpers/utils.ts @@ -148,41 +148,6 @@ function scrollToTop () { window.scroll(0, 0) } -// Thanks: https://github.com/uupaa/dynamic-import-polyfill -function importModule (path: string) { - return new Promise((resolve, reject) => { - const vector = '$importModule$' + Math.random().toString(32).slice(2) - const script = document.createElement('script') - - const destructor = () => { - delete window[ vector ] - script.onerror = null - script.onload = null - script.remove() - URL.revokeObjectURL(script.src) - script.src = '' - } - - script.defer = true - script.type = 'module' - - script.onerror = () => { - reject(new Error(`Failed to import: ${path}`)) - destructor() - } - script.onload = () => { - resolve(window[ vector ]) - destructor() - } - const absURL = (environment.apiUrl || window.location.origin) + path - const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module - const blob = new Blob([ loader ], { type: 'text/javascript' }) - script.src = URL.createObjectURL(blob) - - document.head.appendChild(script) - }) -} - function isInViewport (el: HTMLElement) { const bounding = el.getBoundingClientRect() return ( @@ -216,7 +181,6 @@ export { getAbsoluteEmbedUrl, objectLineFeedToHtml, removeElementFromArray, - importModule, scrollToTop, isInViewport, isXPercentInViewport diff --git a/client/src/environments/environment.ts b/client/src/environments/environment.ts index 4816e3060..e00523976 100644 --- a/client/src/environments/environment.ts +++ b/client/src/environments/environment.ts @@ -9,8 +9,8 @@ import 'core-js/features/reflect' export const environment = { - production: false, + production: true, hmr: false, - apiUrl: 'http://localhost:9000', - embedUrl: 'http://localhost:9000' + apiUrl: '', + embedUrl: '' } diff --git a/client/src/root-helpers/plugins.ts b/client/src/root-helpers/plugins.ts new file mode 100644 index 000000000..011721761 --- /dev/null +++ b/client/src/root-helpers/plugins.ts @@ -0,0 +1,81 @@ +import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' +import { ClientHookName, ClientScript, RegisterClientHookOptions, ServerConfigPlugin, PluginType, clientHookObject } from '../../../shared/models' +import { RegisterClientHelpers } from 'src/types/register-client-option.model' +import { ClientScript as ClientScriptModule } from '../types/client-script.model' +import { importModule } from './utils' + +interface HookStructValue extends RegisterClientHookOptions { + plugin: ServerConfigPlugin + clientScript: ClientScript +} + +type Hooks = { [ name: string ]: HookStructValue[] } + +type PluginInfo = { + plugin: ServerConfigPlugin + clientScript: ClientScript + pluginType: PluginType + isTheme: boolean +} + +async function runHook (hooks: Hooks, hookName: ClientHookName, result?: T, params?: any) { + if (!hooks[hookName]) return result + + const hookType = getHookType(hookName) + + for (const hook of hooks[hookName]) { + console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name) + + result = await internalRunHook(hook.handler, hookType, result, params, err => { + console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err) + }) + } + + return result +} + +function loadPlugin (hooks: Hooks, pluginInfo: PluginInfo, peertubeHelpersFactory: (pluginInfo: PluginInfo) => RegisterClientHelpers) { + const { plugin, clientScript } = pluginInfo + + const registerHook = (options: RegisterClientHookOptions) => { + if (clientHookObject[options.target] !== true) { + console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name) + return + } + + if (!hooks[options.target]) hooks[options.target] = [] + + hooks[options.target].push({ + plugin, + clientScript, + target: options.target, + handler: options.handler, + priority: options.priority || 0 + }) + } + + const peertubeHelpers = peertubeHelpersFactory(pluginInfo) + + console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name) + + return importModule(clientScript.script) + .then((script: ClientScriptModule) => script.register({ registerHook, peertubeHelpers })) + .then(() => sortHooksByPriority(hooks)) + .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err)) +} + +export { + HookStructValue, + Hooks, + PluginInfo, + loadPlugin, + runHook +} + +function sortHooksByPriority (hooks: Hooks) { + for (const hookName of Object.keys(hooks)) { + hooks[hookName].sort((a, b) => { + return b.priority - a.priority + }) + } +} diff --git a/client/src/root-helpers/utils.ts b/client/src/root-helpers/utils.ts index acfb565a3..6df151ad9 100644 --- a/client/src/root-helpers/utils.ts +++ b/client/src/root-helpers/utils.ts @@ -1,3 +1,5 @@ +import { environment } from '../environments/environment' + function objectToUrlEncoded (obj: any) { const str: string[] = [] for (const key of Object.keys(obj)) { @@ -7,6 +9,42 @@ function objectToUrlEncoded (obj: any) { return str.join('&') } +// Thanks: https://github.com/uupaa/dynamic-import-polyfill +function importModule (path: string) { + return new Promise((resolve, reject) => { + const vector = '$importModule$' + Math.random().toString(32).slice(2) + const script = document.createElement('script') + + const destructor = () => { + delete window[ vector ] + script.onerror = null + script.onload = null + script.remove() + URL.revokeObjectURL(script.src) + script.src = '' + } + + script.defer = true + script.type = 'module' + + script.onerror = () => { + reject(new Error(`Failed to import: ${path}`)) + destructor() + } + script.onload = () => { + resolve(window[ vector ]) + destructor() + } + const absURL = (environment.apiUrl || window.location.origin) + path + const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module + const blob = new Blob([ loader ], { type: 'text/javascript' }) + script.src = URL.createObjectURL(blob) + + document.head.appendChild(script) + }) +} + export { + importModule, objectToUrlEncoded } diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index adba32a31..fe65794f7 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -1,7 +1,5 @@ import './embed.scss' import videojs from 'video.js' -import { objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' -import { Tokens } from '@root-helpers/users' import { peertubeTranslate } from '../../../../shared/core-utils/i18n' import { ResultList, @@ -11,12 +9,19 @@ import { VideoDetails, VideoPlaylist, VideoPlaylistElement, - VideoStreamingPlaylistType + VideoStreamingPlaylistType, + PluginType, + ClientHookName } from '../../../../shared/models' import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager' import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings' import { TranslationsManager } from '../../assets/player/translations-manager' +import { Hooks, loadPlugin, runHook } from '../../root-helpers/plugins' +import { Tokens } from '../../root-helpers/users' +import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage' +import { objectToUrlEncoded } from '../../root-helpers/utils' import { PeerTubeEmbedApi } from './embed-api' +import { RegisterClientHelpers } from '../../types/register-client-option.model' type Translations = { [ id: string ]: string } @@ -60,6 +65,9 @@ export class PeerTubeEmbed { private wrapperElement: HTMLElement + private peertubeHooks: Hooks = {} + private loadedScripts = new Set() + static async main () { const videoContainerId = 'video-wrapper' const embed = new PeerTubeEmbed(videoContainerId) @@ -473,6 +481,8 @@ export class PeerTubeEmbed { this.PeertubePlayerManagerModulePromise ]) + await this.ensurePluginsAreLoaded(config, serverTranslations) + const videoInfo: VideoDetails = videoInfoTmp const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager @@ -577,6 +587,8 @@ export class PeerTubeEmbed { this.playNextVideo() }) } + + this.runHook('action:embed.player.loaded', undefined, { player: this.player }) } private async initCore () { @@ -714,6 +726,69 @@ export class PeerTubeEmbed { private isPlaylistEmbed () { return window.location.pathname.split('/')[1] === 'video-playlists' } + + private async ensurePluginsAreLoaded (config: ServerConfig, translations?: { [ id: string ]: string }) { + if (config.plugin.registered.length === 0) return + + for (const plugin of config.plugin.registered) { + for (const key of Object.keys(plugin.clientScripts)) { + const clientScript = plugin.clientScripts[key] + + if (clientScript.scopes.includes('embed') === false) continue + + const script = `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}` + + if (this.loadedScripts.has(script)) continue + + const pluginInfo = { + plugin, + clientScript: { + script, + scopes: clientScript.scopes + }, + pluginType: PluginType.PLUGIN, + isTheme: false + } + + await loadPlugin(this.peertubeHooks, pluginInfo, _ => this.buildPeerTubeHelpers(translations)) + } + } + } + + private buildPeerTubeHelpers (translations?: { [ id: string ]: string }): RegisterClientHelpers { + function unimplemented (): any { + throw new Error('This helper is not implemented in embed.') + } + + return { + getBaseStaticRoute: unimplemented, + + getSettings: unimplemented, + + isLoggedIn: unimplemented, + + notifier: { + info: unimplemented, + error: unimplemented, + success: unimplemented + }, + + showModal: unimplemented, + + markdownRenderer: { + textMarkdownToHTML: unimplemented, + enhancedMarkdownToHTML: unimplemented + }, + + translate: (value: string) => { + return Promise.resolve(peertubeTranslate(value, translations)) + } + } + } + + private runHook (hookName: ClientHookName, result?: T, params?: any): Promise { + return runHook(this.peertubeHooks, hookName, result, params) + } } PeerTubeEmbed.main() diff --git a/shared/models/plugins/client-hook.model.ts b/shared/models/plugins/client-hook.model.ts index b53b8de99..193a3f646 100644 --- a/shared/models/plugins/client-hook.model.ts +++ b/shared/models/plugins/client-hook.model.ts @@ -80,7 +80,13 @@ export const clientActionHookObject = { 'action:router.navigation-end': true, // Fired when the registration page is being initialized - 'action:signup.register.init': true + 'action:signup.register.init': true, + + // ####### Embed hooks ####### + // In embed scope, peertube helpers are not available + + // Fired when the embed loaded the player + 'action:embed.player.loaded': true } export type ClientActionHookName = keyof typeof clientActionHookObject diff --git a/shared/models/plugins/plugin-client-scope.type.ts b/shared/models/plugins/plugin-client-scope.type.ts index d112434e8..a3c669fe7 100644 --- a/shared/models/plugins/plugin-client-scope.type.ts +++ b/shared/models/plugins/plugin-client-scope.type.ts @@ -1 +1 @@ -export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login' +export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login' | 'embed'