mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
Add-Ons Loader System (#606)
Implements an Add-on Loader so that other add-ons, such as the FFZ Add-on Pack, can be loaded directly by FFZ without requiring the user to install multiple extensions into their browser.
This commit is contained in:
parent
d9f252ee4e
commit
a305d03b2c
20 changed files with 716 additions and 11 deletions
|
@ -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"],
|
||||
|
|
334
src/addons.js
Normal file
334
src/addons.js
Normal file
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
45
src/addons.json
Normal file
45
src/addons.json
Normal file
|
@ -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/"
|
||||
}
|
||||
]
|
|
@ -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) {
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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'),
|
||||
|
|
231
src/modules/main_menu/components/add-ons.vue
Normal file
231
src/modules/main_menu/components/add-ons.vue
Normal file
|
@ -0,0 +1,231 @@
|
|||
<template lang="html">
|
||||
<div class="ffz--addons tw-border-t tw-pd-y-1">
|
||||
<div v-if="reload" class="tw-mg-y-1 tw-c-background-accent tw-c-text-overlay tw-pd-1">
|
||||
<h4 class="ffz-i-attention">
|
||||
{{ t('addon.refresh-needed', 'You must refresh your Twitch pages for some changes to take effect.') }}
|
||||
</h4>
|
||||
|
||||
<button
|
||||
class="tw-button tw-button--hollow tw-mg-t-05"
|
||||
@click="item.refresh()"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-arrows-cw" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.refresh', 'Refresh') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="! ready" class="tw-align-center tw-pd-1">
|
||||
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="addon in sorted_addons"
|
||||
v-if="shouldShow(addon)"
|
||||
:key="addon.id"
|
||||
class="ffz--addon-info tw-elevation-1 tw-c-background-base tw-border tw-pd-1 tw-mg-b-1 tw-flex tw-flex-nowrap"
|
||||
>
|
||||
<div class="tw-flex tw-flex-column tw-align-center tw-flex-shrink-0 tw-mg-r-1">
|
||||
<div class="tw-card-img--size-6 tw-overflow-hidden tw-mg-b-1">
|
||||
<img :src="addon.icon" class="tw-image">
|
||||
</div>
|
||||
|
||||
<div v-if="enabled[addon.id]" class="tw-mg-b-05 tw-pill ffz--pill-enabled">
|
||||
{{ t('addon.enabled', 'Enabled') }}
|
||||
</div>
|
||||
|
||||
<div v-if="addon.dev" class="tw-mg-b-05 tw-pill">
|
||||
{{ t('addon.dev', 'Developer') }}
|
||||
</div>
|
||||
|
||||
<div v-if="item.isAddonExternal(addon.id)" class="tw-mg-b-05 tw-pill">
|
||||
{{ t('addon.external', 'External') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex-grow-1">
|
||||
<div class="tw-border-b tw-mg-b-05">
|
||||
<h4>{{ t(addon.name_i18n, addon.name) }} <span class="tw-c-text-alt-2 tw-font-size-6">({{ addon.id }})</span></h4>
|
||||
<span class="tw-c-text-alt tw-mg-r-1">
|
||||
{{ t('addon.author', 'By: {author}', {
|
||||
author: t(addon.author_i18n, addon.author)
|
||||
}) }}
|
||||
</span>
|
||||
<span v-if="item.getVersion(addon.id)" class="tw-c-text-alt">
|
||||
{{ t('addon.version', 'Version {version}', {version: item.getVersion(addon.id)}) }}
|
||||
</span>
|
||||
</div>
|
||||
<markdown :source="t(addon.description_i18n, addon.description)" />
|
||||
|
||||
<div class="tw-mg-t-1 tw-pd-t-1 tw-border-t">
|
||||
<template v-if="enabled[addon.id]">
|
||||
<button
|
||||
v-if="item.isAddonExternal(addon.id)"
|
||||
disabled
|
||||
class="tw-button tw-button--hollow tw-button--disabled tw-tooltip-wrapper tw-mg-r-1"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-trash" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.disable', 'Disable') }}
|
||||
</span>
|
||||
<div class="tw-tooltip tw-tooltip--up tw-tooltip--align-left">
|
||||
{{ t('addon.external.description', 'This add-on has been loaded by an external script and cannot be disabled here.') }}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="tw-button tw-button--hollow ffz--button-disable tw-mg-r-1"
|
||||
@click="item.disableAddon(addon.id)"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-trash" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.disable', 'Disable') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="addon.settings"
|
||||
class="tw-button tw-button--hollow tw-mg-r-1"
|
||||
@click="openSettings(addon)"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-cog" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.settings', 'Settings') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="tw-button tw-button--hollow ffz--button-enable tw-mg-r-1"
|
||||
@click="item.enableAddon(addon.id)"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-download" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.enable', 'Enable') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-if="addon.website"
|
||||
:href="addon.website"
|
||||
:title="addon.website"
|
||||
class="tw-button tw-button--hollow tw-mg-r-1"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="tw-button__icon tw-button__icon--left">
|
||||
<figure class="ffz-i-link-ext" />
|
||||
</span>
|
||||
<span class="tw-button__text">
|
||||
{{ t('addon.website', 'Website') }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: ['item', 'context', 'filter'],
|
||||
|
||||
data() {
|
||||
const enabled = {};
|
||||
|
||||
for(const addon of this.item.getAddons())
|
||||
enabled[addon.id] = this.item.isAddonEnabled(addon.id);
|
||||
|
||||
return {
|
||||
ready: this.item.isReady(),
|
||||
reload: this.item.isReloadRequired(),
|
||||
enabled
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
sorted_addons() {
|
||||
const addons = this.item.getAddons();
|
||||
|
||||
addons.sort((a, b) => {
|
||||
if ( a.sort < b.sort ) return -1;
|
||||
if ( b.sort < a.sort ) return 1;
|
||||
|
||||
const a_n = a.name.toLowerCase(),
|
||||
b_n = b.name.toLowerCase();
|
||||
|
||||
if ( a_n < b_n ) return -1;
|
||||
if ( b_n < a_n ) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return addons;
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.item.on(':ready', this.onReady, this);
|
||||
this.item.on(':addon-enabled', this.onEnabled, this);
|
||||
this.item.on(':addon-disabled', this.onDisabled, this);
|
||||
this.item.on(':reload-required', this.onReload, this);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.item.off(':ready', this.onReady, this);
|
||||
this.item.off(':addon-enabled', this.onEnabled, this);
|
||||
this.item.off(':addon-disabled', this.onDisabled, this);
|
||||
this.item.off(':reload-required', this.onReload, this);
|
||||
},
|
||||
|
||||
methods: {
|
||||
shouldShow(addon) {
|
||||
if ( ! this.filter || ! this.filter.length )
|
||||
return true;
|
||||
|
||||
return addon.search_terms.includes(this.filter)
|
||||
},
|
||||
|
||||
onReady() {
|
||||
this.ready = true;
|
||||
|
||||
// Refresh the enabled cache.
|
||||
for(const addon of this.item.getAddons())
|
||||
this.enabled[addon.id] = this.item.isAddonEnabled(addon.id);
|
||||
},
|
||||
|
||||
onEnabled(id) {
|
||||
this.enabled[id] = true;
|
||||
},
|
||||
|
||||
onDisabled(id) {
|
||||
this.enabled[id] = false;
|
||||
},
|
||||
|
||||
onReload() {
|
||||
this.reload = true;
|
||||
},
|
||||
|
||||
openSettings(addon) {
|
||||
let key;
|
||||
if ( typeof addon.settings === 'string' )
|
||||
key = addon.settings;
|
||||
else
|
||||
key = `add_ons.${addon.id}`;
|
||||
|
||||
this.$emit('navigate', key);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -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) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
|
@ -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) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
:context="context"
|
||||
:item="i"
|
||||
:filter="filter"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -140,7 +140,8 @@ export default class RavenLogger extends Module {
|
|||
'Access is denied.',
|
||||
'Zugriff verweigert',
|
||||
'freed script',
|
||||
'ffzenhancing'
|
||||
'ffzenhancing',
|
||||
'dead object'
|
||||
],
|
||||
sanitizeKeys: [
|
||||
/Token$/
|
||||
|
|
|
@ -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 &,
|
||||
|
|
20
src/utilities/addon.js
Normal file
20
src/utilities/addon.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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 = '<div class="ffz-i-zreknarf loader"></div>';
|
||||
content.then(content => {
|
||||
|
|
|
@ -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 )
|
||||
|
|
|
@ -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);
|
||||
}
|
5
styles/widgets/add-ons.scss
Normal file
5
styles/widgets/add-ons.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
.ffz--add-on-info {
|
||||
.ffz-logo-section {
|
||||
max-width: 8rem;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue