Support plugin hooks in embed

This commit is contained in:
Chocobozzz 2020-08-20 11:46:25 +02:00 committed by Chocobozzz
parent a9f6802e7d
commit f95628636b
8 changed files with 217 additions and 114 deletions

View File

@ -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<boolean>(1),
'video-watch': new ReplaySubject<boolean>(1),
signup: new ReplaySubject<boolean>(1),
login: new ReplaySubject<boolean>(1)
login: new ReplaySubject<boolean>(1),
embed: new ReplaySubject<boolean>(1)
}
translationsObservable: Observable<PluginTranslation>
@ -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 <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
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)

View File

@ -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

View File

@ -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: ''
}

View File

@ -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<T> (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
})
}
}

View File

@ -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
}

View File

@ -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<string>()
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 <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
return runHook(this.peertubeHooks, hookName, result, params)
}
}
PeerTubeEmbed.main()

View File

@ -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

View File

@ -1 +1 @@
export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login'
export type PluginClientScope = 'common' | 'video-watch' | 'search' | 'signup' | 'login' | 'embed'