1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-07-25 20:18:31 +00:00
* Added: Option to hide Twitch's native stream uptime. Enabled by default when using FFZ's own stream uptime.
* Fixed: Implement stream metadata for the new Twitch layout.
* Fixed: Display the subscription tier in badge tool-tips for Tier 2 and Tier 3 subscriber badges.

This release involves significant changes under the hood. Due to Twitch's heavy use of functional React components and effects in recent updates to their site, we need to start listening for DOM nodes rather than components in many cases. To that end, I've implemented the Elemental module to grab elements using MutationObservers. I didn't want to, but this is where we're at. In a future release I'll be using Elemental to add support back to the directory for certain features.
This commit is contained in:
SirStendec 2020-06-30 19:48:46 -04:00
parent 8c9a3aa8a4
commit ed0577f09e
16 changed files with 719 additions and 824 deletions

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.20.0",
"version": "4.20.1",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0",
"scripts": {

View file

@ -3,8 +3,8 @@
"name": "New API Stress Testing",
"description": "Send duplicate requests to the new API server for load testing.",
"groups": [
{"value": true, "weight": 50},
{"value": false, "weight": 50}
{"value": true, "weight": 25},
{"value": false, "weight": 75}
]
}
}

View file

@ -358,12 +358,21 @@ export default class Badges extends Module {
continue;
let title = bd.title || global_badge.title;
const tier = bd.tier || global_badge.tier;
if ( d.data ) {
if ( d.badge === 'subscriber' ) {
title = this.i18n.t('badges.subscriber.months', '{title} ({count,number} Month{count,en_plural})', {
title,
count: d.data
});
if ( tier > 0 )
title = this.i18n.t('badges.subscriber.tier-months', '{title}\n(Tier {tier}, {months,number} Month{months,en_plural})', {
title,
tier,
months: d.data
});
else
title = this.i18n.t('badges.subscriber.months', '{title}\n({count,number} Month{count,en_plural})', {
title,
count: d.data
});
} else if ( d.badge === 'founder' ) {
title = this.i18n.t('badges.founder.months', '{title}\n(Subscribed for {count,number} Month{count,en_plural})', {
title,

View file

@ -401,6 +401,16 @@ export default class Room {
const sid = data.setID,
bs = b[sid] = b[sid] || {};
if ( sid === 'subscriber' ) {
const id = parseInt(data.version, 10);
if ( ! isNaN(id) && isFinite(id) ) {
data.tier = (id - (id % 1000)) / 1000;
if ( data.tier < 0 )
data.tier = 0;
} else
data.tier = 0;
}
bs[data.version] = data;
this.badge_count++;
}

View file

@ -81,14 +81,17 @@ export default class Metadata extends Module {
refresh() { return this.settings.get('metadata.uptime') > 0 },
setup(data) {
const socket = this.resolve('socket'),
created_at = data?.meta?.createdAt;
const socket = this.resolve('socket');
let created = data?.channel?.live_since;
if ( ! created ) {
const created_at = data?.meta?.createdAt;
if ( ! created_at )
return {};
if ( ! created_at )
return {};
created = new Date(created_at);
}
const created = new Date(created_at),
now = Date.now() - socket._time_drift;
const now = Date.now() - socket._time_drift;
return {
created,
@ -381,7 +384,12 @@ export default class Metadata extends Module {
}
updateMetadata(keys) {
const bar = this.resolve('site.channel_bar');
const channel = this.resolve('site.channel');
if ( channel )
for(const el of channel.InfoBar.instances)
channel.updateMetadata(el, keys);
/*const bar = this.resolve('site.channel_bar');
if ( bar ) {
for(const inst of bar.ChannelBar.instances)
bar.updateMetadata(inst, keys);
@ -391,7 +399,7 @@ export default class Metadata extends Module {
if ( legacy_bar ) {
for(const inst of legacy_bar.ChannelBar.instances)
legacy_bar.updateMetadata(inst, keys);
}
}*/
}
async renderLegacy(key, data, container, timers, refresh_fn) {
@ -465,7 +473,7 @@ export default class Metadata extends Module {
if ( def.popup && def.click ) {
el = (<div
class={`tw-align-items-center tw-inline-flex tw-relative tw-tooltip-wrapper ffz-stat tw-stat ffz-stat--fix-padding ${border ? 'tw-mg-l-1' : 'tw-mg-l-05 ffz-mg-r--05'}`}
class={`tw-align-items-center tw-inline-flex tw-relative tw-tooltip-wrapper ffz-stat tw-stat ffz-stat--fix-padding ${border ? 'tw-mg-r-1' : 'tw-mg-r-05 ffz-mg-l--05'}`}
data-key={key}
tip_content={null}
>
@ -490,7 +498,7 @@ export default class Metadata extends Module {
} else
btn = popup = el = (<button
class={`ffz-stat tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-top-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-right-radius-medium tw-core-button tw-core-button--text ${inherit ? 'ffz-c-text-inherit' : 'tw-c-text-base'} tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative tw-pd-x-05 ffz-stat--fix-padding ${border ? 'tw-border tw-mg-l-1' : 'tw-font-size-5 tw-regular tw-mg-l-05 ffz-mg-r--05'}`}
class={`ffz-stat tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-top-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-right-radius-medium tw-core-button tw-core-button--text ${inherit ? 'ffz-c-text-inherit' : 'tw-c-text-base'} tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative tw-pd-x-05 ffz-stat--fix-padding ${border ? 'tw-border tw-mg-r-1' : 'tw-font-size-5 tw-regular tw-mg-r-05 ffz-mg-l--05'}`}
data-key={key}
tip_content={null}
>
@ -589,7 +597,7 @@ export default class Metadata extends Module {
icon = (<span class="tw-stat__icon"><figure class={icon} /></span>);
el = (<div
class="tw-align-items-center tw-inline-flex tw-relative tw-tooltip-wrapper ffz-stat tw-stat tw-mg-l-1"
class="tw-align-items-center tw-inline-flex tw-relative tw-tooltip-wrapper ffz-stat tw-stat tw-mg-r-1"
data-key={key}
tip_content={null}
>

View file

@ -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',

View file

@ -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);
});
}
}

View file

@ -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 = <div class="ffz--stat-container tw-pd-l-05"></div>;
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);
}
}

View file

@ -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 = {
'\\&lt\\;\\]': ['<]'],
'\\:-?(S|s)': [':s', ':S', ':-s', ':-S'],
'\\:\\&gt\\;': [':>']
};
};*/
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;
}
}

View file

@ -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'));

View file

@ -0,0 +1,9 @@
.ffz--meta-tray {
.live-time {
display: none !important;
}
& > :first-child > :last-child {
margin-right: 0 !important;
}
}

View file

@ -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);

View file

@ -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() {

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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');