diff --git a/.eslintrc.js b/.eslintrc.js index 83360e50..25fd1d7e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,7 @@ module.exports = { '__version_minor__': false, '__version_patch__': false, '__version_prerelease__': false, + '__extension__': false, 'FrankerFaceZ': false }, 'rules': { diff --git a/package.json b/package.json index 421f994b..96d562e3 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.47.1", + "version": "4.48.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/addons.js b/src/addons.js index 8ad6e432..cd22f3c3 100644 --- a/src/addons.js +++ b/src/addons.js @@ -5,7 +5,7 @@ // ============================================================================ import Module from 'utilities/module'; -import { SERVER } from 'utilities/constants'; +import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants'; import { createElement } from 'utilities/dom'; import { timeout, has } from 'utilities/object'; import { getBuster } from 'utilities/time'; @@ -69,15 +69,16 @@ export default class AddonManager extends Module { 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' - } - }); + if ( ! EXTENSION ) + 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); @@ -151,9 +152,13 @@ export default class AddonManager extends Module { async loadAddonData() { const [cdn_data, local_data] = await Promise.all([ - fetchJSON(`${SERVER}/script/addons.json?_=${getBuster(30)}`), - this.settings.get('addons.dev.server') ? - fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) : null + fetchJSON(`${SERVER_OR_EXT}/addons.json?_=${getBuster(30)}`), + + // Do not attempt to load local add-ons if using the extension, as + // loading external code is against the policy of basically everyone. + (! EXTENSION && this.settings.get('addons.dev.server')) + ? fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) + : null ]); if ( Array.isArray(cdn_data) ) @@ -469,7 +474,7 @@ export default class AddonManager extends Module { document.head.appendChild(createElement('script', { id: `ffz-loaded-addon-${addon.id}`, type: 'text/javascript', - src: addon.src || `${addon.dev ? 'https://localhost:8001' : SERVER}/script/addons/${addon.id}/script.js?_=${getBuster(30)}`, + src: addon.src || `${addon.dev ? 'https://localhost:8001/script' : SERVER_OR_EXT}/addons/${addon.id}/script.js?_=${getBuster(30)}`, crossorigin: 'anonymous' })); diff --git a/src/entry_ext.js b/src/entry_ext.js new file mode 100644 index 00000000..6a9d9f57 --- /dev/null +++ b/src/entry_ext.js @@ -0,0 +1,25 @@ +/* eslint strict: off */ +'use strict'; +(() => { + // Don't run on certain sub-domains. + if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) ) + return; + + const HOST = location.hostname, + SERVER = __EXTENSION_PATH__, + script = document.createElement('script'); + + let FLAVOR = + HOST.includes('player') ? 'player' : + HOST.includes('clips') ? 'clips' : + (location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon'); + + if (FLAVOR === 'clips' && location.pathname === '/embed') + FLAVOR = 'player'; + + script.id = 'ffz-script'; + script.async = true; + script.crossOrigin = 'anonymous'; + script.src = `${SERVER}/${FLAVOR}.js?_=${Date.now()}`; + document.head.appendChild(script); +})(); diff --git a/src/load_tracker.jsx b/src/load_tracker.jsx index 9bcdd419..cd68089f 100644 --- a/src/load_tracker.jsx +++ b/src/load_tracker.jsx @@ -67,9 +67,11 @@ export default class LoadTracker extends Module { data.success = true; if ( ! data.pending.size ) { - this.log.debug('complete', type, Object.keys(data.timers)); + const keys = Object.keys(data.timers); + + this.log.debug('complete', type, keys); if ( data.success ) - this.emit(`:complete:${type}`); + this.emit(`:complete:${type}`, keys); this.pending_loads.delete(type); } } diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 5b5dc397..98635679 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -1271,7 +1271,7 @@ export default class Chat extends Module { this.on('chat:get-tab-commands', event => { event.commands.push({ - name: 'ffz reload', + name: 'ffz:reload', description: this.i18n.t('chat.command.reload', 'Reload FFZ and add-on chat data (emotes, badges, etc.)'), permissionLevel: 0, ffz_group: 'FrankerFaceZ' @@ -1292,11 +1292,11 @@ export default class Chat extends Module { this.emit('chat:reload-data'); }); - this.on('load_tracker:complete:chat-data', () => { + this.on('load_tracker:complete:chat-data', (list) => { if ( this.triggered_reload ) { const sc = this.resolve('site.chat'); if ( sc?.addNotice ) - sc.addNotice('*', this.i18n.t('chat.command.reload.done', 'FFZ has finished reloading data.')); + sc.addNotice('*', this.i18n.t('chat.command.reload.done', 'FFZ has finished reloading data. (Sources: {list})', {list: list.join(', ')})); } this.triggered_reload = false; diff --git a/src/modules/chat/tokenizers.jsx b/src/modules/chat/tokenizers.jsx index 2e627df1..a6d747d2 100644 --- a/src/modules/chat/tokenizers.jsx +++ b/src/modules/chat/tokenizers.jsx @@ -22,7 +22,7 @@ const SHRINK_X = MODIFIER_FLAGS.ShrinkX, const EMOTE_CLASS = 'chat-image chat-line__message--emote', //WHITESPACE = /^\s*$/, //LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g, - NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*[^\.!,?])?))/g, + NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*[^\s\.!,?])?))/g, //OLD_NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g, //MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index 32997324..f40307b1 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -361,6 +361,12 @@ export default class Metadata extends Module { } } + // Get the video element. + const video = maybe_call(player.getHTMLVideoElement, player); + stats.avOffset = 0; + if ( video?._ffz_context ) + stats.avOffset = (video._ffz_context_offset ?? 0) + video._ffz_context.currentTime - video.currentTime; + let tampered = false; try { const url = player.core.state.path; @@ -493,6 +499,14 @@ export default class Metadata extends Module { stats ); + const desync = data.avOffset !== 0 + ? (
{this.i18n.t( + 'metadata.player-stats.av-offset', + 'A/V Offset: {avOffset, number} seconds', + stats + )}
) + : null; + if ( data.old ) return [ delayed, @@ -510,6 +524,7 @@ export default class Metadata extends Module {
{video_info}
, + desync, tampered ]; @@ -522,6 +537,7 @@ export default class Metadata extends Module {
{video_info}
, + desync, tampered ]; } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 97ed2f77..a3b54e20 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -2227,7 +2227,7 @@ export default class ChatHook extends Module { inst.sendMessage = function(msg, extra) { msg = msg.replace(/\s+/g, ' '); - if ( msg.startsWith('/ffz') ) { + if ( msg.startsWith('/ffz:') ) { msg = msg.slice(5).trim(); const idx = msg.indexOf(' '); let subcmd; @@ -2258,7 +2258,7 @@ export default class ChatHook extends Module { else inst.addMessage({ type: t.chat_types.Notice, - message: t.i18n.t('chat.ffz-command.invalid', 'No such command: /ffz {subcmd}', {subcmd}) + message: t.i18n.t('chat.ffz-command.invalid', 'No such command: /ffz:{subcmd}', {subcmd}) }); return false; diff --git a/src/sites/twitch-twilight/modules/chat/input.jsx b/src/sites/twitch-twilight/modules/chat/input.jsx index 3d4ae186..3101867d 100644 --- a/src/sites/twitch-twilight/modules/chat/input.jsx +++ b/src/sites/twitch-twilight/modules/chat/input.jsx @@ -402,13 +402,24 @@ export default class Input extends Module { } checkForPreviews(inst, node) { - for(const el of node.querySelectorAll?.('span[data-a-target="chat-input-emote-preview"][aria-describedby]') ?? []) { + // We can't find the tooltip element directly (without digging into React tree at least) + // So instead just find the relevant images in the document. This shouldn't happen TOO + // frequently, with any luck, so the performance impact should be small. + if ( node.querySelector?.('span[data-a-target="chat-input-emote-preview"]') ) { + for(const target of document.querySelectorAll('.tw-tooltip-layer img.chat-line__message--emote')) { + if ( target && target.src.startsWith('https://static-cdn.jtvnw.net/emoticons/v2/__FFZ__') ) + this.updatePreview(inst, target); + } + } + + // This no longer works because they removed aria-describedby + /*for(const el of node.querySelectorAll?.('span[data-a-target="chat-input-emote-preview"][aria-describedby]') ?? []) { const cont = document.getElementById(el.getAttribute('aria-describedby')), target = cont && cont.querySelector('img.chat-line__message--emote'); if ( target && target.src.startsWith('https://static-cdn.jtvnw.net/emoticons/v2/__FFZ__') ) this.updatePreview(inst, target); - } + }*/ for(const target of node.querySelectorAll?.('img.chat-line__message--emote')) { if ( target && (target.dataset.ffzId || target.src.startsWith('https://static-cdn.jtvnw.net/emoticons/v2/__FFZ__')) ) diff --git a/src/utilities/constants.js b/src/utilities/constants.js index 3fd50c6a..49c33ea7 100644 --- a/src/utilities/constants.js +++ b/src/utilities/constants.js @@ -4,7 +4,11 @@ import {make_enum} from 'utilities/object'; export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev'); +export const EXTENSION = document.body.classList.contains('ffz-ext') && !!__extension__; export const SERVER = DEBUG ? '//localhost:8000' : 'https://cdn.frankerfacez.com'; +export const SERVER_OR_EXT = EXTENSION + ? __extension__ + : `${SERVER}/script`; export const CLIENT_ID = 'a3bc9znoz6vi8ozsoca0inlcr4fcvkl'; export const API_SERVER = '//api.frankerfacez.com'; diff --git a/webpack.common.js b/webpack.common.js index 7699e65f..e0a13fac 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -8,6 +8,7 @@ const VueLoaderPlugin = require('vue-loader/lib/plugin'); const VERSION = semver.parse(require('./package.json').version); const PRODUCTION = process.env.NODE_ENV === 'production'; +const FOR_EXTENSION = !! process.env.FFZ_EXTENSION; const ENTRY_POINTS = { bridge: './src/bridge.js', @@ -36,7 +37,9 @@ module.exports = { } ], output: { - chunkFilename: '[name].[chunkhash].js', + chunkFilename: FOR_EXTENSION + ? '[name].js' + : '[name].[chunkhash].js', path: path.resolve(__dirname, 'dist'), jsonpFunction: 'ffzWebpackJsonp', crossOriginLoading: 'anonymous' @@ -58,7 +61,10 @@ module.exports = { __version_major__: VERSION.major, __version_minor__: VERSION.minor, __version_patch__: VERSION.patch, - __version_prerelease__: VERSION.prerelease + __version_prerelease__: VERSION.prerelease, + __extension__: FOR_EXTENSION + ? JSON.stringify(process.env.FFZ_EXTENSION) + : false }), ], module: { @@ -67,7 +73,9 @@ module.exports = { use: [{ loader: 'file-loader', options: { - name: PRODUCTION ? '[name].[hash].css' : '[name].css' + name: (! FOR_EXTENSION && PRODUCTION) + ? '[name].[hash].css' + : '[name].css' } }, { loader: 'extract-loader' @@ -89,7 +97,9 @@ module.exports = { type: 'javascript/auto', loader: 'file-loader', options: { - name: PRODUCTION ? '[name].[hash].json' : '[name].json' + name: (! FOR_EXTENSION && PRODUCTION) + ? '[name].[hash].json' + : '[name].json' } }, { @@ -123,7 +133,9 @@ module.exports = { use: [{ loader: 'file-loader', options: { - name: PRODUCTION ? '[name].[hash].[ext]' : '[name].[ext]' + name: (! FOR_EXTENSION && PRODUCTION) + ? '[name].[hash].[ext]' + : '[name].[ext]' } }] }, diff --git a/webpack.web.prod.js b/webpack.web.prod.js index a442395a..f156920e 100644 --- a/webpack.web.prod.js +++ b/webpack.web.prod.js @@ -12,13 +12,16 @@ const Terser = require('terser'); // Get Git info const commit_hash = require('child_process').execSync('git rev-parse HEAD').toString().trim(); +const FOR_EXTENSION = !! process.env.FFZ_EXTENSION; /* global module Buffer */ const minifier = content => { - const text = content.toString('utf8'); + let text = content.toString('utf8'); + if ( FOR_EXTENSION ) + text = text.replace('__EXTENSION_PATH__', JSON.stringify(process.env.FFZ_EXTENSION)); const minified = Terser.minify(text); - return (minified && minified.code) ? Buffer.from(minified.code) : content; + return (minified && minified.code) ? Buffer.from(minified.code) : Buffer.from(text); }; module.exports = merge(common, { @@ -45,7 +48,9 @@ module.exports = merge(common, { }), new CopyPlugin([ { - from: './src/entry.js', + from: FOR_EXTENSION + ? './src/entry_ext.js' + : './src/entry.js', to: 'script.min.js', transform: minifier } @@ -62,7 +67,11 @@ module.exports = merge(common, { ], output: { - publicPath: '//cdn.frankerfacez.com/static/', - filename: '[name].[hash].js' + publicPath: FOR_EXTENSION + ? process.env.FFZ_EXTENSION + : '//cdn.frankerfacez.com/static/', + filename: FOR_EXTENSION + ? '[name].js' + : '[name].[hash].js' } }); \ No newline at end of file