diff --git a/src/main.js b/src/main.js index 59a2143e..761609a1 100644 --- a/src/main.js +++ b/src/main.js @@ -100,7 +100,7 @@ class FrankerFaceZ extends Module { FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 0, revision: 0, extra: '-rc9.3', + major: 4, minor: 0, revision: 0, extra: '-rc10', commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index aa858d41..0dda7b01 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -4,7 +4,7 @@ // Channel Metadata // ============================================================================ -import {createElement, ClickOutside} from 'utilities/dom'; +import {createElement, ClickOutside, setChildren} from 'utilities/dom'; import {maybe_call} from 'utilities/object'; import {duration_to_string} from 'utilities/time'; @@ -58,10 +58,10 @@ export default class Metadata extends Module { this.definitions.uptime = { refresh() { return this.settings.get('metadata.uptime') > 0 }, - setup() { + setup(data) { const socket = this.resolve('socket'), apollo = this.resolve('site.apollo'), - created_at = apollo.getFromQuery('ChannelPage_ChannelHeader', 'data.user.stream.createdAt'); + created_at = apollo.getFromQuery(data.legacy ? 'ChannelPage_ChannelHeader' : 'ChannelPage_User', 'data.user.stream.createdAt'); if ( ! created_at ) return {}; @@ -86,6 +86,8 @@ export default class Metadata extends Module { return duration_to_string(data.uptime, false, false, false, setting !== 2); }, + subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'), + tooltip(data) { if ( ! data.created ) return null; @@ -135,7 +137,7 @@ export default class Metadata extends Module { } } - if ( ! stats ) + if ( ! stats || stats.hlsLatencyBroadcaster < -100 ) return {stats}; let drift = 0; @@ -154,6 +156,8 @@ export default class Metadata extends Module { order: 3, icon: 'ffz-i-gauge', + subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'), + label(data) { if ( ! this.settings.get('metadata.player-stats') || ! data.delay ) return null; @@ -226,9 +230,6 @@ export default class Metadata extends Module { if ( bar ) { for(const inst of bar.ChannelBar.instances) bar.updateMetadata(inst, keys); - - for(const inst of bar.HostBar.instances) - bar.updateMetadata(inst, keys); } } @@ -239,6 +240,264 @@ export default class Metadata extends Module { let el = container.querySelector(`.ffz-stat[data-key="${key}"]`); + const def = this.definitions[key], + destroy = () => { + if ( el ) { + if ( el.tooltip ) + el.tooltip.destroy(); + + if ( el.popper ) + el.popper.destroy(); + + if ( el._ffz_destroy ) + el._ffz_destroy(); + + el._ffz_destroy = el.tooltip = el.popper = null; + el.remove(); + } + }; + + if ( ! def ) + return destroy(); + + try { + // Process the data if a setup method is defined. + if ( def.setup ) + data = await def.setup.call(this, data); + + // Let's get refresh logic out of the way now. + const refresh = maybe_call(def.refresh, this, data); + if ( refresh ) + timers[key] = setTimeout( + () => refresh_fn(key), + typeof refresh === 'number' ? refresh : 1000 + ); + + + // Grab the element again in case it changed, somehow. + el = container.querySelector(`.ffz-sidebar-stat[data-key="${key}"]`); + + let stat, old_color, sub_el; + + const label = maybe_call(def.label, this, data); + + if ( ! label ) + return destroy(); + + const tooltip = maybe_call(def.tooltip, this, data), + subtitle = maybe_call(def.subtitle, this, data), + order = maybe_call(def.order, this, data), + color = maybe_call(def.color, this, data) || ''; + + if ( ! el ) { + let icon = maybe_call(def.icon, this, data); + let button = false; + + el = (
); + + if ( def.popup || def.click ) { + button = true; + + let btn, popup; + let cls = maybe_call(def.button, this, data); + if ( typeof cls !== 'string' ) + cls = `tw-button--${cls ? 'hollow' : 'text'}`; + + if ( typeof icon === 'string' ) + icon = (
); + + if ( def.popup && def.click ) { + el.appendChild(
+ {btn = ()} + {popup = ()} +
); + + } else { + el.appendChild(btn = popup = ()); + } + + if ( def.click ) + btn.addEventListener('click', e => { + if ( btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') ) + return false; + + def.click.call(this, el._ffz_data, e, () => refresh_fn(key)); + }); + + if ( def.popup ) + popup.addEventListener('click', () => { + if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') ) + return false; + + if ( el._ffz_popup ) + return el._ffz_destroy(); + + const destroy = el._ffz_destroy = () => { + if ( el._ffz_outside ) + el._ffz_outside.destroy(); + + if ( el._ffz_popup ) { + const fp = el._ffz_popup; + el._ffz_popup = null; + fp.destroy(); + } + + el._ffz_destroy = el._ffz_outside = null; + }; + + const parent = document.body.querySelector('body #root,body'), + tt = el._ffz_popup = new Tooltip(parent, el, { + logger: this.log, + manual: true, + html: true, + + tooltipClass: 'ffz-metadata-balloon tw-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background', + // Hide the arrow for now, until we re-do our CSS to make it render correctly. + arrowClass: 'tw-balloon__tail tw-overflow-hidden tw-absolute', + arrowInner: 'tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background tw-absolute', + innerClass: 'tw-pd-1', + + popper: { + placement: 'right-start', + modifiers: { + preventOverflow: { + boundariesElement: parent + }, + flip: { + behavior: ['top', 'bottom', 'left', 'right'] + } + } + }, + content: (t, tip) => def.popup.call(this, el._ffz_data, tip, () => refresh_fn(key)), + onShow: (t, tip) => + setTimeout(() => { + el._ffz_outside = new ClickOutside(tip.outer, destroy); + }), + onHide: destroy + }); + + tt._enter(el); + }); + + } else { + el.appendChild(
+ {stat =
} +
); + } + + el.appendChild(sub_el =
); + + let subcontainer; + + if ( button ) { + subcontainer = container.querySelector('.ffz-sidebar-stats--buttons'); + if ( ! subcontainer ) + container.appendChild(subcontainer = (
)); + + } else { + subcontainer = container.querySelector('.ffz-sidebar-stats--stats'); + if ( ! subcontainer ) { + subcontainer = (
); + const btns = container.querySelector('.ffz-sidebar-stats--buttons'); + if ( btns ) + container.insertBefore(subcontainer, btns); + else + container.appendChild(subcontainer); + } + } + + el._ffz_order = order; + + if ( order != null ) + el.style.order = order; + + subcontainer.appendChild(el); + + if ( def.tooltip ) { + const parent = document.body.querySelector('body #root,body'); + el.tooltip = new Tooltip(parent, el, { + logger: this.log, + live: false, + html: true, + content: () => el.tip_content, + onShow: (t, tip) => el.tip = tip, + onHide: () => el.tip = null, + popper: { + placement: 'top', + modifiers: { + flip: { + behavior: ['bottom', 'top'] + }, + preventOverflow: { + boundariesElement: parent + } + } + } + }); + } + + + } else { + stat = el.querySelector('.ffz-sidebar-stat--label'); + sub_el = el.querySelector('.ffz-sidebar-stat--subtitle') + old_color = el.dataset.color || ''; + + if ( el._ffz_order !== order ) + el.style.order = el._ffz_order = order; + + if ( el.tip_content !== tooltip ) { + el.tip_content = tooltip; + if ( el.tip ) + el.tip.element.innerHTML = tooltip; + } + } + + if ( old_color !== color ) + el.dataset.color = el.style.color = color; + + el._ffz_data = data; + + setChildren(stat, label, true); + setChildren(sub_el, subtitle, true); + + if ( def.disabled !== undefined ) + el.disabled = maybe_call(def.disabled, this, data); + + } catch(err) { + this.log.capture(err, { + tags: { + metadata: key + } + }); + this.log.error(`Error rendering metadata for ${key}`, err); + return destroy(); + } + } + + + async renderLegacy(key, data, container, timers, refresh_fn) { + if ( timers[key] ) + clearTimeout(timers[key]); + + let el = container.querySelector(`.ffz-stat[data-key="${key}"]`); + const def = this.definitions[key], destroy = () => { if ( el ) { diff --git a/src/sites/twitch-twilight/modules/channel.js b/src/sites/twitch-twilight/modules/channel.js index 2d18a4ff..609e311d 100644 --- a/src/sites/twitch-twilight/modules/channel.js +++ b/src/sites/twitch-twilight/modules/channel.js @@ -5,7 +5,7 @@ // ============================================================================ import Module from 'utilities/module'; -import { has } from 'utilities/object'; +import { get, has } from 'utilities/object'; import Twilight from 'site'; @@ -44,9 +44,8 @@ export default class Channel extends Module { this.ChannelPage = this.fine.define( 'channel-page', - n => n.getHostedChannelLogin && n.handleHostingChange, - //n => n.hostModeFromGraphQL, - ['user'] + n => (n.getHostedChannelLogin && n.handleHostingChange) || n.hostModeFromGraphQL, + ['user', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'] ); this.RaidController = this.fine.define( @@ -68,17 +67,12 @@ export default class Channel extends Module { }); this.ChannelPage.on('update', inst => { - if ( this.settings.get('channel.hosting.enable') ) + if ( this.settings.get('channel.hosting.enable') || has(inst.state, 'hostMode') ) return; // We can't do this immediately because the player state // occasionally screws up if we do. setTimeout(() => { - /*if ( inst.state.hostMode ) { - inst.ffzExpectedHost = inst.state.hostMode; - inst.ffzOldSetState({hostMode: null}); - }*/ - 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; @@ -134,26 +128,40 @@ export default class Channel extends Module { if ( inst._ffz_hosting_wrapped ) return; - const t = this; + const t = this, + new_style = 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 ( ! t.settings.get('channel.hosting.enable') ) { - if ( has(state, 'isHosting') ) - state.isHosting = false; + if ( new_style ) { + if ( has(state, 'hostMode') ) { + t.log.info('update-host', state.hostMode) - if ( has(state, 'videoPlayerSource') ) - state.videoPlayerSource = inst.props.match.params.channelName; - } - - /*if ( has(state, 'hostMode') ) { - inst.ffzExpectedHost = state.hostMode; - if ( state.hostMode && ! t.settings.get('channel.hosting.enable') ) { - state.hostMode = null; - state.videoPlayerSource = inst.props.match.params.channelName; + inst.ffzExpectedHost = state.hostMode; + if ( state.hostMode && ! t.settings.get('channel.hosting.enable') ) { + state.hostMode = null; + state.videoPlayerSource = inst.ffzGetChannel(); + } } - }*/ + + } else { + if ( ! t.settings.get('channel.hosting.enable') ) { + if ( has(state, 'isHosting') ) + state.isHosting = false; + + if ( has(state, 'videoPlayerSource') ) + state.videoPlayerSource = inst.ffzGetChannel(); + } + } } catch(err) { t.log.capture(err, {extra: {props: inst.props, state}}); @@ -164,34 +172,41 @@ export default class Channel extends Module { inst._ffz_hosting_wrapped = true; - inst.ffzOldGetHostedLogin = inst.getHostedChannelLogin; - inst.getHostedChannelLogin = function() { - return t.settings.get('channel.hosting.enable') ? - inst.ffzOldGetHostedLogin() : null; - } + if ( new_style ) { + const hosted = inst.ffzExpectedHost = inst.state.hostMode; + t.log.info('Expected Host', hosted); - inst.ffzOldHostHandler = inst.handleHostingChange; - inst.handleHostingChange = function(channel) { - inst.ffzExpectedHost = channel; - if ( t.settings.get('channel.hosting.enable') ) - return inst.ffzOldHostHandler(channel); - } + if ( hosted && ! this.settings.get('channel.hosting.enable') ) + inst.ffzOldSetState({ + hostMode: null, + videoPlayerSource: inst.props.match.params.channelName + }); - // Store the current state and disable the current host if needed. - inst.ffzExpectedHost = inst.state.isHosting ? inst.state.videoPlayerSource : null; - if ( ! this.settings.get('channel.hosting.enable') ) - inst.ffzOldHostHandler(null); + } else { + inst.ffzOldGetHostedLogin = inst.getHostedChannelLogin; + 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; + 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(); - /*const hosted = inst.ffzExpectedHost = inst.state.hostMode; - if ( hosted && ! this.settings.get('channel.hosting.enable') ) - inst.ffzOldSetState({ - hostMode: null, - videoPlayerSource: inst.props.match.params.channelName - });*/ + } @@ -200,15 +215,17 @@ export default class Channel extends Module { val = this.settings.get('channel.hosting.enable'); for(const inst of this.ChannelPage.instances) { - inst.ffzOldHostHandler(val ? inst.ffzExpectedHost : null); + if ( has(inst.state, 'hostMode') ) { + const host = val ? inst.ffzExpectedHost : null, + target = host && host.hostedChannel && host.hostedChannel.login || inst.ffzGetChannel(); - /*const host = val ? inst.ffzExpectedHost : null, - target = host && host.hostedChannel && host.hostedChannel.login || inst.props.match.params.channelName; + inst.ffzOldSetState({ + hostMode: host, + videoPlayerSource: target + }); - inst.ffzOldSetState({ - hostMode: host, - videoPlayerSource: target - });*/ + } else + inst.ffzOldHostHandler(val ? inst.ffzExpectedHost : null); } } } \ No newline at end of file diff --git a/src/sites/twitch-twilight/modules/channel_bar.js b/src/sites/twitch-twilight/modules/channel_bar.js index 5a4dce7d..c7d51f39 100644 --- a/src/sites/twitch-twilight/modules/channel_bar.js +++ b/src/sites/twitch-twilight/modules/channel_bar.js @@ -9,7 +9,6 @@ import {deep_copy} from 'utilities/object'; import CHANNEL_QUERY from './channel_bar_query.gql'; - export default class ChannelBar extends Module { constructor(...args) { super(...args); @@ -21,27 +20,19 @@ export default class ChannelBar extends Module { this.inject('metadata'); this.inject('socket'); - this.apollo.registerModifier('ChannelPage_ChannelHeader', CHANNEL_QUERY); - this.apollo.registerModifier('ChannelPage_ChannelHeader', data => { + this.apollo.registerModifier('ChannelPage_User', CHANNEL_QUERY); + /*this.apollo.registerModifier('ChannelPage_User', 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); - + }, false);*/ this.ChannelBar = this.fine.define( 'channel-bar', - n => n.getTitle && n.getGame && n.renderGame, - ['user'] - ); - - - this.HostBar = this.fine.define( - 'host-container', - n => n.handleReportHosterClick, - ['user'] + n => n.renderChannelMetadata && n.renderTitleInfo, + ['user', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'] ) } @@ -54,16 +45,6 @@ export default class ChannelBar extends Module { for(const inst of instances) this.updateChannelBar(inst); }); - - - /*this.HostBar.on('unmount', this.unmountHostBar, this); - this.HostBar.on('mount', this.updateHostBar, this); - this.HostBar.on('update', this.updateHostBar, this); - - this.HostBar.ready((cls, instances) => { - for(const inst of instances) - this.updateHostBar(inst); - });*/ } @@ -99,11 +80,13 @@ export default class ChannelBar extends Module { updateMetadata(inst, keys) { const container = this.fine.getChildNode(inst), - metabar = container && container.querySelector && container.querySelector('.channel-info-bar__action-container > .tw-flex'); + wrapper = container && container.querySelector && container.querySelector('.side-nav-channel-info__info-wrapper > .tw-pd-t-05'); - if ( ! inst._ffz_mounted || ! metabar ) + if ( ! inst._ffz_mounted || ! wrapper ) return; + const metabar = wrapper; + if ( ! keys ) keys = this.metadata.keys; else if ( ! Array.isArray(keys) ) @@ -112,7 +95,7 @@ export default class ChannelBar extends Module { const timers = inst._ffz_meta_timers = inst._ffz_meta_timers || {}, refresh_func = key => this.updateMetadata(inst, key), data = { - channel: inst.props.userData && inst.props.userData.user, + channel: inst.props.data && inst.props.data.user, hosting: false, _inst: inst } diff --git a/src/sites/twitch-twilight/modules/css_tweaks/styles/chat-width.scss b/src/sites/twitch-twilight/modules/css_tweaks/styles/chat-width.scss index 91de4410..0b386b93 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/styles/chat-width.scss +++ b/src/sites/twitch-twilight/modules/css_tweaks/styles/chat-width.scss @@ -8,7 +8,9 @@ body .channel-page__video-player--theatre-mode { } body .video-watch-page__right-column, -body .channel-page__right-column { +body .channel-page__right-column, +body .right-column, +body .channel-root__right-column { width: var(--ffz-chat-width); } diff --git a/src/sites/twitch-twilight/modules/legacy_channel_bar.js b/src/sites/twitch-twilight/modules/legacy_channel_bar.js new file mode 100644 index 00000000..b290f48d --- /dev/null +++ b/src/sites/twitch-twilight/modules/legacy_channel_bar.js @@ -0,0 +1,124 @@ +'use strict'; + +// ============================================================================ +// Channel Bar +// ============================================================================ + +import Module from 'utilities/module'; +import {deep_copy} from 'utilities/object'; + +import CHANNEL_QUERY from './channel_bar_query.gql'; + + +export default class LegacyChannelBar extends Module { + constructor(...args) { + super(...args); + + this.should_enable = true; + + this.inject('site.fine'); + this.inject('site.apollo'); + 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.ChannelBar = this.fine.define( + 'legacy-channel-bar', + n => n.getTitle && n.getGame && n.renderGame, + ['user'] + ); + + + this.HostBar = this.fine.define( + 'legacy-host-container', + n => n.handleReportHosterClick, + ['user'] + ) + } + + onEnable() { + 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.HostBar.on('unmount', this.unmountHostBar, this); + this.HostBar.on('mount', this.updateHostBar, this); + this.HostBar.on('update', this.updateHostBar, this); + + this.HostBar.ready((cls, instances) => { + for(const inst of instances) + this.updateHostBar(inst); + });*/ + } + + + updateChannelBar(inst) { + const login = inst.props.channelLogin; + 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.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; + } + + + updateMetadata(inst, keys) { + const container = this.fine.getChildNode(inst), + metabar = container && container.querySelector && container.querySelector('.channel-info-bar__action-container > .tw-flex'); + + 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.userData && inst.props.userData.user, + hosting: false, + legacy: true, + _inst: 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/styles/channel.scss b/src/sites/twitch-twilight/styles/channel.scss index 7f249ea8..bee101d0 100644 --- a/src/sites/twitch-twilight/styles/channel.scss +++ b/src/sites/twitch-twilight/styles/channel.scss @@ -36,8 +36,30 @@ } } +.ffz-sidebar-stats { + margin-top: .5rem; + margin-right: -1rem; + + & + .ffz-sidebar-stats { + margin-top: 0 !important; + } + + .ffz-sidebar-stat { + min-width: calc(50% - 1rem); + margin: 0 1rem 1rem 0; + } +} + +.ffz-has-stat-arrow { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + + .ffz-stat-arrow { border-left: none !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; }