// ==UserScript==
// @name FrankerFaceZ
// @namespace FrankerFaceZ
//
// @version 1.59.1
//
// @description FrankerFaceZ gives Twitch users custom chat emotes and introduces new features to improve the viewing experience.
// @homepageURL https://www.frankerfacez.com/
// @icon https://cdn.frankerfacez.com/script/icon32.png
// @icon64 https://cdn.frankerfacez.com/script/icon64.png
//
// @include http://twitch.tv/*
// @include https://twitch.tv/*
// @include http://*.twitch.tv/*
// @include https://*.twitch.tv/*
//
// @exclude http://api.twitch.tv/*
// @exclude https://api.twitch.tv/*
//
// @grant unsafeWindow
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.getValues
// @grant GM.deleteValue
// @grant GM.deleteValues
// @grant GM.listValues
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @run-at document-end
// ==/UserScript==
function ffz_provider_init() {
try {
if (typeof GM.listValues !== 'function')
return;
} catch(err) {
console.warn('FFZ: Unable to access user-script storage API. Settings provider will not be registered.');
return;
}
let providers;
try {
providers = unsafeWindow.ffz_providers = unsafeWindow.ffz_providers || [];
} catch(err) {
console.warn('FFZ: Unable to access unsafeWindow. Settings provider will not be registered.');
return;
}
providers.push(evt => {
class UserScriptProvider extends evt.Provider {
static priority = 20;
static title = 'User-Script Storage';
static description = 'User-script managers provider a mechanism for user-scripts to store data.';
static supported() {
return true;
}
static crossOrigin() {
return true;
}
static hasContent() {
const IGNORE_CONTENT_KEYS = evt.IGNORE_CONTENT_KEYS || [];
return GM.listValues().then(arr => Array.isArray(arr) && arr.filter(x => x !== '--sync--' && ! IGNORE_CONTENT_KEYS.includes(x)).length > 0);
}
constructor(manager) {
super(manager);
this._cached = new Map;
this.loadAllValues();
this._boundHandleMessage = this.handleMessage.bind(this);
this._handler_id = GM_addValueChangeListener('--sync--', this._boundHandleMessage);
}
broadcastTransfer() {
this.broadcast({type: 'change-provider'});
}
removeListeners() {
if ( this._handler_id != null ) {
GM_removeValueChangeListener(this._handler_id);
this._boundHandleMessage = this._handler_id = null;
}
}
disableEvents() {
this.removeListeners();
this.broadcast = () => {};
this.emit = () => {};
}
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.removeListeners();
this.disabled = true;
}
flush() { /* no-op */ }
broadcast(msg) {
if ( this._handler_id != null )
GM.setValue('--sync--', {...msg, t: Date.now()});
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
else if ( ! this._ready_promise )
this._ready_promise = new Promise(resolve => {
this._resolve_ready = resolve;
});
return this._ready_promise;
}
async loadAllValues() {
const keys = await GM.listValues();
const stuff = await GM.getValues(keys);
for(const [key,val] of Object.entries(stuff)) {
if (key !== '--sync--')
this._cached.set(key, val);
}
this.ready = true;
if ( this._resolve_ready ) {
this._resolve_ready();
this._resolve_ready = null;
}
}
async handleMessage(k, old, event, remote) {
if ( this.disabled || ! event || ! remote )
return;
const {type, key} = event;
this.manager.log.debug('storage broadcast event', type, key);
if ( type === 'change-provider') {
this.manager.log.info('Received notice of changed settings provider.');
this.emit('change-provider');
this.disable();
this.disableEvents();
} else if ( type === 'set' ) {
const val = await GM.getValue(key);
this._cached.set(key, val);
this.emit('changed', key, val, false);
} else if ( type === 'delete' ) {
this._cached.delete(key);
this.emit('changed', key, undefined, true);
} else if ( type === 'clear' ) {
const old_keys = Array.from(this._cached.keys());
this._cached.clear();
for(const key of old_keys)
this.emit('changed', key, undefined, true);
}
}
get(key, default_value) {
return this._cached.has(key) ? this._cached.get(key) : default_value;
}
set(key, value) {
if ( this.disabled )
return;
if ( value === undefined ) {
if ( this.has(key) )
this.delete(key);
return;
}
this._cached.set(key, value);
GM.setValue(key, value)
.then(() => this.broadcast({type: 'set', key}))
.catch(err => {
if ( this.manager )
this.manager.log.error(`An error occurred while trying to save a value to user-script storage for key "${key}"`);
});
this.emit('set', key, value, false);
}
delete(key) {
if ( this.disabled )
return;
this._cached.delete(key);
GM.deleteValue(key)
.then(() => this.broadcast({type: 'delete', key}));
this.emit('set', key, undefined, true);
}
has(key) {
return this._cached.has(key);
}
keys() {
return this._cached.keys();
}
clear() {
if ( this.disabled )
return;
const old_cache = this._cached;
this._cached = new Map;
for(const key of old_cache.keys()) {
GM.deleteValue(key);
this.emit('changed', key, undefined, true);
}
this.broadcast({type: 'clear'});
}
entries() {
return this._cached.entries();
}
get size() {
return this._cached.size;
}
}
evt.registerProvider('userscript', UserScriptProvider);
});
}
async function ffz_init() {
const script = document.createElement('script');
script.id = 'ffz_script';
script.type = 'text/javascript';
script.src = `//cdn2.frankerfacez.com/script/script.min.js?_=${Date.now()}`;
if ( localStorage.ffzDebugMode == 'true' ) {
// Developer Mode is enabled. But is the server running? Check before
// we include the script, otherwise someone could break their
// experience and not be able to recover.
let resp;
try {
resp = await fetch('//localhost:8000/dev_server').then(r => r.ok ? r.json() : null).catch(() => null);
} catch(err) { resp = null; }
if ( resp ) {
console.log(`FFZ: Development Server is present. Version ${resp.version} running from: ${resp.path}`);
script.src = `//localhost:8000/script/script.js?_=${Date.now()}`;
document.body.classList.add('ffz-dev');
} else
console.log('FFZ: Development Server is not present. Using CDN.');
}
ffz_provider_init();
document.head.appendChild(script);
}
async function ffz_extension_check() {
try {
const ffz = unsafeWindow.ffz;
const FFZ = unsafeWindow.FrankerFaceZ;
if ( ! ffz || ! FFZ?.utilities?.constants?.EXTENSION )
return;
const provider = await ffz.resolve('settings').awaitProvider();
const last = provider.get('us-extension-warning', 0);
if ( last && Date.now() - last < 1000 * 60 * 60 * 24 * 30 )
return; // Don't show the warning more than once a month.
provider.set('us-extension-warning', Date.now());
ffz.resolve('site.menu_button').addToast({
icon: 'ffz-i-zreknarf',
title: 'User-Script Conflict',
title_i18n: 'user-script.conflict.title',
text: 'You have both the FrankerFaceZ browser extension and user-script installed. You should disable the browser extension to avoid conflicts and ensure you always receive the latest version of FFZ.',
text_i18n: 'user-script.conflict.text',
});
} catch(err) {
console.error(err);
/* no-op */
}
}
ffz_init();
setTimeout(ffz_extension_check, 5000);