diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js
index b8701653..807485e1 100644
--- a/src/sites/twitch-twilight/index.js
+++ b/src/sites/twitch-twilight/index.js
@@ -7,6 +7,7 @@
import BaseSite from '../base';
import WebMunch from 'utilities/compat/webmunch';
+import Elemental from 'utilities/compat/elemental';
import Fine from 'utilities/compat/fine';
import FineRouter from 'utilities/compat/fine-router';
import Apollo from 'utilities/compat/apollo';
@@ -30,6 +31,7 @@ export default class Twilight extends BaseSite {
this.inject(WebMunch);
this.inject(Fine);
+ this.inject(Elemental);
this.inject('router', FineRouter);
this.inject(Apollo, false);
this.inject(TwitchData);
@@ -91,6 +93,7 @@ export default class Twilight extends BaseSite {
this.router.on(':route', (route, match) => {
this.log.info('Navigation', route && route.name, match && match[0]);
this.fine.route(route && route.name);
+ this.elemental.route(route && route.name);
this.settings.updateContext({
route,
route_data: match
@@ -99,6 +102,7 @@ export default class Twilight extends BaseSite {
const current = this.router.current;
this.fine.route(current && current.name);
+ this.elemental.route(current && current.name);
this.settings.updateContext({
route: current,
route_data: this.router.match
@@ -193,6 +197,12 @@ Twilight.KNOWN_MODULES = {
}
+Twilight.POPOUT_ROUTES = [
+ 'embed-chat',
+ 'popout'
+];
+
+
Twilight.CHAT_ROUTES = [
'collection',
'popout',
diff --git a/src/sites/twitch-twilight/modules/channel.js b/src/sites/twitch-twilight/modules/channel.js
index b7e9d192..7f9a6011 100644
--- a/src/sites/twitch-twilight/modules/channel.js
+++ b/src/sites/twitch-twilight/modules/channel.js
@@ -5,79 +5,188 @@
// ============================================================================
import Module from 'utilities/module';
-import { get, has } from 'utilities/object';
+import { Color } from 'utilities/color';
+import {debounce} from 'utilities/object';
-import Twilight from 'site';
-import { Color } from 'src/utilities/color';
+const USER_PAGES = ['user', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'];
export default class Channel extends Module {
+
constructor(...args) {
super(...args);
this.should_enable = true;
+ this.inject('i18n');
this.inject('settings');
- this.inject('site.fine');
this.inject('site.css_tweaks');
+ this.inject('site.fine');
+ this.inject('site.elemental');
+ this.inject('site.twitch_data');
+ this.inject('metadata');
+ this.inject('socket');
- this.joined_raids = new Set;
-
- this.settings.add('channel.hosting.enable', {
- default: true,
- ui: {
- path: 'Channel > Behavior >> Hosting',
- title: 'Enable Channel Hosting',
- component: 'setting-check-box'
- },
- changed: val => this.updateChannelHosting(val)
- });
-
-
- this.settings.add('channel.raids.no-autojoin', {
- default: false,
- ui: {
- path: 'Channel > Behavior >> Raids',
- title: 'Do not automatically join raids.',
- component: 'setting-check-box'
- }
- });
-
- /*this.settings.add('channel.squads.no-autojoin', {
- default: false,
- ui: {
- path: 'Channel > Behavior >> Squads',
- title: 'Do not automatically redirect to Squad Streams.',
- component: 'setting-check-box'
- }
- });*/
-
-
- this.ChannelPage = this.fine.define(
- 'channel-page',
- n => (n.updateHost && n.updateChannel && n.state && has(n.state, 'hostedChannel')) || (n.getHostedChannelLogin && n.handleHostingChange) || (n.onChatHostingChange && n.state && has(n.state, 'hostMode')),
- ['user', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following', 'mod-view']
+ this.ChannelRoot = this.elemental.define(
+ 'channel-root', '.channel-root',
+ USER_PAGES,
+ {attributes: true}, 1
);
- this.RaidController = this.fine.define(
- 'raid-controller',
- n => n.handleLeaveRaid && n.handleJoinRaid,
- Twilight.CHAT_ROUTES
+ this.InfoBar = this.elemental.define(
+ 'channel-info-bar', '.channel-info-content',
+ USER_PAGES,
+ {childNodes: true, subtree: true}, 1
);
-
- this.ChannelContext = this.fine.define(
- 'channel-context',
- n => n.resetPrivateVariables && n.fetchChannel && n.clearBroadcastSettingsUpdateInterval,
- ['popout', 'embed-chat']
- );
-
- /*this.SquadController = this.fine.define(
- 'squad-controller',
- n => n.onSquadPage && n.isValidSquad && n.handleLeaveSquad,
- Twilight.CHAT_ROUTES
- );*/
}
+ onEnable() {
+ this.updateChannelColor();
+
+ this.ChannelRoot.on('mount', this.updateRoot, this);
+ this.ChannelRoot.on('mutate', this.updateRoot, this);
+ this.ChannelRoot.on('unmount', this.removeRoot, this);
+ this.ChannelRoot.each(el => this.updateRoot(el));
+
+ this.InfoBar.on('mount', this.updateBar, this);
+ this.InfoBar.on('mutate', this.updateBar, this);
+ this.InfoBar.on('unmount', this.removeBar, this);
+ this.InfoBar.each(el => this.updateBar(el));
+ }
+
+ updateSubscription(login) {
+ if ( this._subbed_login === login )
+ return;
+
+ if ( this._subbed_login ) {
+ this.socket.unsubscribe(this, `channel.${this._subbed_login}`);
+ this._subbed_login = null;
+ }
+
+ if ( login ) {
+ this.socket.subscribe(this, `channel.${login}`);
+ this._subbed_login = login;
+ }
+ }
+
+ updateBar(el) {
+ // TODO: Run a data check to abort early if nothing has changed before updating metadata
+ // thus avoiding a potential loop from mutations.
+ if ( ! el._ffz_update )
+ el._ffz_update = debounce(() => requestAnimationFrame(() => this._updateBar(el)), 1000, 2);
+
+ el._ffz_update();
+ }
+
+ _updateBar(el) {
+ if ( el._ffz_cont && ! el.contains(el._ffz_cont) ) {
+ el._ffz_cont.classList.remove('ffz--meta-tray');
+ el._ffz_cont = null;
+ }
+
+ if ( ! el._ffz_cont ) {
+ const report = el.querySelector('.report-button'),
+ cont = report && report.closest('.tw-flex-wrap.tw-justify-content-end');
+
+ if ( cont && el.contains(cont) ) {
+ el._ffz_cont = cont;
+ cont.classList.add('ffz--meta-tray');
+
+ } else
+ el._ffz_cont = null;
+ }
+
+ const react = this.fine.getReactInstance(el),
+ props = react?.memoizedProps?.children?.props;
+
+ if ( ! el._ffz_cont || ! props?.channelID ) {
+ this.updateSubscription(null);
+ return;
+ }
+
+ this.updateSubscription(props.channelLogin);
+ this.updateMetadata(el);
+ }
+
+ removeBar(el) {
+ this.updateSubscription(null);
+
+ if ( el._ffz_cont )
+ el._ffz_cont.classList.remove('ffz--meta-tray');
+
+ el._ffz_cont = null;
+ if ( el._ffz_meta_timers ) {
+ for(const val of Object.values(el._ffz_meta_timers))
+ clearTimeout(val);
+
+ el._ffz_meta_timers = null;
+ }
+
+ el._ffz_update = null;
+ }
+
+ updateMetadata(el, keys) {
+ const cont = el._ffz_cont,
+ react = this.fine.getReactInstance(el),
+ props = react?.memoizedProps?.children?.props;
+
+ if ( ! cont || ! el.contains(cont) || ! props || ! props.channelID )
+ return;
+
+ if ( ! keys )
+ keys = this.metadata.keys;
+ else if ( ! Array.isArray(keys) )
+ keys = [keys];
+
+ const timers = el._ffz_meta_timers = el._ffz_meta_timers || {},
+ refresh_fn = key => this.updateMetadata(el, key),
+ data = {
+ channel: {
+ id: props.channelID,
+ login: props.channelLogin,
+ display_name: props.displayName,
+ live: props.isLive,
+ live_since: props.liveSince
+ },
+ props,
+ hosted: {
+ login: props.hostLogin,
+ display_name: props.hostDisplayName
+ },
+ el,
+ getBroadcastID: () => this.getBroadcastID(el, props.channelID)
+ };
+
+ for(const key of keys)
+ this.metadata.renderLegacy(key, data, cont, timers, refresh_fn);
+ }
+
+
+ updateRoot(el) {
+ const root = this.fine.getReactInstance(el),
+ channel = root?.return?.memoizedState?.next?.memoizedState?.current?.previousData?.result?.data?.user;
+
+ if ( channel && channel.id ) {
+ this.updateChannelColor(channel.primaryColorHex);
+
+ this.settings.updateContext({
+ channel: channel.login,
+ channelID: channel.id,
+ channelColor: channel.primaryColorHex
+ });
+
+ } else
+ this.removeRoot();
+ }
+
+ removeRoot() {
+ this.updateChannelColor();
+ this.settings.updateContext({
+ channel: null,
+ channelID: null,
+ channelColor: null
+ });
+ }
updateChannelColor(color) {
let parsed = color && Color.RGBA.fromHex(color);
@@ -95,343 +204,49 @@ export default class Channel extends Module {
}
}
-
- onEnable() {
- this.updateChannelColor();
-
- this.ChannelPage.on('mount', this.wrapChannelPage, this);
- this.RaidController.on('mount', this.wrapRaidController, this);
- this.RaidController.on('update', this.noAutoRaids, this);
-
- //this.SquadController.on('mount', this.noAutoSquads, this);
- //this.SquadController.on('update', this.noAutoSquads, this);
-
- this.ChannelContext.on('mount', this.onChannelContext, this);
- this.ChannelContext.on('update', this.onChannelContext, this);
- this.ChannelContext.on('unmount', this.offChannelContext, this);
- this.ChannelContext.ready((cls, instances) => {
- for(const inst of instances)
- this.onChannelContext(inst);
- });
-
- this.RaidController.ready((cls, instances) => {
- for(const inst of instances)
- this.wrapRaidController(inst);
- });
-
- this.ChannelPage.on('mount', this.onChannelMounted, this);
-
- this.ChannelPage.on('unmount', () => {
- this.updateChannelColor(null);
-
- this.settings.updateContext({
- channel: null,
- channelID: null,
- channelColor: null,
- category: null,
- categoryID: null,
- title: null
- });
- });
-
- this.ChannelPage.on('update', inst => {
- const category = get('state.video.game', inst) || get('state.clip.game', inst) || get('state.channel.stream.game', inst) || get('state.channel.broadcastSettings.game', inst),
- title = get('state.video.title', inst) || get('state.clip.title', inst) || get('state.channel.stream.title', inst) || get('state.channel.broadcastSettings.title', inst);
-
- const color = get('state.primaryColorHex', inst);
- this.updateChannelColor(color);
-
- this.settings.updateContext({
- channel: get('state.channel.login', inst),
- channelID: get('state.channel.id', inst),
- channelColor: color,
- category: category?.name,
- categoryID: category?.id,
- title
- });
-
- if ( this.settings.get('channel.hosting.enable') || has(inst.state, 'hostMode') || has(inst.state, 'hostedChannel') )
- return;
-
- // We can't do this immediately because the player state
- // occasionally screws up if we do.
- setTimeout(() => {
- const current_channel = inst.props.data && inst.props.data.variables && inst.props.data.variables.currentChannelLogin;
- if ( current_channel && current_channel !== inst.state.videoPlayerSource ) {
- inst.ffzExpectedHost = inst.state.videoPlayerSource;
- inst.ffzOldHostHandler(null);
- }
- });
- });
-
- this.ChannelPage.ready((cls, instances) => {
- for(const inst of instances)
- this.onChannelMounted(inst);
- });
- }
-
-
- onChannelContext(inst) {
- if ( ! inst.state || inst.state.loading )
- return;
-
- const channel = inst.state.channel,
- clip = inst.state.clip,
- video = inst.state.video,
-
- category = video?.game || clip?.game || channel?.stream?.game || channel?.broadcastSettings?.game,
- title = video?.title || clip?.title || channel?.stream?.title || channel?.broadcastSettings?.title || null;
-
- const color = inst.state?.primaryColorHex;
- this.updateChannelColor(color);
-
- this.settings.updateContext({
- channel: inst.state.channel?.login,
- channelID: inst.state.channel?.id,
- channelColor: color,
- category: category?.name,
- categoryID: category?.id,
- title
- });
- }
-
- offChannelContext() {
- this.updateChannelColor(null);
- this.settings.updateContext({
- channel: null,
- channelID: null,
- category: null,
- categoryID: null,
- channelColor: null,
- title: null
- });
- }
-
-
- onChannelMounted(inst) {
- this.wrapChannelPage(inst);
-
- const category = get('state.video.game', inst) || get('state.clip.game', inst) || get('state.channel.stream.game', inst) || get('state.channel.broadcastSettings.game', inst),
- title = get('state.video.title', inst) || get('state.clip.title', inst) || get('state.channel.stream.title', inst) || get('state.channel.broadcastSettings.title', inst) || null;
-
- const color = get('state.primaryColorHex', inst);
- this.updateChannelColor(color);
-
- this.settings.updateContext({
- channel: get('state.channel.login', inst),
- channelID: get('state.channel.id', inst),
- channelColor: color,
- category: category?.name,
- categoryID: category?.id,
- title
- });
- }
-
-
- wrapRaidController(inst) {
- if ( inst._ffz_wrapped )
- return this.noAutoRaids(inst);
-
- inst._ffz_wrapped = true;
-
- const t = this,
- old_handle_join = inst.handleJoinRaid;
-
- inst.handleJoinRaid = function(event, ...args) {
- const raid_id = inst.props && inst.props.raid && inst.props.raid.id;
- if ( event && event.type && raid_id )
- t.joined_raids.add(raid_id);
-
- return old_handle_join.call(this, event, ...args);
+ getBroadcastID(el, channel_id) {
+ const cache = el._ffz_bcast_cache = el._ffz_bcast_cache || {};
+ if ( channel_id === cache.channel_id ) {
+ if ( Date.now() - cache.saved < 60000 )
+ return Promise.resolve(cache.broadcast_id);
}
- this.noAutoRaids(inst);
- }
+ return new Promise(async (s, f) => {
+ if ( cache.updating ) {
+ cache.updating.push([s, f]);
+ return ;
+ }
+ cache.channel_id = channel_id;
+ cache.updating = [[s,f]];
+ let id, err;
- noAutoSquads(inst) {
- if ( this.settings.get('channel.squads.no-autojoin') )
- setTimeout(() => {
- if ( inst.isValidSquad() && inst.state && inst.state.hasJoined ) {
- this.log.info('Automatically opting out of Squad Stream.');
- inst.handleLeaveSquad();
- }
- });
- }
-
-
- noAutoRaids(inst) {
- if ( this.settings.get('channel.raids.no-autojoin') )
- setTimeout(() => {
- if ( inst.props && inst.props.raid && ! inst.isRaidCreator && inst.hasJoinedCurrentRaid ) {
- const id = inst.props.raid.id;
- if ( this.joined_raids.has(id) )
- return;
-
- this.log.info('Automatically leaving raid:', id);
- inst.handleLeaveRaid();
- }
- });
- }
-
-
- wrapChannelPage(inst) {
- if ( inst._ffz_hosting_wrapped )
- return;
-
- const t = this,
- new_new_style = inst.updateChannel && has(inst.state, 'hostedChannel'),
- new_style = ! new_new_style && ! inst.handleHostingChange || has(inst.state, 'hostMode');
-
- inst.ffzGetChannel = () => {
- const params = inst.props.match.params
- if ( ! params )
- return get('props.data.variables.currentChannelLogin', inst)
-
- return params.channelName || params.channelLogin
- }
-
- inst.ffzOldSetState = inst.setState;
- inst.setState = function(state, ...args) {
try {
- if ( new_new_style ) {
- const expected = inst.ffzGetChannel();
- if ( has(state, 'hostedChannel') ) {
- inst.ffzExpectedHost = state.hostedChannel;
- if ( state.hostedChannel && ! t.settings.get('channel.hosting.enable') ) {
- state.hostedChannel = null;
- state.videoPlayerSource = expected;
- }
-
- t.settings.updateContext({hosting: !!state.hostedChannel});
-
- } else if ( has(state, 'videoPlayerSource') ) {
- if ( state.videoPlayerSource !== expected && ! t.settings.get('channel.hosting.enable') ) {
- state.videoPlayerSource = expected;
- }
- }
-
- } else if ( new_style ) {
- const expected = inst.ffzGetChannel();
- if ( has(state, 'hostMode') ) {
- inst.ffzExpectedHost = state.hostMode;
- if ( state.hostMode && ! t.settings.get('channel.hosting.enable') ) {
- state.hostMode = null;
- state.videoPlayerSource = expected;
- }
-
- t.settings.updateContext({hosting: !!state.hostMode});
-
- } else if ( has(state, 'videoPlayerSource') ) {
- if ( state.videoPlayerSource !== expected && ! t.settings.get('channel.hosting.enable') )
- state.videoPlayerSource = expected;
- }
-
- } else {
- if ( ! t.settings.get('channel.hosting.enable') ) {
- if ( has(state, 'isHosting') )
- state.isHosting = false;
-
- if ( has(state, 'videoPlayerSource') )
- state.videoPlayerSource = inst.ffzGetChannel();
- }
-
- if ( has(state, 'isHosting') )
- t.settings.updateContext({hosting: state.isHosting});
- }
-
- } catch(err) {
- t.log.capture(err, {extra: {props: inst.props, state}});
+ id = await this.twitch_data.getBroadcastID(channel_id);
+ } catch(error) {
+ id = null;
+ err = error;
}
- return inst.ffzOldSetState(state, ...args);
- }
+ const waiters = cache.updating;
+ cache.updating = null;
- inst._ffz_hosting_wrapped = true;
+ if ( cache.channel_id !== channel_id ) {
+ err = new Error('Outdated');
+ cache.channel_id = null;
+ cache.broadcast_id = null;
+ cache.saved = 0;
+ for(const pair of waiters)
+ pair[1](err);
- if ( new_new_style ) {
- const hosted = inst.ffzExpectedHost = inst.state.hostedChannel;
- this.settings.updateContext({hosting: this.settings.get('channel.hosting.enable') && !!hosted});
-
- if ( hosted && ! this.settings.get('channel.hosting.enable') ) {
- inst.ffzOldSetState({
- hostedChannel: null,
- videoPlayerSource: inst.ffzGetChannel()
- });
+ return;
}
- } else if ( new_style ) {
- const hosted = inst.ffzExpectedHost = inst.state.hostMode;
- this.settings.updateContext({hosting: this.settings.get('channel.hosting.enable') && !!inst.state.hostMode});
+ cache.broadcast_id = id;
+ cache.saved = Date.now();
- if ( hosted && ! this.settings.get('channel.hosting.enable') ) {
- inst.ffzOldSetState({
- hostMode: null,
- videoPlayerSource: inst.ffzGetChannel()
- });
- }
-
- } else {
- inst.ffzOldGetHostedLogin = () => get('props.data.user.hosting.login', inst) || null;
- inst.getHostedChannelLogin = function() {
- return t.settings.get('channel.hosting.enable') ?
- inst.ffzOldGetHostedLogin() : null;
- }
-
- inst.ffzOldHostHandler = inst.handleHostingChange;
- inst.handleHostingChange = function(channel) {
- inst.ffzExpectedHost = channel;
- if ( t.settings.get('channel.hosting.enable') )
- return inst.ffzOldHostHandler(channel);
- }
-
- // Store the current state and disable the current host if needed.
- inst.ffzExpectedHost = inst.state.isHosting ? inst.state.videoPlayerSource : null;
- this.settings.updateContext({hosting: this.settings.get('channel.hosting.enable') && inst.state.isHosting});
- if ( ! this.settings.get('channel.hosting.enable') ) {
- inst.ffzOldHostHandler(null);
- }
- }
-
- // Finally, we force an update so that any child components
- // receive our updated handler.
- inst.forceUpdate();
- this.emit('site:dom-update', 'channel-page', inst);
- }
-
-
- updateChannelHosting(val) {
- if ( val === undefined )
- val = this.settings.get('channel.hosting.enable');
-
- let hosting = val;
-
- for(const inst of this.ChannelPage.instances) {
- if ( ! inst.ffzExpectedHost )
- hosting = false;
-
- if ( has(inst.state, 'hostedChannel') ) {
- const host = val ? inst.ffzExpectedHost : null,
- target = host && host.login || inst.ffzGetChannel();
-
- inst.ffzOldSetState({
- hostedChannel: host,
- videoPlayerSource: target
- });
-
- } else if ( has(inst.state, 'hostMode') ) {
- const host = val ? inst.ffzExpectedHost : null,
- target = host && host.hostedChannel && host.hostedChannel.login || inst.ffzGetChannel();
-
- inst.ffzOldSetState({
- hostMode: host,
- videoPlayerSource: target
- });
-
- } else
- inst.ffzOldHostHandler(val ? inst.ffzExpectedHost : null);
- }
-
- this.settings.updateContext({hosting});
+ for(const pair of waiters)
+ err ? pair[1](err) : pair[0](id);
+ });
}
}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/channel_bar.jsx b/src/sites/twitch-twilight/modules/channel_bar.jsx
deleted file mode 100644
index 85ce2ef4..00000000
--- a/src/sites/twitch-twilight/modules/channel_bar.jsx
+++ /dev/null
@@ -1,291 +0,0 @@
-'use strict';
-
-// ============================================================================
-// Channel Bar
-// ============================================================================
-
-import Module from 'utilities/module';
-import {get} from 'utilities/object';
-import {createElement} from 'utilities/dom';
-
-//import CHANNEL_QUERY from './channel_header_query.gql';
-
-
-export default class ChannelBar extends Module {
- constructor(...args) {
- super(...args);
-
- this.should_enable = true;
-
- this.inject('i18n');
- this.inject('settings');
- this.inject('site.css_tweaks');
- this.inject('site.fine');
- this.inject('site.web_munch');
- this.inject('site.apollo');
- this.inject('site.twitch_data');
- this.inject('metadata');
- this.inject('socket');
-
- /*this.apollo.registerModifier('ChannelPage_ChannelHeader', CHANNEL_QUERY);
- this.apollo.registerModifier('ChannelPage_ChannelHeader', data => {
- const u = data && data.data && data.data.user;
- if ( u ) {
- const o = u.profileViewCount = new Number(u.profileViewCount || 0);
- o.data = deep_copy(u);
- }
- }, false);*/
-
-
- this.settings.add('channel.metadata.force-above', {
- default: false,
- ui: {
- path: 'Channel > Metadata >> Appearance',
- title: 'Force metadata and tags to the top of the channel information bar.',
- component: 'setting-check-box'
- },
- changed: val => this.css_tweaks.toggle('channel-metadata-top', val)
- });
-
- this.VideoBar = this.fine.define(
- 'video-bar',
- n => n.props && n.props.getLastVideoOffset && n.renderTrackedHighlightButton,
- ['video', 'user-video']
- );
-
- this.ChannelBar = this.fine.define(
- 'channel-bar',
- n => n.getTitle && n.getGame && n.renderGame,
- ['user']
- );
-
- this.ModWidget = this.fine.define(
- 'mod-widget',
- n => n.renderToolbar && n.getToolbarControls && n.childContext,
- ['mod-view']
- );
- }
-
- onEnable() {
- this.css_tweaks.toggle('channel-metadata-top', this.settings.get('channel.metadata.force-above'));
-
- this.on('i18n:update', () => {
- for(const bar of this.VideoBar.instances)
- this.updateVideoBar(bar);
- });
-
- this.ChannelBar.on('unmount', this.unmountChannelBar, this);
- this.ChannelBar.on('mount', this.updateChannelBar, this);
- this.ChannelBar.on('update', this.updateChannelBar, this);
-
- this.ChannelBar.ready((cls, instances) => {
- for(const inst of instances)
- this.updateChannelBar(inst);
- });
-
-
- /*this.ModWidget.on('mount', this.updateModWidget, this);
- this.ModWidget.on('update', this.updateModWidget, this);
-
- this.ModWidget.ready((cls, instances) => {
- for(const inst of instances)
- this.updateModWidget(inst);
- });*/
-
-
- //this.VideoBar.on('unmount', this.unmountVideoBar, this);
- this.VideoBar.on('mount', this.updateVideoBar, this);
- this.VideoBar.on('update', this.updateVideoBar, this);
-
- this.VideoBar.ready((cls, instances) => {
- for(const inst of instances)
- this.updateVideoBar(inst);
- });
-
- }
-
-
- /*updateModWidget(inst) {
- const container = this.fine.getChildNode(inst);
- if ( ! container || ! container.querySelector('.video-player-hosting-ui__container') )
- return;
-
- const header = container.querySelector('.mod-view-panel-header');
- if ( ! header )
- return;
-
- let cont = header.querySelector('.ffz--stat-container');
-
- if ( ! cont ) {
- cont =
;
- const contcont = header.querySelector(':scope > div:first-child > div');
- if ( ! contcont )
- return;
-
- contcont.appendChild(cont);
- }
-
- this.log.info('mod-widget', inst, cont);
- }*/
-
-
- updateVideoBar(inst) {
- const container = this.fine.getChildNode(inst),
- timestamp = container && container.querySelector('[data-test-selector="date"]');
-
- if ( ! timestamp )
- return;
-
- const published = get('props.video.publishedAt', inst);
-
- if ( ! published )
- timestamp.classList.toggle('ffz-tooltip', false);
- else {
- timestamp.classList.toggle('ffz-tooltip', true);
- timestamp.dataset.title = this.i18n.t('video.published-on', 'Published on: {date,date}', {date: published});
- }
- }
-
-
- updateChannelBar(inst) {
- const login = get('props.channel.login', inst);
- if ( login !== inst._ffz_old_login ) {
- if ( inst._ffz_old_login )
- this.socket.unsubscribe(inst, `channel.${inst._ffz_old_login}`);
-
- if ( login )
- this.socket.subscribe(inst, `channel.${login}`);
- inst._ffz_old_login = login;
- }
-
- this.updateUptime(inst);
- this.updateMetadata(inst);
- }
-
- unmountChannelBar(inst) {
- if ( inst._ffz_old_login ) {
- this.socket.unsubscribe(inst, `channel.${inst._ffz_old_login}`);
- inst._ffz_old_login = null;
- }
-
- const timers = inst._ffz_meta_timers;
- if ( timers )
- for(const key in timers)
- if ( timers[key] )
- clearTimeout(timers[key]);
-
- inst._ffz_meta_timers = null;
- }
-
-
- getBroadcastID(inst) {
- const current_id = inst.props?.channel?.stream?.id;
- if ( current_id === inst._ffz_stream_id ) {
- if ( Date.now() - inst._ffz_broadcast_saved < 60000 )
- return Promise.resolve(inst._ffz_broadcast_id);
- }
-
- return new Promise(async (s, f) => {
- if ( inst._ffz_broadcast_updating )
- return inst._ffz_broadcast_updating.push([s, f]);
-
- inst._ffz_broadcast_updating = [[s, f]];
-
- let id, err;
-
- try {
- id = await this.twitch_data.getBroadcastID(inst.props.channel.id);
- } catch(error) {
- id = null;
- err = error;
- }
-
- const waiters = inst._ffz_broadcast_updating;
- inst._ffz_broadcast_updating = null;
-
- if ( current_id !== inst.props?.channel?.stream?.id ) {
- err = new Error('Outdated');
- inst._ffz_stream_id = null;
- inst._ffz_broadcast_saved = 0;
- inst._ffz_broadcast_id = null;
-
- for(const pair of waiters)
- pair[1](err);
-
- return;
- }
-
- inst._ffz_broadcast_id = id;
- inst._ffz_broadcast_saved = Date.now();
- inst._ffz_stream_id = current_id;
-
- if ( err ) {
- for(const pair of waiters)
- pair[1](err);
- } else {
- for(const pair of waiters)
- pair[0](id);
- }
- });
- }
-
-
- async updateUptime(inst) {
- const current_id = inst?.props?.channel?.id;
- if ( current_id === inst._ffz_uptime_id ) {
- if ( Date.now() - inst._ffz_uptime_saved < 60000 )
- return;
- }
-
- if ( inst._ffz_uptime_updating )
- return;
-
- inst._ffz_uptime_updating = true;
- inst._ffz_uptime_id = current_id;
-
- if ( ! current_id )
- inst._ffz_meta = null;
- else {
- try {
- inst._ffz_meta = await this.twitch_data.getStreamMeta(current_id, inst?.props?.channel?.login);
- } catch(err) {
- this.log.capture(err);
- this.log.error('Error fetching uptime:', err);
- inst._ffz_meta = null;
- }
- }
-
- inst._ffz_uptime_saved = Date.now();
- inst._ffz_uptime_updating = false;
-
- this.updateMetadata(inst);
- }
-
-
- updateMetadata(inst, keys) {
- const container = this.fine.getChildNode(inst),
- metabar = container?.querySelector?.('.channel-info-bar__viewers-count-wrapper > .tw-flex:last-child');
-
- if ( ! inst._ffz_mounted || ! metabar )
- return;
-
- if ( ! keys )
- keys = this.metadata.keys;
- else if ( ! Array.isArray(keys) )
- keys = [keys];
-
- const timers = inst._ffz_meta_timers = inst._ffz_meta_timers || {},
- refresh_func = key => this.updateMetadata(inst, key),
- data = {
- channel: inst.props.channel,
- meta: inst._ffz_meta,
- hosting: false,
- legacy: true,
- _inst: inst,
- getBroadcastID: () => this.getBroadcastID(inst)
- }
-
- for(const key of keys)
- this.metadata.renderLegacy(key, data, metabar, timers, refresh_func);
- }
-}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js
index 64999a57..8b8710ab 100644
--- a/src/sites/twitch-twilight/modules/chat/index.js
+++ b/src/sites/twitch-twilight/modules/chat/index.js
@@ -5,8 +5,7 @@
// ============================================================================
import {ColorAdjuster} from 'utilities/color';
-import {setChildren} from 'utilities/dom';
-import {get, has, make_enum, split_chars, shallow_object_equals, set_equals, deep_equals} from 'utilities/object';
+import {get, has, make_enum, shallow_object_equals, set_equals, deep_equals} from 'utilities/object';
import {WEBKIT_CSS as WEBKIT} from 'utilities/constants';
import {FFZEvent} from 'utilities/events';
@@ -22,7 +21,7 @@ import Input from './input';
import ViewerCards from './viewer_card';
-const REGEX_EMOTES = {
+/*const REGEX_EMOTES = {
'B-?\\)': ['B)', 'B-)'],
'R-?\\)': ['R)', 'R-)'],
'[oO](_|\\.)[oO]': ['o_o', 'O_o', 'o_O', 'O_O', 'o.o', 'O.o', 'o.O', 'O.O'],
@@ -42,7 +41,7 @@ const REGEX_EMOTES = {
'\\<\\;\\]': ['<]'],
'\\:-?(S|s)': [':s', ':S', ':-s', ':-S'],
'\\:\\>\\;': [':>']
-};
+};*/
const MESSAGE_TYPES = make_enum(
@@ -190,11 +189,13 @@ export default class ChatHook extends Module {
Twilight.CHAT_ROUTES
);
- /*this.PinnedCheer = this.fine.define(
- 'pinned-cheer',
- n => n.collapseCheer && n.saveRenderedMessageRef,
+ this.joined_raids = new Set;
+
+ this.RaidController = this.fine.define(
+ 'raid-controller',
+ n => n.handleLeaveRaid && n.handleJoinRaid,
Twilight.CHAT_ROUTES
- );*/
+ );
this.InlineCallout = this.fine.define(
'inline-callout',
@@ -246,6 +247,15 @@ export default class ChatHook extends Module {
// Settings
+ this.settings.add('channel.raids.no-autojoin', {
+ default: false,
+ ui: {
+ path: 'Channel > Behavior >> Raids',
+ title: 'Do not automatically join raids.',
+ component: 'setting-check-box'
+ }
+ });
+
this.settings.add('chat.hide-community-highlights', {
default: false,
ui: {
@@ -729,6 +739,13 @@ export default class ChatHook extends Module {
this.updateLineBorders();
this.updateMentionCSS();
+ this.RaidController.on('mount', this.wrapRaidController, this);
+ this.RaidController.on('update', this.noAutoRaids, this);
+ this.RaidController.ready((cls, instances) => {
+ for(const inst of instances)
+ this.wrapRaidController(inst);
+ });
+
this.InlineCallout.on('mount', this.onInlineCallout, this);
this.InlineCallout.on('update', this.onInlineCallout, this);
this.InlineCallout.ready(() => this.updateInlineCallouts());
@@ -1002,7 +1019,7 @@ export default class ChatHook extends Module {
this.ChatContainer.on('mount', this.containerMounted, this);
- this.ChatContainer.on('unmount', this.removeRoom, this);
+ this.ChatContainer.on('unmount', this.containerUnmounted, this); //removeRoom, this);
this.ChatContainer.on('update', this.containerUpdated, this);
this.ChatContainer.ready((cls, instances) => {
@@ -1048,15 +1065,41 @@ export default class ChatHook extends Module {
for(const inst of instances)
this.containerMounted(inst);
});
+ }
- /*this.PinnedCheer.on('mount', this.fixPinnedCheer, this);
- this.PinnedCheer.on('update', this.fixPinnedCheer, this);
+ wrapRaidController(inst) {
+ if ( inst._ffz_wrapped )
+ return this.noAutoRaids(inst);
- this.PinnedCheer.ready((cls, instances) => {
- for(const inst of instances)
- this.fixPinnedCheer(inst);
- });*/
+ inst._ffz_wrapped = true;
+
+ const t = this,
+ old_handle_join = inst.handleJoinRaid;
+
+ inst.handleJoinRaid = function(event, ...args) {
+ const raid_id = inst.props && inst.props.raid && inst.props.raid.id;
+ if ( event && event.type && raid_id )
+ t.joined_raids.add(raid_id);
+
+ return old_handle_join.call(this, event, ...args);
+ }
+
+ this.noAutoRaids(inst);
+ }
+
+ noAutoRaids(inst) {
+ if ( this.settings.get('channel.raids.no-autojoin') )
+ setTimeout(() => {
+ if ( inst.props && inst.props.raid && ! inst.isRaidCreator && inst.hasJoinedCurrentRaid ) {
+ const id = inst.props.raid.id;
+ if ( this.joined_raids.has(id) )
+ return;
+
+ this.log.info('Automatically leaving raid:', id);
+ inst.handleLeaveRaid();
+ }
+ });
}
@@ -1583,30 +1626,6 @@ export default class ChatHook extends Module {
cls.prototype._ffz_was_here = true;
- /*cls.prototype.ffzGetEmotes = function() {
- const emote_map = this.client && this.client.session && this.client.session.emoteMap;
- if ( this._ffz_cached_map === emote_map )
- return this._ffz_cached_emotes;
-
- this._ffz_cached_map = emote_map;
- const emotes = this._ffz_cached_emotes = {};
-
- if ( emote_map )
- for(const emote of Object.values(emote_map))
- if ( emote ) {
- const token = emote.token;
- if ( Array.isArray(REGEX_EMOTES[token]) ) {
- for(const tok of REGEX_EMOTES[token] )
- emotes[tok] = emote.id;
-
- } else
- emotes[token] = emote.id;
- }
-
- return emotes;
- }*/
-
-
cls.prototype._ffzInstall = function() {
if ( this._ffz_installed )
return;
@@ -2029,52 +2048,10 @@ export default class ChatHook extends Module {
updateChatLines() {
- //this.PinnedCheer.forceUpdate();
this.chat_line.updateLines();
}
- // ========================================================================
- // Pinned Cheers
- // ========================================================================
-
- /*fixPinnedCheer(inst) {
- const el = this.fine.getChildNode(inst),
- container = el && el.querySelector && el.querySelector('.pinned-cheer__headline'),
- tc = inst.props.topCheer;
-
- if ( ! container || ! tc )
- return;
-
- container.dataset.roomId = inst.props.channelID;
- container.dataset.room = inst.props.channelLogin && inst.props.channelLogin.toLowerCase();
- container.dataset.userId = tc.user.userID;
- container.dataset.user = tc.user.userLogin && tc.user.userLogin.toLowerCase();
-
- if ( tc.user.color ) {
- const user_el = container.querySelector('.chat-author__display-name');
- if ( user_el )
- user_el.style.color = this.colors.process(tc.user.color);
-
- const login_el = container.querySelector('.chat-author__intl-login');
- if ( login_el )
- login_el.style.color = this.colors.process(tc.user.color);
- }
-
- const bit_el = container.querySelector('.chat-line__message--emote'),
- cont = bit_el ? bit_el.parentElement.parentElement : container.querySelector('.ffz--pinned-top-emote'),
- prefix = extractCheerPrefix(tc.messageParts);
-
- if ( cont && prefix ) {
- const tokens = this.chat.tokenizeString(`${prefix}${tc.bits}`, tc);
-
- cont.classList.add('ffz--pinned-top-emote');
- cont.innerHTML = '';
- setChildren(cont, this.chat.renderTokens(tokens));
- }
- }*/
-
-
// ========================================================================
// Room Handling
// ========================================================================
@@ -2278,6 +2255,17 @@ export default class ChatHook extends Module {
this.updateRoomBitsConfig(cont, props.bitsConfig);
if ( props.data ) {
+ if ( Twilight.POPOUT_ROUTES.includes(this.router.current_name) ) {
+ const color = props.data.user?.primaryColorHex;
+ this.resolve('site.channel').updateChannelColor(color);
+
+ this.settings.updateContext({
+ channel: props.channelLogin,
+ channelID: props.channelID,
+ channelColor: color
+ });
+ }
+
this.chat.badges.updateTwitchBadges(props.data.badges);
this.updateRoomBadges(cont, props.data.user && props.data.user.broadcastBadges);
this.updateRoomRules(cont, props.chatRules);
@@ -2285,6 +2273,21 @@ export default class ChatHook extends Module {
}
+ containerUnmounted(cont) {
+ if ( Twilight.POPOUT_ROUTES.includes(this.router.current_name) ) {
+ this.resolve('site.channel').updateChannelColor();
+
+ this.settings.updateContext({
+ channel: null,
+ channelID: null,
+ channelColor: null
+ });
+ }
+
+ this.removeRoom(cont);
+ }
+
+
containerUpdated(cont, props) {
// If we don't have a room, or if the room ID doesn't match our ID
// then we need to just create a new Room because the chat room changed.
@@ -2298,6 +2301,17 @@ export default class ChatHook extends Module {
if ( props.bitsConfig !== cont.props.bitsConfig )
this.updateRoomBitsConfig(cont, props.bitsConfig);
+ if ( props.data && Twilight.POPOUT_ROUTES.includes(this.router.current_name) ) {
+ const color = props.data.user?.primaryColorHex;
+ this.resolve('site.channel').updateChannelColor(color);
+
+ this.settings.updateContext({
+ channel: props.channelLogin,
+ channelID: props.channelID,
+ channelColor: color
+ });
+ }
+
// Twitch, React, and Apollo are the trifecta of terror so we
// can't compare the badgeSets property in any reasonable way.
// Instead, just check the lengths to see if they've changed
@@ -2421,40 +2435,4 @@ export function formatBitsConfig(config) {
}
return out;
-}
-
-
-/*export function findEmotes(msg, emotes) {
- const out = {};
- let idx = 0;
-
- for(const part of msg.split(' ')) {
- const len = split_chars(part).length;
-
- if ( has(emotes, part) ) {
- const em = emotes[part],
- matches = out[em] = out[em] || [];
-
- matches.push({
- startIndex: idx,
- endIndex: idx + len - 1
- });
- }
-
- idx += len + 1;
- }
-
- return out;
-}*/
-
-
-function extractCheerPrefix(parts) {
- for(const part of parts) {
- if ( part.type !== 3 || ! part.content.cheerAmount )
- continue;
-
- return part.content.alt;
- }
-
- return null;
-}
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/css_tweaks/index.js b/src/sites/twitch-twilight/modules/css_tweaks/index.js
index 93109229..352eac9a 100644
--- a/src/sites/twitch-twilight/modules/css_tweaks/index.js
+++ b/src/sites/twitch-twilight/modules/css_tweaks/index.js
@@ -59,6 +59,21 @@ export default class CSSTweaks extends Module {
// Layout
+ this.settings.add('metadata.uptime.no-native', {
+ requires: ['metadata.uptime'],
+ default: null,
+ process(ctx, val) {
+ return val == null ? ctx.get('metadata.uptime') !== 0 : val
+ },
+ changed: val => this.toggle('hide-native-uptime', val),
+ ui: {
+ path: 'Channel > Metadata >> Player',
+ title: "Hide Twitch's native Stream Uptime.",
+ description: "By default, this is enabled whenever FFZ's own Stream Uptime display is enabled to avoid redundant information.",
+ component: 'setting-check-box'
+ }
+ });
+
this.settings.add('layout.use-chat-fix', {
requires: ['layout.swap-sidebars', 'layout.use-portrait', 'chat.use-width'],
process(ctx) {
@@ -318,6 +333,7 @@ export default class CSSTweaks extends Module {
}
onEnable() {
+ this.toggle('hide-native-uptime', this.settings.get('metadata.uptime.no-native'));
this.toggle('chat-fix', this.settings.get('layout.use-chat-fix'));
this.toggle('swap-sidebars', this.settings.get('layout.swap-sidebars'));
this.toggle('minimal-navigation', this.settings.get('layout.minimal-navigation'));
diff --git a/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-native-uptime.scss b/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-native-uptime.scss
new file mode 100644
index 00000000..f78bda01
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-native-uptime.scss
@@ -0,0 +1,9 @@
+.ffz--meta-tray {
+ .live-time {
+ display: none !important;
+ }
+
+ & > :first-child > :last-child {
+ margin-right: 0 !important;
+ }
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/directory/game.jsx b/src/sites/twitch-twilight/modules/directory/game.jsx
index 9d5210ae..9c4f5718 100644
--- a/src/sites/twitch-twilight/modules/directory/game.jsx
+++ b/src/sites/twitch-twilight/modules/directory/game.jsx
@@ -39,14 +39,14 @@ export default class Game extends SiteModule {
}, false);*/
}
- modifyStreams(res) { // eslint-disable-line class-methods-use-this
+ /*modifyStreams(res) { // eslint-disable-line class-methods-use-this
const edges = get('data.game.streams.edges', res);
if ( ! edges || ! edges.length )
return res;
res.data.game.streams.edges = this.parent.processNodes(edges, true);
return res;
- }
+ }*/
onEnable() {
this.GameHeader.on('mount', this.updateGameHeader, this);
diff --git a/src/sites/twitch-twilight/modules/player.jsx b/src/sites/twitch-twilight/modules/player.jsx
index 1b738bd8..e81c7cd0 100644
--- a/src/sites/twitch-twilight/modules/player.jsx
+++ b/src/sites/twitch-twilight/modules/player.jsx
@@ -54,11 +54,11 @@ export default class Player extends Module {
// React Components
- this.SquadStreamBar = this.fine.define(
+ /*this.SquadStreamBar = this.fine.define(
'squad-stream-bar',
n => n.shouldRenderSquadBanner && n.props && n.props.triggerPlayerReposition,
PLAYER_ROUTES
- );
+ );*/
this.PersistentPlayer = this.fine.define(
'persistent-player',
@@ -502,7 +502,7 @@ export default class Player extends Module {
}
});
- this.settings.add('player.hide-squad-banner', {
+ /*this.settings.add('player.hide-squad-banner', {
default: false,
ui: {
path: 'Channel > Appearance >> General',
@@ -510,7 +510,7 @@ export default class Player extends Module {
component: 'setting-check-box'
},
changed: () => this.SquadStreamBar.forceUpdate()
- });
+ });*/
this.settings.add('player.hide-mouse', {
default: true,
@@ -541,7 +541,7 @@ export default class Player extends Module {
const t = this;
- this.SquadStreamBar.ready(cls => {
+ /*this.SquadStreamBar.ready(cls => {
const old_should_render = cls.prototype.shouldRenderSquadBanner;
cls.prototype.shouldRenderSquadBanner = function(...args) {
@@ -557,7 +557,7 @@ export default class Player extends Module {
this.SquadStreamBar.on('mount', this.updateSquadContext, this);
this.SquadStreamBar.on('update', this.updateSquadContext, this);
- this.SquadStreamBar.on('unmount', this.updateSquadContext, this);
+ this.SquadStreamBar.on('unmount', this.updateSquadContext, this);*/
this.Player.ready((cls, instances) => {
@@ -621,12 +621,18 @@ export default class Player extends Module {
}
cls.prototype.ffzUpdateVolume = function() {
+ if ( document.hidden )
+ return;
+
const player = this.props.mediaPlayerInstance,
video = player?.mediaSinkManager?.video || player?.core?.mediaSinkManager?.video;
if ( video ) {
- const volume = video.volume;
- if ( ! player.isMuted() && ! video.muted && player.getVolume() !== volume )
+ const volume = video.volume,
+ muted = player.isMuted();
+ if ( ! video.muted && player.getVolume() !== volume ) {
player.setVolume(volume);
+ player.setMuted(muted);
+ }
}
}
@@ -755,7 +761,9 @@ export default class Player extends Module {
if ( ! player?.isMuted )
return;
- player.setMuted(! player.isMuted());
+ const muted = ! player.isMuted();
+ player.setMuted(muted);
+ localStorage.setItem('video-muted', JSON.stringify({default: muted}));
event.preventDefault();
return false;
}
@@ -1503,12 +1511,12 @@ export default class Player extends Module {
* @returns {void}
*/
repositionPlayer() {
- for(const inst of this.SquadStreamBar.instances) {
+ /*for(const inst of this.SquadStreamBar.instances) {
if ( inst?.props?.triggerPlayerReposition ) {
inst.props.triggerPlayerReposition();
return;
}
- }
+ }*/
}
updateSquadContext() {
@@ -1518,8 +1526,9 @@ export default class Player extends Module {
}
get hasSquadBar() {
- const inst = this.SquadStreamBar.first;
- return inst ? inst.shouldRenderSquadBanner(inst.props) : false
+ return false;
+ /*const inst = this.SquadStreamBar.first;
+ return inst ? inst.shouldRenderSquadBanner(inst.props) : false*/
}
get playerUI() {
diff --git a/src/sites/twitch-twilight/styles/channel.scss b/src/sites/twitch-twilight/styles/channel.scss
index ea0b0434..58b93957 100644
--- a/src/sites/twitch-twilight/styles/channel.scss
+++ b/src/sites/twitch-twilight/styles/channel.scss
@@ -41,11 +41,26 @@
}
}
+.ffz-stat-text {
+ font-size: 1.2rem;
+ font-variant-numeric: tabular-nums;
+}
+
.ffz-stat--fix-padding {
margin-top: -.7rem !important;
margin-bottom: -.7rem !important;
}
+.ffz--meta-tray {
+ & > :first-child {
+ order: 1;
+ }
+
+ & > :nth-child(0n+2) {
+ order: 500;
+ }
+}
+
.ffz-stat {
font-variant-numeric: tabular-nums;
}
diff --git a/src/utilities/compat/elemental.js b/src/utilities/compat/elemental.js
new file mode 100644
index 00000000..f21f2ab1
--- /dev/null
+++ b/src/utilities/compat/elemental.js
@@ -0,0 +1,302 @@
+'use strict';
+
+// ============================================================================
+// Elemental
+// It finds elements.
+// ============================================================================
+
+import {EventEmitter} from 'utilities/events';
+import Module from 'utilities/module';
+
+export default class Elemental extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this._pruneLive = this._pruneLive.bind(this);
+
+ this._wrappers = new Map;
+
+ this._observer = null;
+ this._watching = new Set;
+ this._live_watching = null;
+ }
+
+
+ onDisable() {
+ this._stopWatching();
+ }
+
+
+ define(key, selector, routes, opts = null, limit = 0, timeout = 5000) {
+ if ( this._wrappers.has(key) )
+ return this._wrappers.get(key);
+
+ if ( ! selector || typeof selector !== 'string' || ! selector.length )
+ throw new Error('cannot find definition and no selector provided');
+
+ const wrapper = new ElementalWrapper(key, selector, routes, opts, limit, timeout, this);
+ this._wrappers.set(key, wrapper);
+
+ return wrapper;
+ }
+
+
+ route(route) {
+ this._route = route;
+ this._timer = Date.now();
+ this._updateLiveWatching();
+ this.checkAll();
+ }
+
+
+ checkAll() {
+ if ( this._watching )
+ for(const watcher of this._watching)
+ watcher.check();
+ }
+
+
+ updateTimeout() {
+ this._timer = Date.now();
+ this._updateLiveWatching();
+ this.checkAll();
+ }
+
+
+ _isActive(watcher, now) {
+ if ( this._route && watcher.routes.length && ! watcher.routes.includes(this._route) )
+ return false;
+
+ if ( watcher.timeout > 0 && (now - this._timer) > watcher.timeout )
+ return false;
+
+ return true;
+ }
+
+
+ _updateLiveWatching() {
+ if ( this._timeout ) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+
+ const lw = this._live_watching = [],
+ now = Date.now();
+ let min_timeout = Number.POSITIVE_INFINITY;
+
+ if ( this._watching )
+ for(const watcher of this._watching)
+ if ( this._isActive(watcher, now) ) {
+ if ( watcher.timeout > 0 && watcher.timeout < min_timeout )
+ min_timeout = watcher.timeout;
+
+ lw.push(watcher);
+ }
+
+ if ( isFinite(min_timeout) )
+ this._timeout = setTimeout(this._pruneLive, min_timeout);
+
+ if ( ! lw.length )
+ this._stopWatching();
+ else if ( ! this._observer )
+ this._startWatching();
+ }
+
+ _pruneLive() {
+ this._updateLiveWatching();
+ }
+
+ _checkWatchers(muts) {
+ for(const watcher of this._live_watching)
+ watcher.checkElements(muts);
+ }
+
+ _startWatching() {
+ if ( ! this._observer && this._live_watching && this._live_watching.length ) {
+ this.log.info('Installing MutationObserver.');
+
+ this._observer = new MutationObserver(mutations => this._checkWatchers(mutations.map(x => x.target)));
+ this._observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+ }
+ }
+
+ _stopWatching() {
+ if ( this._observer ) {
+ this.log.info('Stopping MutationObserver.');
+ this._observer.disconnect();
+ }
+
+ if ( this._timeout ) {
+ clearTimeout(this._timeout);
+ this._timeout = null;
+ }
+
+ this._live_watching = null;
+ this._observer = null;
+ }
+
+
+ listen(inst) {
+ if ( this._watching.has(inst) )
+ return;
+
+ this._watching.add(inst);
+ this._updateLiveWatching();
+ }
+
+ unlisten(inst) {
+ if ( ! this._watching.has(inst) )
+ return;
+
+ this._watching.delete(inst);
+ this._updateLiveWatching();
+ }
+}
+
+
+let elemental_id = 0;
+
+export class ElementalWrapper extends EventEmitter {
+ constructor(name, selector, routes, opts, limit, timeout, elemental) {
+ super();
+
+ this.id = elemental_id++;
+ this.param = `_ffz$elemental$${this.id}`;
+ this.remove_param = `_ffz$elemental_remove$${this.id}`;
+ this.mut_param = `_ffz$elemental_mutating${this.id}`;
+
+ this._schedule = this._schedule.bind(this);
+
+ this.name = name;
+ this.selector = selector;
+ this.routes = routes || [];
+ this.opts = opts;
+ this.limit = limit;
+ this.timeout = timeout;
+
+ if ( this.opts && ! this.opts.childList && ! this.opts.attributes && ! this.opts.characterData )
+ this.opts.attributes = true;
+
+ this.count = 0;
+ this.instances = new Set;
+ this.observers = new Map;
+ this.elemental = elemental;
+
+ this.check();
+ this.schedule();
+ }
+
+ get atLimit() {
+ return this.limit > 0 && this.count >= this.limit;
+ }
+
+ schedule() {
+ if ( ! this._stimer )
+ this._stimer = setTimeout(this._schedule, 0);
+ }
+
+ _schedule() {
+ clearTimeout(this._stimer);
+ this._stimer = null;
+
+ if ( this.limit === 0 || this.count < this.limit )
+ this.elemental.listen(this);
+ else
+ this.elemental.unlisten(this);
+ }
+
+ check() {
+ const matches = document.querySelectorAll(this.selector);
+ for(const el of matches)
+ this.add(el);
+ }
+
+ checkElements(els) {
+ if ( this.atLimit )
+ return this.schedule();
+
+ for(const el of els) {
+ const matches = el.querySelectorAll(this.selector);
+ for(const match of matches)
+ this.add(match);
+
+ if ( this.atLimit )
+ return;
+ }
+ }
+
+ get first() {
+ for(const el of this.instances)
+ return el;
+
+ return null;
+ }
+
+ toArray() {
+ return Array.from(this.instances);
+ }
+
+ each(fn) {
+ for(const el of this.instances)
+ fn(el);
+ }
+
+ add(el) {
+ if ( this.instances.has(el) )
+ return;
+
+ this.instances.add(el);
+ this.count++;
+
+ const remove_check = new MutationObserver(() => {
+ requestAnimationFrame(() => {
+ if ( ! document.contains(el) )
+ this.remove(el);
+ });
+ });
+
+ remove_check.observe(el.parentNode, {childList: true});
+ el[this.remove_param] = remove_check;
+
+ if ( this.opts ) {
+ const observer = new MutationObserver(muts => {
+ if ( ! document.contains(el) ) {
+ this.remove(el);
+ } else if ( ! this.__running.size )
+ this.emit('mutate', el, muts);
+ });
+
+ observer.observe(el, this.opts);
+ el[this.param] = observer;
+ }
+
+ this.schedule();
+ this.emit('mount', el);
+ }
+
+ remove(el) {
+ const observer = el[this.param];
+ if ( observer ) {
+ observer.disconnect();
+ el[this.param] = null;
+ }
+
+ const remove_check = el[this.remove_param];
+ if ( remove_check ) {
+ remove_check.disconnect();
+ el[this.remove_param] = null;
+ }
+
+ if ( ! this.instances.has(el) )
+ return;
+
+ this.instances.delete(el);
+ this.count--;
+
+ this.schedule();
+ this.emit('unmount', el);
+ }
+}
\ No newline at end of file
diff --git a/src/utilities/compat/fine.js b/src/utilities/compat/fine.js
index 55c3b46f..b1d17ee7 100644
--- a/src/utilities/compat/fine.js
+++ b/src/utilities/compat/fine.js
@@ -591,6 +591,11 @@ export class FineWrapper extends EventEmitter {
this.once('set', fn);
}
+ each(fn) {
+ for(const inst of this.instances)
+ fn(inst);
+ }
+
_set(cls, instances) {
if ( this._class )
throw new Error('already have a class');