aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts6
-rw-r--r--client/src/app/core/plugins/plugin.service.ts184
-rw-r--r--client/src/app/core/theme/theme.service.ts1
-rw-r--r--client/src/assets/player/peertube-player-manager.ts26
-rw-r--r--client/src/root-helpers/index.ts1
-rw-r--r--client/src/root-helpers/plugins-manager.ts251
-rw-r--r--client/src/root-helpers/plugins.ts126
-rw-r--r--client/src/standalone/videos/embed.ts53
-rw-r--r--shared/models/plugins/client/client-hook.model.ts5
9 files changed, 336 insertions, 317 deletions
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 3fdbc0184..a444dc51f 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -12,6 +12,7 @@ import {
12 MetaService, 12 MetaService,
13 Notifier, 13 Notifier,
14 PeerTubeSocket, 14 PeerTubeSocket,
15 PluginService,
15 RestExtractor, 16 RestExtractor,
16 ScreenService, 17 ScreenService,
17 ServerService, 18 ServerService,
@@ -146,6 +147,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
146 private videoCaptionService: VideoCaptionService, 147 private videoCaptionService: VideoCaptionService,
147 private hotkeysService: HotkeysService, 148 private hotkeysService: HotkeysService,
148 private hooks: HooksService, 149 private hooks: HooksService,
150 private pluginService: PluginService,
149 private peertubeSocket: PeerTubeSocket, 151 private peertubeSocket: PeerTubeSocket,
150 private screenService: ScreenService, 152 private screenService: ScreenService,
151 private location: PlatformLocation, 153 private location: PlatformLocation,
@@ -859,7 +861,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
859 861
860 webtorrent: { 862 webtorrent: {
861 videoFiles: video.files 863 videoFiles: video.files
862 } 864 },
865
866 pluginsManager: this.pluginService.getPluginsManager()
863 } 867 }
864 868
865 // Only set this if we're in a playlist 869 // Only set this if we're in a playlist
diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts
index ccbfd3e4d..bfd5ba4cc 100644
--- a/client/src/app/core/plugins/plugin.service.ts
+++ b/client/src/app/core/plugins/plugin.service.ts
@@ -1,6 +1,5 @@
1import * as debug from 'debug' 1import { Observable, of } from 'rxjs'
2import { Observable, of, ReplaySubject } from 'rxjs' 2import { catchError, map, shareReplay } from 'rxjs/operators'
3import { catchError, first, map, shareReplay } from 'rxjs/operators'
4import { HttpClient } from '@angular/common/http' 3import { HttpClient } from '@angular/common/http'
5import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core' 4import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
6import { VideoEditType } from '@app/+videos/+video-edit/shared/video-edit.type' 5import { VideoEditType } from '@app/+videos/+video-edit/shared/video-edit.type'
@@ -11,7 +10,7 @@ import { RestExtractor } from '@app/core/rest'
11import { ServerService } from '@app/core/server/server.service' 10import { ServerService } from '@app/core/server/server.service'
12import { getDevLocale, isOnDevLocale } from '@app/helpers' 11import { getDevLocale, isOnDevLocale } from '@app/helpers'
13import { CustomModalComponent } from '@app/modal/custom-modal.component' 12import { CustomModalComponent } from '@app/modal/custom-modal.component'
14import { FormFields, Hooks, loadPlugin, PluginInfo, runHook } from '@root-helpers/plugins' 13import { PluginInfo, PluginsManager } from '@root-helpers/plugins-manager'
15import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n' 14import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n'
16import { 15import {
17 ClientHook, 16 ClientHook,
@@ -20,49 +19,39 @@ import {
20 PluginTranslation, 19 PluginTranslation,
21 PluginType, 20 PluginType,
22 PublicServerSetting, 21 PublicServerSetting,
22 RegisterClientFormFieldOptions,
23 RegisterClientSettingsScript, 23 RegisterClientSettingsScript,
24 RegisterClientVideoFieldOptions,
24 ServerConfigPlugin 25 ServerConfigPlugin
25} from '@shared/models' 26} from '@shared/models'
26import { environment } from '../../../environments/environment' 27import { environment } from '../../../environments/environment'
27import { RegisterClientHelpers } from '../../../types/register-client-option.model' 28import { RegisterClientHelpers } from '../../../types/register-client-option.model'
28 29
29const logger = debug('peertube:plugins') 30type FormFields = {
31 video: {
32 commonOptions: RegisterClientFormFieldOptions
33 videoFormOptions: RegisterClientVideoFieldOptions
34 }[]
35}
30 36
31@Injectable() 37@Injectable()
32export class PluginService implements ClientHook { 38export class PluginService implements ClientHook {
33 private static BASE_PLUGIN_API_URL = environment.apiUrl + '/api/v1/plugins' 39 private static BASE_PLUGIN_API_URL = environment.apiUrl + '/api/v1/plugins'
34 private static BASE_PLUGIN_URL = environment.apiUrl + '/plugins' 40 private static BASE_PLUGIN_URL = environment.apiUrl + '/plugins'
35 41
36 pluginsLoaded: { [ scope in PluginClientScope ]: ReplaySubject<boolean> } = {
37 common: new ReplaySubject<boolean>(1),
38 'admin-plugin': new ReplaySubject<boolean>(1),
39 search: new ReplaySubject<boolean>(1),
40 'video-watch': new ReplaySubject<boolean>(1),
41 signup: new ReplaySubject<boolean>(1),
42 login: new ReplaySubject<boolean>(1),
43 'video-edit': new ReplaySubject<boolean>(1),
44 embed: new ReplaySubject<boolean>(1)
45 }
46
47 translationsObservable: Observable<PluginTranslation> 42 translationsObservable: Observable<PluginTranslation>
48 43
49 customModal: CustomModalComponent 44 customModal: CustomModalComponent
50 45
51 private plugins: ServerConfigPlugin[] = []
52 private helpers: { [ npmName: string ]: RegisterClientHelpers } = {} 46 private helpers: { [ npmName: string ]: RegisterClientHelpers } = {}
53 47
54 private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
55
56 private loadedScripts: { [ script: string ]: boolean } = {}
57 private loadedScopes: PluginClientScope[] = []
58 private loadingScopes: { [id in PluginClientScope]?: boolean } = {}
59
60 private hooks: Hooks = {}
61 private formFields: FormFields = { 48 private formFields: FormFields = {
62 video: [] 49 video: []
63 } 50 }
64 private settingsScripts: { [ npmName: string ]: RegisterClientSettingsScript } = {} 51 private settingsScripts: { [ npmName: string ]: RegisterClientSettingsScript } = {}
65 52
53 private pluginsManager: PluginsManager
54
66 constructor ( 55 constructor (
67 private authService: AuthService, 56 private authService: AuthService,
68 private notifier: Notifier, 57 private notifier: Notifier,
@@ -74,111 +63,48 @@ export class PluginService implements ClientHook {
74 @Inject(LOCALE_ID) private localeId: string 63 @Inject(LOCALE_ID) private localeId: string
75 ) { 64 ) {
76 this.loadTranslations() 65 this.loadTranslations()
66
67 this.pluginsManager = new PluginsManager({
68 peertubeHelpersFactory: this.buildPeerTubeHelpers.bind(this),
69 onFormFields: this.onFormFields.bind(this),
70 onSettingsScripts: this.onSettingsScripts.bind(this)
71 })
77 } 72 }
78 73
79 initializePlugins () { 74 initializePlugins () {
80 const config = this.server.getHTMLConfig() 75 this.pluginsManager.loadPluginsList(this.server.getHTMLConfig())
81 this.plugins = config.plugin.registered
82
83 this.buildScopeStruct()
84 76
85 this.ensurePluginsAreLoaded('common') 77 this.pluginsManager.ensurePluginsAreLoaded('common')
86 } 78 }
87 79
88 initializeCustomModal (customModal: CustomModalComponent) { 80 initializeCustomModal (customModal: CustomModalComponent) {
89 this.customModal = customModal 81 this.customModal = customModal
90 } 82 }
91 83
92 ensurePluginsAreLoaded (scope: PluginClientScope) { 84 runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
93 this.loadPluginsByScope(scope) 85 return this.zone.runOutsideAngular(() => {
94 86 return this.pluginsManager.runHook(hookName, result, params)
95 return this.pluginsLoaded[scope].asObservable() 87 })
96 .pipe(first(), shareReplay())
97 .toPromise()
98 } 88 }
99 89
100 addPlugin (plugin: ServerConfigPlugin, isTheme = false) { 90 ensurePluginsAreLoaded (scope: PluginClientScope) {
101 const pathPrefix = this.getPluginPathPrefix(isTheme) 91 return this.pluginsManager.ensurePluginsAreLoaded(scope)
102
103 for (const key of Object.keys(plugin.clientScripts)) {
104 const clientScript = plugin.clientScripts[key]
105
106 for (const scope of clientScript.scopes) {
107 if (!this.scopes[scope]) this.scopes[scope] = []
108
109 this.scopes[scope].push({
110 plugin,
111 clientScript: {
112 script: `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
113 scopes: clientScript.scopes
114 },
115 pluginType: isTheme ? PluginType.THEME : PluginType.PLUGIN,
116 isTheme
117 })
118
119 this.loadedScripts[clientScript.script] = false
120 }
121 }
122 } 92 }
123 93
124 removePlugin (plugin: ServerConfigPlugin) { 94 reloadLoadedScopes () {
125 for (const key of Object.keys(this.scopes)) { 95 return this.pluginsManager.reloadLoadedScopes()
126 this.scopes[key] = this.scopes[key].filter(o => o.plugin.name !== plugin.name)
127 }
128 } 96 }
129 97
130 async reloadLoadedScopes () { 98 getPluginsManager () {
131 for (const scope of this.loadedScopes) { 99 return this.pluginsManager
132 await this.loadPluginsByScope(scope, true)
133 }
134 } 100 }
135 101
136 async loadPluginsByScope (scope: PluginClientScope, isReload = false) { 102 addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
137 if (this.loadingScopes[scope]) return 103 return this.pluginsManager.addPlugin(plugin, isTheme)
138 if (!isReload && this.loadedScopes.includes(scope)) return
139
140 this.loadingScopes[scope] = true
141
142 logger('Loading scope %s', scope)
143
144 try {
145 if (!isReload) this.loadedScopes.push(scope)
146
147 const toLoad = this.scopes[ scope ]
148 if (!Array.isArray(toLoad)) {
149 this.loadingScopes[scope] = false
150 this.pluginsLoaded[scope].next(true)
151
152 logger('Nothing to load for scope %s', scope)
153 return
154 }
155
156 const promises: Promise<any>[] = []
157 for (const pluginInfo of toLoad) {
158 const clientScript = pluginInfo.clientScript
159
160 if (this.loadedScripts[ clientScript.script ]) continue
161
162 promises.push(this.loadPlugin(pluginInfo))
163
164 this.loadedScripts[ clientScript.script ] = true
165 }
166
167 await Promise.all(promises)
168
169 this.pluginsLoaded[scope].next(true)
170 this.loadingScopes[scope] = false
171
172 logger('Scope %s loaded', scope)
173 } catch (err) {
174 console.error('Cannot load plugins by scope %s.', scope, err)
175 }
176 } 104 }
177 105
178 runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> { 106 removePlugin (plugin: ServerConfigPlugin) {
179 return this.zone.runOutsideAngular(() => { 107 return this.pluginsManager.removePlugin(plugin)
180 return runHook(this.hooks, hookName, result, params)
181 })
182 } 108 }
183 109
184 nameToNpmName (name: string, type: PluginType) { 110 nameToNpmName (name: string, type: PluginType) {
@@ -189,12 +115,6 @@ export class PluginService implements ClientHook {
189 return prefix + name 115 return prefix + name
190 } 116 }
191 117
192 pluginTypeFromNpmName (npmName: string) {
193 return npmName.startsWith('peertube-plugin-')
194 ? PluginType.PLUGIN
195 : PluginType.THEME
196 }
197
198 getRegisteredVideoFormFields (type: VideoEditType) { 118 getRegisteredVideoFormFields (type: VideoEditType) {
199 return this.formFields.video.filter(f => f.videoFormOptions.type === type) 119 return this.formFields.video.filter(f => f.videoFormOptions.type === type)
200 } 120 }
@@ -213,27 +133,17 @@ export class PluginService implements ClientHook {
213 return helpers.translate(toTranslate) 133 return helpers.translate(toTranslate)
214 } 134 }
215 135
216 private loadPlugin (pluginInfo: PluginInfo) { 136 private onFormFields (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) {
217 return this.zone.runOutsideAngular(() => { 137 this.formFields.video.push({
218 const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType) 138 commonOptions,
219 139 videoFormOptions
220 const helpers = this.buildPeerTubeHelpers(pluginInfo)
221 this.helpers[npmName] = helpers
222
223 return loadPlugin({
224 hooks: this.hooks,
225 formFields: this.formFields,
226 onSettingsScripts: options => this.settingsScripts[npmName] = options,
227 pluginInfo,
228 peertubeHelpersFactory: () => helpers
229 })
230 }) 140 })
231 } 141 }
232 142
233 private buildScopeStruct () { 143 private onSettingsScripts (pluginInfo: PluginInfo, options: RegisterClientSettingsScript) {
234 for (const plugin of this.plugins) { 144 const npmName = this.nameToNpmName(pluginInfo.plugin.name, pluginInfo.pluginType)
235 this.addPlugin(plugin) 145
236 } 146 this.settingsScripts[npmName] = options
237 } 147 }
238 148
239 private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers { 149 private buildPeerTubeHelpers (pluginInfo: PluginInfo): RegisterClientHelpers {
@@ -242,12 +152,12 @@ export class PluginService implements ClientHook {
242 152
243 return { 153 return {
244 getBaseStaticRoute: () => { 154 getBaseStaticRoute: () => {
245 const pathPrefix = this.getPluginPathPrefix(pluginInfo.isTheme) 155 const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
246 return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/static` 156 return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/static`
247 }, 157 },
248 158
249 getBaseRouterRoute: () => { 159 getBaseRouterRoute: () => {
250 const pathPrefix = this.getPluginPathPrefix(pluginInfo.isTheme) 160 const pathPrefix = PluginsManager.getPluginPathPrefix(pluginInfo.isTheme)
251 return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/router` 161 return environment.apiUrl + `${pathPrefix}/${plugin.name}/${plugin.version}/router`
252 }, 162 },
253 163
@@ -324,8 +234,4 @@ export class PluginService implements ClientHook {
324 .get<PluginTranslation>(PluginService.BASE_PLUGIN_URL + '/translations/' + completeLocale + '.json') 234 .get<PluginTranslation>(PluginService.BASE_PLUGIN_URL + '/translations/' + completeLocale + '.json')
325 .pipe(shareReplay()) 235 .pipe(shareReplay())
326 } 236 }
327
328 private getPluginPathPrefix (isTheme: boolean) {
329 return isTheme ? '/themes' : '/plugins'
330 }
331} 237}
diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts
index 0c7dec0a1..c35548798 100644
--- a/client/src/app/core/theme/theme.service.ts
+++ b/client/src/app/core/theme/theme.service.ts
@@ -114,6 +114,7 @@ export class ThemeService {
114 const theme = this.getTheme(currentTheme) 114 const theme = this.getTheme(currentTheme)
115 if (theme) { 115 if (theme) {
116 console.log('Adding scripts of theme %s.', currentTheme) 116 console.log('Adding scripts of theme %s.', currentTheme)
117
117 this.pluginService.addPlugin(theme, true) 118 this.pluginService.addPlugin(theme, true)
118 119
119 this.pluginService.reloadLoadedScopes() 120 this.pluginService.reloadLoadedScopes()
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 62dff8285..814253188 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -22,8 +22,10 @@ import './videojs-components/settings-panel-child'
22import './videojs-components/theater-button' 22import './videojs-components/theater-button'
23import './playlist/playlist-plugin' 23import './playlist/playlist-plugin'
24import videojs from 'video.js' 24import videojs from 'video.js'
25import { PluginsManager } from '@root-helpers/plugins-manager'
25import { isDefaultLocale } from '@shared/core-utils/i18n' 26import { isDefaultLocale } from '@shared/core-utils/i18n'
26import { VideoFile } from '@shared/models' 27import { VideoFile } from '@shared/models'
28import { copyToClipboard } from '../../root-helpers/utils'
27import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' 29import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
28import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' 30import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
29import { segmentValidatorFactory } from './p2p-media-loader/segment-validator' 31import { segmentValidatorFactory } from './p2p-media-loader/segment-validator'
@@ -37,8 +39,7 @@ import {
37 VideoJSPluginOptions 39 VideoJSPluginOptions
38} from './peertube-videojs-typings' 40} from './peertube-videojs-typings'
39import { TranslationsManager } from './translations-manager' 41import { TranslationsManager } from './translations-manager'
40import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils' 42import { buildVideoLink, buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
41import { copyToClipboard } from '../../root-helpers/utils'
42 43
43// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) 44// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
44(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' 45(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
@@ -116,21 +117,26 @@ export interface CommonOptions extends CustomizationOptions {
116} 117}
117 118
118export type PeertubePlayerManagerOptions = { 119export type PeertubePlayerManagerOptions = {
119 common: CommonOptions, 120 common: CommonOptions
120 webtorrent: WebtorrentOptions, 121 webtorrent: WebtorrentOptions
121 p2pMediaLoader?: P2PMediaLoaderOptions 122 p2pMediaLoader?: P2PMediaLoaderOptions
123
124 pluginsManager: PluginsManager
122} 125}
123 126
124export class PeertubePlayerManager { 127export class PeertubePlayerManager {
125 private static playerElementClassName: string 128 private static playerElementClassName: string
126 private static onPlayerChange: (player: videojs.Player) => void 129 private static onPlayerChange: (player: videojs.Player) => void
127 private static alreadyPlayed = false 130 private static alreadyPlayed = false
131 private static pluginsManager: PluginsManager
128 132
129 static initState () { 133 static initState () {
130 PeertubePlayerManager.alreadyPlayed = false 134 PeertubePlayerManager.alreadyPlayed = false
131 } 135 }
132 136
133 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) { 137 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) {
138 this.pluginsManager = options.pluginsManager
139
134 let p2pMediaLoader: any 140 let p2pMediaLoader: any
135 141
136 this.onPlayerChange = onPlayerChange 142 this.onPlayerChange = onPlayerChange
@@ -144,7 +150,7 @@ export class PeertubePlayerManager {
144 ]) 150 ])
145 } 151 }
146 152
147 const videojsOptions = this.getVideojsOptions(mode, options, p2pMediaLoader) 153 const videojsOptions = await this.getVideojsOptions(mode, options, p2pMediaLoader)
148 154
149 await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs) 155 await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
150 156
@@ -206,7 +212,7 @@ export class PeertubePlayerManager {
206 await import('./webtorrent/webtorrent-plugin') 212 await import('./webtorrent/webtorrent-plugin')
207 213
208 const mode = 'webtorrent' 214 const mode = 'webtorrent'
209 const videojsOptions = this.getVideojsOptions(mode, options) 215 const videojsOptions = await this.getVideojsOptions(mode, options)
210 216
211 const self = this 217 const self = this
212 videojs(newVideoElement, videojsOptions, function (this: videojs.Player) { 218 videojs(newVideoElement, videojsOptions, function (this: videojs.Player) {
@@ -218,16 +224,16 @@ export class PeertubePlayerManager {
218 }) 224 })
219 } 225 }
220 226
221 private static getVideojsOptions ( 227 private static async getVideojsOptions (
222 mode: PlayerMode, 228 mode: PlayerMode,
223 options: PeertubePlayerManagerOptions, 229 options: PeertubePlayerManagerOptions,
224 p2pMediaLoaderModule?: any 230 p2pMediaLoaderModule?: any
225 ): videojs.PlayerOptions { 231 ): Promise<videojs.PlayerOptions> {
226 const commonOptions = options.common 232 const commonOptions = options.common
227 const isHLS = mode === 'p2p-media-loader' 233 const isHLS = mode === 'p2p-media-loader'
228 234
229 let autoplay = this.getAutoPlayValue(commonOptions.autoplay) 235 let autoplay = this.getAutoPlayValue(commonOptions.autoplay)
230 let html5 = { 236 const html5 = {
231 preloadTextTracks: false 237 preloadTextTracks: false
232 } 238 }
233 239
@@ -306,7 +312,7 @@ export class PeertubePlayerManager {
306 Object.assign(videojsOptions, { language: commonOptions.language }) 312 Object.assign(videojsOptions, { language: commonOptions.language })
307 } 313 }
308 314
309 return videojsOptions 315 return this.pluginsManager.runHook('filter:internal.player.videojs.options.result', videojsOptions)
310 } 316 }
311 317
312 private static addP2PMediaLoaderOptions ( 318 private static addP2PMediaLoaderOptions (
diff --git a/client/src/root-helpers/index.ts b/client/src/root-helpers/index.ts
index 036a7677d..d62f07f9e 100644
--- a/client/src/root-helpers/index.ts
+++ b/client/src/root-helpers/index.ts
@@ -2,3 +2,4 @@ export * from './users'
2export * from './bytes' 2export * from './bytes'
3export * from './peertube-web-storage' 3export * from './peertube-web-storage'
4export * from './utils' 4export * from './utils'
5export * from './plugins-manager'
diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts
new file mode 100644
index 000000000..f919db8af
--- /dev/null
+++ b/client/src/root-helpers/plugins-manager.ts
@@ -0,0 +1,251 @@
1import * as debug from 'debug'
2import { ReplaySubject } from 'rxjs'
3import { first, shareReplay } from 'rxjs/operators'
4import { RegisterClientHelpers } from 'src/types/register-client-option.model'
5import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
6import {
7 ClientHookName,
8 clientHookObject,
9 ClientScript,
10 HTMLServerConfig,
11 PluginClientScope,
12 PluginType,
13 RegisterClientFormFieldOptions,
14 RegisterClientHookOptions,
15 RegisterClientSettingsScript,
16 RegisterClientVideoFieldOptions,
17 ServerConfigPlugin
18} from '../../../shared/models'
19import { environment } from '../environments/environment'
20import { ClientScript as ClientScriptModule } from '../types/client-script.model'
21
22interface HookStructValue extends RegisterClientHookOptions {
23 plugin: ServerConfigPlugin
24 clientScript: ClientScript
25}
26
27type Hooks = { [ name: string ]: HookStructValue[] }
28
29type PluginInfo = {
30 plugin: ServerConfigPlugin
31 clientScript: ClientScript
32 pluginType: PluginType
33 isTheme: boolean
34}
35
36type PeertubeHelpersFactory = (pluginInfo: PluginInfo) => RegisterClientHelpers
37type OnFormFields = (options: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => void
38type OnSettingsScripts = (pluginInfo: PluginInfo, options: RegisterClientSettingsScript) => void
39
40const logger = debug('peertube:plugins')
41
42class PluginsManager {
43 private hooks: Hooks = {}
44
45 private scopes: { [ scopeName: string ]: PluginInfo[] } = {}
46
47 private loadedScripts: { [ script: string ]: boolean } = {}
48 private loadedScopes: PluginClientScope[] = []
49 private loadingScopes: { [id in PluginClientScope]?: boolean } = {}
50
51 private pluginsLoaded: { [ scope in PluginClientScope ]: ReplaySubject<boolean> } = {
52 common: new ReplaySubject<boolean>(1),
53 'admin-plugin': new ReplaySubject<boolean>(1),
54 search: new ReplaySubject<boolean>(1),
55 'video-watch': new ReplaySubject<boolean>(1),
56 signup: new ReplaySubject<boolean>(1),
57 login: new ReplaySubject<boolean>(1),
58 'video-edit': new ReplaySubject<boolean>(1),
59 embed: new ReplaySubject<boolean>(1)
60 }
61
62 private readonly peertubeHelpersFactory: PeertubeHelpersFactory
63 private readonly onFormFields: OnFormFields
64 private readonly onSettingsScripts: OnSettingsScripts
65
66 constructor (options: {
67 peertubeHelpersFactory: PeertubeHelpersFactory
68 onFormFields?: OnFormFields
69 onSettingsScripts?: OnSettingsScripts
70 }) {
71 this.peertubeHelpersFactory = options.peertubeHelpersFactory
72 this.onFormFields = options.onFormFields
73 this.onSettingsScripts = options.onSettingsScripts
74 }
75
76 static getPluginPathPrefix (isTheme: boolean) {
77 return isTheme ? '/themes' : '/plugins'
78 }
79
80 loadPluginsList (config: HTMLServerConfig) {
81 for (const plugin of config.plugin.registered) {
82 this.addPlugin(plugin)
83 }
84 }
85
86 async runHook<T> (hookName: ClientHookName, result?: T, params?: any) {
87 if (!this.hooks[hookName]) return result
88
89 const hookType = getHookType(hookName)
90
91 for (const hook of this.hooks[hookName]) {
92 console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name)
93
94 result = await internalRunHook(hook.handler, hookType, result, params, err => {
95 console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err)
96 })
97 }
98
99 return result
100 }
101
102 ensurePluginsAreLoaded (scope: PluginClientScope) {
103 this.loadPluginsByScope(scope)
104
105 return this.pluginsLoaded[scope].asObservable()
106 .pipe(first(), shareReplay())
107 .toPromise()
108 }
109
110 async reloadLoadedScopes () {
111 for (const scope of this.loadedScopes) {
112 await this.loadPluginsByScope(scope, true)
113 }
114 }
115
116 addPlugin (plugin: ServerConfigPlugin, isTheme = false) {
117 const pathPrefix = PluginsManager.getPluginPathPrefix(isTheme)
118
119 for (const key of Object.keys(plugin.clientScripts)) {
120 const clientScript = plugin.clientScripts[key]
121
122 for (const scope of clientScript.scopes) {
123 if (!this.scopes[scope]) this.scopes[scope] = []
124
125 this.scopes[scope].push({
126 plugin,
127 clientScript: {
128 script: `${pathPrefix}/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`,
129 scopes: clientScript.scopes
130 },
131 pluginType: isTheme ? PluginType.THEME : PluginType.PLUGIN,
132 isTheme
133 })
134
135 this.loadedScripts[clientScript.script] = false
136 }
137 }
138 }
139
140 removePlugin (plugin: ServerConfigPlugin) {
141 for (const key of Object.keys(this.scopes)) {
142 this.scopes[key] = this.scopes[key].filter(o => o.plugin.name !== plugin.name)
143 }
144 }
145
146 async loadPluginsByScope (scope: PluginClientScope, isReload = false) {
147 if (this.loadingScopes[scope]) return
148 if (!isReload && this.loadedScopes.includes(scope)) return
149
150 this.loadingScopes[scope] = true
151
152 logger('Loading scope %s', scope)
153
154 try {
155 if (!isReload) this.loadedScopes.push(scope)
156
157 const toLoad = this.scopes[ scope ]
158 if (!Array.isArray(toLoad)) {
159 this.loadingScopes[scope] = false
160 this.pluginsLoaded[scope].next(true)
161
162 logger('Nothing to load for scope %s', scope)
163 return
164 }
165
166 const promises: Promise<any>[] = []
167 for (const pluginInfo of toLoad) {
168 const clientScript = pluginInfo.clientScript
169
170 if (this.loadedScripts[ clientScript.script ]) continue
171
172 promises.push(this.loadPlugin(pluginInfo))
173
174 this.loadedScripts[ clientScript.script ] = true
175 }
176
177 await Promise.all(promises)
178
179 this.pluginsLoaded[scope].next(true)
180 this.loadingScopes[scope] = false
181
182 logger('Scope %s loaded', scope)
183 } catch (err) {
184 console.error('Cannot load plugins by scope %s.', scope, err)
185 }
186 }
187
188 private loadPlugin (pluginInfo: PluginInfo) {
189 const { plugin, clientScript } = pluginInfo
190
191 const registerHook = (options: RegisterClientHookOptions) => {
192 if (clientHookObject[options.target] !== true) {
193 console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name)
194 return
195 }
196
197 if (!this.hooks[options.target]) this.hooks[options.target] = []
198
199 this.hooks[options.target].push({
200 plugin,
201 clientScript,
202 target: options.target,
203 handler: options.handler,
204 priority: options.priority || 0
205 })
206 }
207
208 const registerVideoField = (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => {
209 if (!this.onFormFields) {
210 throw new Error('Video field registration is not supported')
211 }
212
213 return this.onFormFields(commonOptions, videoFormOptions)
214 }
215
216 const registerSettingsScript = (options: RegisterClientSettingsScript) => {
217 if (!this.onSettingsScripts) {
218 throw new Error('Registering settings script is not supported')
219 }
220
221 return this.onSettingsScripts(pluginInfo, options)
222 }
223
224 const peertubeHelpers = this.peertubeHelpersFactory(pluginInfo)
225
226 console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
227
228 const absURL = (environment.apiUrl || window.location.origin) + clientScript.script
229 return import(/* webpackIgnore: true */ absURL)
230 .then((script: ClientScriptModule) => script.register({ registerHook, registerVideoField, registerSettingsScript, peertubeHelpers }))
231 .then(() => this.sortHooksByPriority())
232 .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
233 }
234
235 private sortHooksByPriority () {
236 for (const hookName of Object.keys(this.hooks)) {
237 this.hooks[hookName].sort((a, b) => {
238 return b.priority - a.priority
239 })
240 }
241 }
242}
243
244export {
245 PluginsManager,
246
247 PluginInfo,
248 PeertubeHelpersFactory,
249 OnFormFields,
250 OnSettingsScripts
251}
diff --git a/client/src/root-helpers/plugins.ts b/client/src/root-helpers/plugins.ts
deleted file mode 100644
index 10c111a8c..000000000
--- a/client/src/root-helpers/plugins.ts
+++ /dev/null
@@ -1,126 +0,0 @@
1import { RegisterClientHelpers } from 'src/types/register-client-option.model'
2import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
3import {
4 ClientHookName,
5 clientHookObject,
6 ClientScript,
7 PluginType,
8 RegisterClientFormFieldOptions,
9 RegisterClientHookOptions,
10 RegisterClientSettingsScript,
11 RegisterClientVideoFieldOptions,
12 ServerConfigPlugin
13} from '../../../shared/models'
14import { environment } from '../environments/environment'
15import { ClientScript as ClientScriptModule } from '../types/client-script.model'
16
17interface HookStructValue extends RegisterClientHookOptions {
18 plugin: ServerConfigPlugin
19 clientScript: ClientScript
20}
21
22type Hooks = { [ name: string ]: HookStructValue[] }
23
24type PluginInfo = {
25 plugin: ServerConfigPlugin
26 clientScript: ClientScript
27 pluginType: PluginType
28 isTheme: boolean
29}
30
31type FormFields = {
32 video: {
33 commonOptions: RegisterClientFormFieldOptions
34 videoFormOptions: RegisterClientVideoFieldOptions
35 }[]
36}
37
38async function runHook<T> (hooks: Hooks, hookName: ClientHookName, result?: T, params?: any) {
39 if (!hooks[hookName]) return result
40
41 const hookType = getHookType(hookName)
42
43 for (const hook of hooks[hookName]) {
44 console.log('Running hook %s of plugin %s.', hookName, hook.plugin.name)
45
46 result = await internalRunHook(hook.handler, hookType, result, params, err => {
47 console.error('Cannot run hook %s of script %s of plugin %s.', hookName, hook.clientScript.script, hook.plugin.name, err)
48 })
49 }
50
51 return result
52}
53
54function loadPlugin (options: {
55 hooks: Hooks
56 pluginInfo: PluginInfo
57 peertubeHelpersFactory: (pluginInfo: PluginInfo) => RegisterClientHelpers
58 formFields?: FormFields
59 onSettingsScripts?: (options: RegisterClientSettingsScript) => void
60}) {
61 const { hooks, pluginInfo, peertubeHelpersFactory, formFields, onSettingsScripts } = options
62 const { plugin, clientScript } = pluginInfo
63
64 const registerHook = (options: RegisterClientHookOptions) => {
65 if (clientHookObject[options.target] !== true) {
66 console.error('Unknown hook %s of plugin %s. Skipping.', options.target, plugin.name)
67 return
68 }
69
70 if (!hooks[options.target]) hooks[options.target] = []
71
72 hooks[options.target].push({
73 plugin,
74 clientScript,
75 target: options.target,
76 handler: options.handler,
77 priority: options.priority || 0
78 })
79 }
80
81 const registerVideoField = (commonOptions: RegisterClientFormFieldOptions, videoFormOptions: RegisterClientVideoFieldOptions) => {
82 if (!formFields) {
83 throw new Error('Video field registration is not supported')
84 }
85
86 formFields.video.push({
87 commonOptions,
88 videoFormOptions
89 })
90 }
91
92 const registerSettingsScript = (options: RegisterClientSettingsScript) => {
93 if (!onSettingsScripts) {
94 throw new Error('Registering settings script is not supported')
95 }
96
97 return onSettingsScripts(options)
98 }
99
100 const peertubeHelpers = peertubeHelpersFactory(pluginInfo)
101
102 console.log('Loading script %s of plugin %s.', clientScript.script, plugin.name)
103
104 const absURL = (environment.apiUrl || window.location.origin) + clientScript.script
105 return import(/* webpackIgnore: true */ absURL)
106 .then((script: ClientScriptModule) => script.register({ registerHook, registerVideoField, registerSettingsScript, peertubeHelpers }))
107 .then(() => sortHooksByPriority(hooks))
108 .catch(err => console.error('Cannot import or register plugin %s.', pluginInfo.plugin.name, err))
109}
110
111export {
112 HookStructValue,
113 Hooks,
114 PluginInfo,
115 FormFields,
116 loadPlugin,
117 runHook
118}
119
120function sortHooksByPriority (hooks: Hooks) {
121 for (const hookName of Object.keys(hooks)) {
122 hooks[hookName].sort((a, b) => {
123 return b.priority - a.priority
124 })
125 }
126}
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index a367feb8e..dc9727049 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -3,7 +3,6 @@ import videojs from 'video.js'
3import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 3import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
4import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { 5import {
6 ClientHookName,
7 HTMLServerConfig, 6 HTMLServerConfig,
8 OAuth2ErrorCode, 7 OAuth2ErrorCode,
9 PluginType, 8 PluginType,
@@ -19,7 +18,7 @@ import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode } from
19import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings' 18import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
20import { TranslationsManager } from '../../assets/player/translations-manager' 19import { TranslationsManager } from '../../assets/player/translations-manager'
21import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage' 20import { peertubeLocalStorage } from '../../root-helpers/peertube-web-storage'
22import { Hooks, loadPlugin, runHook } from '../../root-helpers/plugins' 21import { PluginsManager } from '../../root-helpers/plugins-manager'
23import { Tokens } from '../../root-helpers/users' 22import { Tokens } from '../../root-helpers/users'
24import { objectToUrlEncoded } from '../../root-helpers/utils' 23import { objectToUrlEncoded } from '../../root-helpers/utils'
25import { RegisterClientHelpers } from '../../types/register-client-option.model' 24import { RegisterClientHelpers } from '../../types/register-client-option.model'
@@ -68,8 +67,7 @@ export class PeerTubeEmbed {
68 67
69 private wrapperElement: HTMLElement 68 private wrapperElement: HTMLElement
70 69
71 private peertubeHooks: Hooks = {} 70 private pluginsManager: PluginsManager
72 private loadedScripts = new Set<string>()
73 71
74 static async main () { 72 static async main () {
75 const videoContainerId = 'video-wrapper' 73 const videoContainerId = 'video-wrapper'
@@ -489,7 +487,7 @@ export class PeerTubeEmbed {
489 this.PeertubePlayerManagerModulePromise 487 this.PeertubePlayerManagerModulePromise
490 ]) 488 ])
491 489
492 await this.ensurePluginsAreLoaded(serverTranslations) 490 await this.loadPlugins(serverTranslations)
493 491
494 const videoInfo: VideoDetails = videoInfoTmp 492 const videoInfo: VideoDetails = videoInfoTmp
495 493
@@ -560,7 +558,9 @@ export class PeerTubeEmbed {
560 558
561 webtorrent: { 559 webtorrent: {
562 videoFiles: videoInfo.files 560 videoFiles: videoInfo.files
563 } 561 },
562
563 pluginsManager: this.pluginsManager
564 } 564 }
565 565
566 if (this.mode === 'p2p-media-loader') { 566 if (this.mode === 'p2p-media-loader') {
@@ -600,7 +600,7 @@ export class PeerTubeEmbed {
600 }) 600 })
601 } 601 }
602 602
603 this.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo }) 603 this.pluginsManager.runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video: videoInfo })
604 } 604 }
605 605
606 private async initCore () { 606 private async initCore () {
@@ -740,37 +740,14 @@ export class PeerTubeEmbed {
740 return window.location.pathname.split('/')[1] === 'video-playlists' 740 return window.location.pathname.split('/')[1] === 'video-playlists'
741 } 741 }
742 742
743 private async ensurePluginsAreLoaded (translations?: { [ id: string ]: string }) { 743 private loadPlugins (translations?: { [ id: string ]: string }) {
744 if (this.config.plugin.registered.length === 0) return 744 this.pluginsManager = new PluginsManager({
745 745 peertubeHelpersFactory: _ => this.buildPeerTubeHelpers(translations)
746 for (const plugin of this.config.plugin.registered) { 746 })
747 for (const key of Object.keys(plugin.clientScripts)) {
748 const clientScript = plugin.clientScripts[key]
749
750 if (clientScript.scopes.includes('embed') === false) continue
751
752 const script = `/plugins/${plugin.name}/${plugin.version}/client-scripts/${clientScript.script}`
753
754 if (this.loadedScripts.has(script)) continue
755 747
756 const pluginInfo = { 748 this.pluginsManager.loadPluginsList(this.config)
757 plugin,
758 clientScript: {
759 script,
760 scopes: clientScript.scopes
761 },
762 pluginType: PluginType.PLUGIN,
763 isTheme: false
764 }
765 749
766 await loadPlugin({ 750 return this.pluginsManager.ensurePluginsAreLoaded('embed')
767 hooks: this.peertubeHooks,
768 pluginInfo,
769 onSettingsScripts: () => undefined,
770 peertubeHelpersFactory: _ => this.buildPeerTubeHelpers(translations)
771 })
772 }
773 }
774 } 751 }
775 752
776 private buildPeerTubeHelpers (translations?: { [ id: string ]: string }): RegisterClientHelpers { 753 private buildPeerTubeHelpers (translations?: { [ id: string ]: string }): RegisterClientHelpers {
@@ -808,10 +785,6 @@ export class PeerTubeEmbed {
808 } 785 }
809 } 786 }
810 } 787 }
811
812 private runHook <T> (hookName: ClientHookName, result?: T, params?: any): Promise<T> {
813 return runHook(this.peertubeHooks, hookName, result, params)
814 }
815} 788}
816 789
817PeerTubeEmbed.main() 790PeerTubeEmbed.main()
diff --git a/shared/models/plugins/client/client-hook.model.ts b/shared/models/plugins/client/client-hook.model.ts
index 3eedd670b..546866845 100644
--- a/shared/models/plugins/client/client-hook.model.ts
+++ b/shared/models/plugins/client/client-hook.model.ts
@@ -53,7 +53,10 @@ export const clientFilterHookObject = {
53 'filter:internal.common.svg-icons.get-content.result': true, 53 'filter:internal.common.svg-icons.get-content.result': true,
54 54
55 // Filter left menu links 55 // Filter left menu links
56 'filter:left-menu.links.create.result': true 56 'filter:left-menu.links.create.result': true,
57
58 // Filter videojs options built for PeerTube player
59 'filter:internal.player.videojs.options.result': true
57} 60}
58 61
59export type ClientFilterHookName = keyof typeof clientFilterHookObject 62export type ClientFilterHookName = keyof typeof clientFilterHookObject