diff --git a/.eslintrc.js b/.eslintrc.js
index d3b5898b..d187e247 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -28,7 +28,8 @@ module.exports = {
"import": false,
"require": false,
"__webpack_hash__": false,
- "__git_commit__": false
+ "__git_commit__": false,
+ "FrankerFaceZ": false
},
"rules": {
"accessor-pairs": ["error"],
diff --git a/src/addons.js b/src/addons.js
new file mode 100644
index 00000000..2b43c987
--- /dev/null
+++ b/src/addons.js
@@ -0,0 +1,334 @@
+'use strict';
+
+// ============================================================================
+// Add-On System
+// ============================================================================
+
+import Module from 'utilities/module';
+import { DEBUG, SERVER } from 'utilities/constants';
+import { createElement } from 'utilities/dom';
+import { timeout, has } from 'utilities/object';
+
+const fetchJSON = (url, options) => {
+ return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
+}
+
+// ============================================================================
+// AddonManager
+// ============================================================================
+
+export default class AddonManager extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.should_enable = true;
+
+ this.inject('experiments');
+ this.inject('settings');
+ this.inject('i18n');
+
+ this.reload_required = false;
+ this.addons = {};
+ this.enabled_addons = [];
+ }
+
+ async onEnable() {
+ if ( ! this.experiments.getAssignment('addons') )
+ return;
+
+ this.settings.addUI('add-ons', {
+ path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch."}',
+ component: 'add-ons',
+ title: 'Add-Ons',
+ no_filter: true,
+
+ getExtraSearch: () => Object.values(this.addons).map(addon => addon.search_terms),
+
+ isReady: () => this.enabled,
+ getAddons: () => Object.values(this.addons),
+ hasAddon: id => this.hasAddon(id),
+ getVersion: id => this.getVersion(id),
+ isAddonEnabled: id => this.isAddonEnabled(id),
+ isAddonExternal: id => this.isAddonExternal(id),
+ enableAddon: id => this.enableAddon(id),
+ disableAddon: id => this.disableAddon(id),
+ isReloadRequired: () => this.reload_required,
+ refresh: () => window.location.reload(),
+
+ on: (...args) => this.on(...args),
+ off: (...args) => this.off(...args)
+ });
+
+ this.settings.add('addons.dev.server', {
+ default: false,
+ ui: {
+ path: 'Add-Ons >> Development',
+ title: 'Use Local Development Server',
+ description: 'Attempt to load add-ons from local development server on port 8001.',
+ component: 'setting-check-box'
+ }
+ });
+
+ this.on('i18n:update', this.rebuildAddonSearch, this);
+
+ this.settings.provider.on('changed', this.onProviderChange, this);
+
+ await this.loadAddonData();
+ this.enabled_addons = this.settings.provider.get('addons.enabled', []);
+
+ // We do not await enabling add-ons because that would delay the
+ // main script's execution.
+ for(const id of this.enabled_addons)
+ if ( this.hasAddon(id) )
+ this._enableAddon(id);
+
+ this.emit(':ready');
+ }
+
+ onProviderChange(key, value) {
+ if ( key != 'addons.enabled' )
+ return;
+
+ if ( ! value )
+ value = [];
+
+ const old_enabled = [...this.enabled_addons];
+
+ // Add-ons to disable
+ for(const id of old_enabled)
+ if ( ! value.includes(id) )
+ this.disableAddon(id, false);
+
+ // Add-ons to enable
+ for(const id of value)
+ if ( ! old_enabled.includes(id) )
+ this.enableAddon(id, false);
+ }
+
+ async loadAddonData() {
+ const [cdn_data, local_data] = await Promise.all([
+ fetchJSON(`${SERVER}/script/addons.json?_=${FrankerFaceZ.version_info}`),
+ this.settings.get('addons.dev.server') ?
+ fetchJSON(`https://localhost:8001/script/addons.json?_=${Date.now()}`) : null
+ ]);
+
+ if ( Array.isArray(cdn_data) )
+ for(const addon of cdn_data )
+ this.addAddon(addon, false);
+
+ if ( Array.isArray(local_data) )
+ for(const addon of local_data)
+ this.addAddon(addon, true);
+
+ this.rebuildAddonSearch();
+ }
+
+ addAddon(addon, is_dev = false) {
+ const old = this.addons[addon.id];
+ this.addons[addon.id] = addon;
+
+ addon.name_i18n = addon.name_i18n || `addon.${addon.id}.name`;
+ addon.short_name_i18n = addon.short_name_i18n || `addon.${addon.id}.short_name`;
+ addon.description_i18n = addon.description_i18n || `addon.${addon.id}.description`;
+ addon.author_i18n = addon.author_i18n || `addon.${addon.id}.author`;
+
+ addon.dev = is_dev;
+ addon.requires = addon.requires || [];
+ addon.required_by = Array.isArray(old) ? old : old && old.required_by || [];
+
+ addon._search = addon.search_terms;
+
+ for(const id of addon.requires) {
+ const target = this.addons[id];
+ if ( Array.isArray(target) )
+ target.push(addon.id);
+ else if ( target )
+ target.required_by.push(addon.id);
+ else
+ this.addons[id] = [addon.id];
+ }
+
+ this.emit(':added-addon');
+ }
+
+ rebuildAddonSearch() {
+ for(const addon of Object.values(this.addons)) {
+ const terms = new Set([
+ addon._search,
+ addon.name,
+ addon.short_name,
+ addon.author,
+ addon.description,
+ ]);
+
+ if ( this.i18n.locale !== 'en' ) {
+ terms.add(this.i18n.t(addon.name_i18n, addon.name));
+ terms.add(this.i18n.t(addon.short_name_i18n, addon.short_name));
+ terms.add(this.i18n.t(addon.author_i18n, addon.author));
+ terms.add(this.i18n.t(addon.description_i18n, addon.description));
+ }
+
+ addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
+ }
+ }
+
+ isAddonEnabled(id) {
+ return this.enabled_addons.includes(id);
+ }
+
+ getAddon(id) {
+ const addon = this.addons[id];
+ return Array.isArray(addon) ? null : addon;
+ }
+
+ hasAddon(id) {
+ return this.getAddon(id) != null;
+ }
+
+ getVersion(id) {
+ const addon = this.getAddon(id);
+ if ( ! addon )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ const module = this.resolve(`addon.${id}`);
+ if ( module ) {
+ if ( has(module, 'version') )
+ return module.version;
+ else if ( module.constructor && has(module.constructor, 'version') )
+ return module.constructor.version;
+ }
+
+ return addon.version;
+ }
+
+ isAddonExternal(id) {
+ if ( ! this.hasAddon(id) )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ const module = this.resolve(`addon.${id}`);
+ // If we can't find it, assume it isn't.
+ if ( ! module )
+ return false;
+
+ // Check for one of our script tags. If we didn't load
+ // it ourselves, then it's external.
+ const script = document.head.querySelector(`script#ffz-loaded-addon-${id}`);
+ if ( ! script )
+ return true;
+
+ // Finally, let the module flag itself as external.
+ return module.external || (module.constructor && module.constructor.external);
+ }
+
+ async _enableAddon(id) {
+ const addon = this.getAddon(id);
+ if ( ! addon )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ await this.loadAddon(id);
+
+ const module = this.resolve(`addon.${id}`);
+ if ( module && ! module.enabled )
+ await module.enable();
+ }
+
+ async loadAddon(id) {
+ const addon = this.getAddon(id);
+ if ( ! addon )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ let module = this.resolve(`addon.${id}`);
+ if ( module ) {
+ if ( ! module.loaded )
+ await module.load();
+
+ this.emit(':addon-loaded', id);
+ return;
+ }
+
+ document.head.appendChild(createElement('script', {
+ id: `ffz-loaded-addon-${addon.id}`,
+ type: 'text/javascript',
+ src: addon.src || `https://${addon.dev ? 'localhost:8001' : SERVER}/script/addons/${addon.id}/script.js`,
+ crossorigin: 'anonymous'
+ }));
+
+ // Error if this takes more than 5 seconds.
+ await timeout(this.waitFor(`addon.${id}:registered`), 5000);
+
+ module = this.resolve(`addon.${id}`);
+ if ( module && ! module.loaded )
+ await module.load();
+
+ this.emit(':addon-loaded', id);
+ }
+
+ unloadAddon(id) {
+ const module = this.resolve(`addon.${id}`);
+ if ( module )
+ return module.unload();
+ }
+
+ enableAddon(id, save = true) {
+ const addon = this.getAddon(id);
+ if( ! addon )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ if ( this.isAddonEnabled(id) )
+ return;
+
+ if ( Array.isArray(addon.requires) ) {
+ for(const id of addon.requires) {
+ if ( ! this.hasAddon(id) )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ this.enableAddon(id);
+ }
+ }
+
+ this.emit(':addon-enabled', id);
+ this.enabled_addons.push(id);
+
+ if ( save )
+ this.settings.provider.set('addons.enabled', this.enabled_addons);
+
+ // Actually load it.
+ this._enableAddon(id);
+ }
+
+ async disableAddon(id, save = true) {
+ const addon = this.getAddon(id);
+ if ( ! addon )
+ throw new Error(`Unknown add-on id: ${id}`);
+
+ if ( this.isAddonExternal(id) )
+ throw new Error(`Cannot disable external add-on with id: ${id}`);
+
+ if ( ! this.isAddonEnabled(id) )
+ return;
+
+ if ( Array.isArray(addon.required_by) ) {
+ const promises = [];
+ for(const id of addon.required_by)
+ promises.push(this.disableAddon(id));
+
+ await Promise.all(promises);
+ }
+
+ this.emit(':addon-disabled', id);
+ this.enabled_addons.splice(this.enabled_addons.indexOf(id), 1);
+
+ if ( save )
+ this.settings.provider.set('addons.enabled', this.enabled_addons);
+
+ // Try disabling loaded modules.
+ try {
+ const module = this.resolve(`addon.${id}`);
+ if ( module )
+ await module.disable();
+ } catch(err) {
+ this.reload_required = true;
+ this.emit(':reload-required');
+ }
+ }
+}
diff --git a/src/addons.json b/src/addons.json
new file mode 100644
index 00000000..16fd730c
--- /dev/null
+++ b/src/addons.json
@@ -0,0 +1,45 @@
+[
+ {
+ "id": "ffzap-bttv",
+ "requires": ["ffzap-core"],
+
+ "version": "3.1.8",
+ "icon": "https://cdn.betterttv.net/assets/logos/mascot.png",
+
+ "shortname": "FFZ:AP BTTV",
+ "name": "BetterTTV Emotes",
+ "author": "The FrankerFaceZ Add-On Pack",
+ "description": "This is the BetterTTV module of The FrankerFaceZ Add-On Pack. It includes BetterTTV Global Emotes, Channel Emotes and Pro Emotes as well as various other functionality.",
+
+ "website": "https://ffzap.com/",
+ "settings": "add_ons.ffz_ap.better_ttv"
+ },
+ {
+ "id": "ffzap-liriklive",
+ "requires": ["ffzap-core"],
+
+ "version": "3.1.4",
+ "icon": "https://cdn.ffzap.com/liriklive/icon.png",
+
+ "shortname": "FFZ:AP LirikLIVE",
+ "name": "LirikLIVE",
+ "author": "The FrankerFaceZ Add-On Pack",
+ "description": "The LirikLIVE extension!",
+
+ "settings": "add_ons.ffz_ap.lirik_live"
+ },
+ {
+ "id": "ffzap-core",
+ "requires": [],
+
+ "version": "3.1.4",
+ "icon": "https://ffzap.com/img/logo.png",
+
+ "shortname": "FFZ:AP Core",
+ "name": "The FrankerFaceZ Add-On Pack - Core",
+ "author": "The FrankerFaceZ Add-On Pack",
+ "description": "This is the core functionality for The FrankerFaceZ Add-On Pack. It includes FFZ:AP supporter functionality and various other required utilities for the FFZ:AP modules.",
+
+ "website": "https://ffzap.com/"
+ }
+]
\ No newline at end of file
diff --git a/src/experiments.js b/src/experiments.js
index 3c5471ca..ba162f99 100644
--- a/src/experiments.js
+++ b/src/experiments.js
@@ -4,7 +4,7 @@
// Experiments
// ============================================================================
-import {SERVER} from 'utilities/constants';
+import {DEBUG, SERVER} from 'utilities/constants';
import Module from 'utilities/module';
import {has, deep_copy} from 'utilities/object';
@@ -75,7 +75,7 @@ export default class ExperimentManager extends Module {
let data;
try {
- data = await fetch(`${SERVER}/script/experiments.json?_=${Date.now()}`).then(r =>
+ data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${Date.now()}`).then(r =>
r.ok ? r.json() : null);
} catch(err) {
diff --git a/src/experiments.json b/src/experiments.json
index 761d91b9..3651c042 100644
--- a/src/experiments.json
+++ b/src/experiments.json
@@ -1,4 +1,12 @@
{
+ "addons": {
+ "name": "Add-Ons Loader",
+ "description": "Enable the new Add-Ons system in development.",
+ "groups": [
+ {"value": true, "weight": 0},
+ {"value": false, "weight": 100}
+ ]
+ },
"api_load": {
"name": "New API Stress Testing",
"description": "Send duplicate requests to the new API server for load testing.",
diff --git a/src/main.js b/src/main.js
index 1f53a092..816596bc 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5,16 +5,17 @@ import RavenLogger from './raven';
import Logger from 'utilities/logging';
import Module from 'utilities/module';
+import { timeout } from 'utilities/object';
import {DEBUG} from 'utilities/constants';
import SettingsManager from './settings/index';
+import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import SocketClient from './socket';
import Site from 'site';
import Vue from 'utilities/vue';
-import { timeout } from './utilities/object';
class FrankerFaceZ extends Module {
constructor() {
@@ -51,6 +52,7 @@ class FrankerFaceZ extends Module {
this.inject('i18n', TranslationManager);
this.inject('socket', SocketClient);
this.inject('site', Site);
+ this.inject('addons', AddonManager);
this.register('vue', Vue);
@@ -158,6 +160,7 @@ const VER = FrankerFaceZ.version_info = {
FrankerFaceZ.utilities = {
+ addon: require('utilities/addon'),
dom: require('utilities/dom'),
color: require('utilities/color'),
events: require('utilities/events'),
diff --git a/src/modules/main_menu/components/add-ons.vue b/src/modules/main_menu/components/add-ons.vue
new file mode 100644
index 00000000..e1610409
--- /dev/null
+++ b/src/modules/main_menu/components/add-ons.vue
@@ -0,0 +1,231 @@
+
+
+
{{ t(addon.name_i18n, addon.name) }} ({{ addon.id }})
+
+ {{ t('addon.author', 'By: {author}', {
+ author: t(addon.author_i18n, addon.author)
+ }) }}
+
+
+ {{ t('addon.version', 'Version {version}', {version: item.getVersion(addon.id)}) }}
+
+