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 @@ + + + + diff --git a/src/modules/main_menu/components/experiments.vue b/src/modules/main_menu/components/experiments.vue index 5e66ca49..cad3d4c8 100644 --- a/src/modules/main_menu/components/experiments.vue +++ b/src/modules/main_menu/components/experiments.vue @@ -48,7 +48,7 @@ :key="idx" :selected="i.value === exp.value" > - {{ t('setting.experiments.entry', '{value} (weight: {weight})', i) }} + {{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }} @@ -122,7 +122,7 @@ :key="idx" :selected="i.value === exp.value" > - {{ i.value }} (weight: {{ i.weight }}) + {{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }} diff --git a/src/modules/main_menu/components/menu-container.vue b/src/modules/main_menu/components/menu-container.vue index ed753c8a..65aef0b3 100644 --- a/src/modules/main_menu/components/menu-container.vue +++ b/src/modules/main_menu/components/menu-container.vue @@ -18,6 +18,7 @@ :context="context" :item="i" :filter="filter" + @navigate="navigate" /> @@ -41,6 +42,10 @@ export default { }, methods: { + navigate(...args) { + this.$emit('navigate', ...args); + }, + shouldShow(item) { if ( ! this.filter || ! this.filter.length || ! item.search_terms ) return true; diff --git a/src/modules/main_menu/components/menu-page.vue b/src/modules/main_menu/components/menu-page.vue index 88879544..c2fc25e3 100644 --- a/src/modules/main_menu/components/menu-page.vue +++ b/src/modules/main_menu/components/menu-page.vue @@ -93,7 +93,7 @@ export default { if ( ! this.filter || ! this.filter.length || ! item.search_terms ) return true; - return item.search_terms.includes(this.filter); + return item.no_filter || item.search_terms.includes(this.filter); }, markSeen(item) { diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index 2c12d5b9..0818bdf7 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -135,6 +135,9 @@ export default class MainMenu extends Module { async onEnable() { await this.site.awaitElement(Dialog.EXCLUSIVE); + this.on('addons:added', this.scheduleUpdate, this); + this.on('i18n:update', this.scheduleUpdate, this); + this.dialog.on('show', () => { this.opened = true; this.updateButtonUnseen(); @@ -362,7 +365,7 @@ export default class MainMenu extends Module { tok.default = def.default; } - const terms = [ + let terms = [ setting_key, this.i18n.t(tok.i18n_key, tok.title, tok, true) ]; @@ -377,6 +380,9 @@ export default class MainMenu extends Module { terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok)); } + if ( tok.getExtraTerms ) + terms = terms.concat(tok.getExtraTerms()); + tok.search_terms = terms.map(format_term).join('\n'); if ( settings_seen ) { @@ -712,7 +718,7 @@ export default class MainMenu extends Module { } - return { + const out = { context, query: '', @@ -757,6 +763,8 @@ export default class MainMenu extends Module { }, version: window.FrankerFaceZ.version_info, - } + }; + + return out; } } \ No newline at end of file diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index 8ce47fd6..9a3de290 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -243,6 +243,10 @@ export default class Metadata extends Module { return Object.keys(this.definitions); } + define(key, definition) { + this.definitions[key] = definition; + this.updateMetadata(key); + } updateMetadata(keys) { const bar = this.resolve('site.channel_bar'); diff --git a/src/raven.js b/src/raven.js index 4759a1c6..42724bfb 100644 --- a/src/raven.js +++ b/src/raven.js @@ -140,7 +140,8 @@ export default class RavenLogger extends Module { 'Access is denied.', 'Zugriff verweigert', 'freed script', - 'ffzenhancing' + 'ffzenhancing', + 'dead object' ], sanitizeKeys: [ /Token$/ diff --git a/src/sites/twitch-twilight/modules/css_tweaks/styles/square-avatars.scss b/src/sites/twitch-twilight/modules/css_tweaks/styles/square-avatars.scss index f13a36d3..82ebd30b 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/styles/square-avatars.scss +++ b/src/sites/twitch-twilight/modules/css_tweaks/styles/square-avatars.scss @@ -1,3 +1,4 @@ +.player-streaminfo__picture img[src], .side-nav-card__avatar.tw-border-radius-rounded, .tw-avatar .tw-border-radius-rounded { .tw-root--theme-dark &, diff --git a/src/utilities/addon.js b/src/utilities/addon.js new file mode 100644 index 00000000..b8fbb517 --- /dev/null +++ b/src/utilities/addon.js @@ -0,0 +1,20 @@ +import Module from 'utilities/module'; + +export class Addon extends Module { + constructor(...args) { + super(...args); + + this.inject('i18n'); + this.inject('settings'); + } + + static register(id, info) { + const ffz = FrankerFaceZ.get(); + ffz.register(`addon.${id}`, this); + + if ( info ) { + info.id = id; + ffz.addons.addAddon(info); + } + } +} \ No newline at end of file diff --git a/src/utilities/module.js b/src/utilities/module.js index bff13709..f46bfb62 100644 --- a/src/utilities/module.js +++ b/src/utilities/module.js @@ -46,6 +46,8 @@ export class Module extends EventEmitter { this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED; this.__state = this.onLoad || this.onEnable ? State.DISABLED : State.ENABLED; + + this.emit(':registered'); } diff --git a/src/utilities/tooltip.js b/src/utilities/tooltip.js index 009e09f7..9c270e7c 100644 --- a/src/utilities/tooltip.js +++ b/src/utilities/tooltip.js @@ -204,6 +204,8 @@ export class Tooltip { this.elements.add(target); // Set this early in case content uses it early. + tip._promises = []; + tip.waitForDom = () => tip.element ? Promise.resolve() : new Promise(s => {tip._promises.push(s)}); tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate(); tip.show = () => this.show(tip); tip.hide = () => this.hide(tip); @@ -277,6 +279,11 @@ export class Tooltip { } } + for(const fn of tip._promises) + fn(); + + tip._promises = null; + if ( content instanceof Promise || (content.then && content.toString() === '[object Promise]') ) { inner.innerHTML = '
'; content.then(content => { diff --git a/src/utilities/translation-core.js b/src/utilities/translation-core.js index 8b09bc23..08ccc313 100644 --- a/src/utilities/translation-core.js +++ b/src/utilities/translation-core.js @@ -16,6 +16,10 @@ import {duration_to_string} from 'utilities/time'; // ============================================================================ export const DEFAULT_TYPES = { + tostring(val) { + return `${val}` + }, + select(val, node, locale, out, ast, data) { const sub_ast = node.o && (node.o[val] || node.o.other); if ( ! sub_ast ) diff --git a/styles/widgets.scss b/styles/widgets.scss index 38c90fc4..1b0f8989 100644 --- a/styles/widgets.scss +++ b/styles/widgets.scss @@ -7,6 +7,7 @@ @import "./widgets/menu-tree.scss"; @import "./widgets/profile-selector.scss"; @import "./widgets/badge-visibility.scss"; +@import "./widgets/add-ons.scss"; @import "./widgets/color-picker.scss"; @@ -229,4 +230,29 @@ background-color: rgba(255,255,255,0.05); } } +} + +@mixin button-colors($color, $text, $shadow) { + &:hover, &:focus { + background: $color !important; + border-color: $color !important; + color: $text !important; + } + + &:focus { + box-shadow: 0 0 $shadow $color !important; + } +} + +.ffz--pill-enabled { + background-color: #007600 !important; + color: #fff !important; +} + +.ffz--button-enable { + @include button-colors(#007600, #fff, 6px); +} + +.ffz--button-disable { + @include button-colors(#bd0f0f, #fff, 6px); } \ No newline at end of file diff --git a/styles/widgets/add-ons.scss b/styles/widgets/add-ons.scss new file mode 100644 index 00000000..a562d54b --- /dev/null +++ b/styles/widgets/add-ons.scss @@ -0,0 +1,5 @@ +.ffz--add-on-info { + .ffz-logo-section { + max-width: 8rem; + } +} \ No newline at end of file