diff --git a/package.json b/package.json index 3cca927e..a1889783 100755 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "sortablejs": "^1.6.1", "vue": "^2.5.2", "vue-clickaway": "^2.1.0", - "vue-template-compiler": "^2.5.2" + "vue-template-compiler": "^2.5.2", + "vuedraggable": "^2.15.0" } } diff --git a/src/api.js b/src/api.js deleted file mode 100644 index cb3ae2b1..00000000 --- a/src/api.js +++ /dev/null @@ -1,293 +0,0 @@ -'use strict'; - -// ======================================================================== -// Legacy API -// ======================================================================== - -import Module from 'utilities/module'; -import {has} from 'utilities/object'; -import {EventEmitter} from 'utilities/events'; - - -export default class ApiModule extends Module { - constructor(...args) { - super(...args); - - this.inject('chat'); - this.inject('chat.emotes'); - - this._apis = {}; - - if ( ! this._known_apis ) { - this._known_apis = {}; - const stored_val = localStorage.getItem(`ffz_known_apis`); - if ( stored_val !== null ) - try { - this._known_apis = JSON.parse(stored_val); - } catch(err) { - this.log.error(`Error loading known APIs`, err); - } - } - } - - create(...args) { - return new LegacyAPI(this, ...args); - } -} - - -export class LegacyAPI extends EventEmitter { - constructor(instance, name, icon = null, version = null, name_key = null) { - super(); - - this.ffz = instance.root; - this.parent = instance; - - if ( name ) { - for(const id in this.parent._known_apis) { - if ( this.parent._known_apis[id] === name ) { - this.id = id; - break; - } - } - } - - if ( ! this.id ) { - let i = 0; - while ( ! this.id ) { - if ( ! has(this.parent._known_apis, i) ) { - this.id = i; - break; - } - i++; - } - - if ( name ) { - this.parent._known_apis[this.id] = name; - localStorage.ffz_known_apis = JSON.stringify(this.parent._known_apis); - } - } - - this.parent._apis[this.id] = this; - - this.emote_sets = {}; - this.global_sets = []; - this.default_sets = []; - - this.badges = {}; - this.users = {}; - - this.name = name || `Extension#${this.id}`; - this.name_key = name_key || this.name.replace(/[^A-Z0-9_-]/g, '').toLowerCase(); - - if ( /^[0-9]/.test(this.name_key) ) - this.name_key = `_${this.name_key}`; - - this.icon = icon; - this.version = version; - - this.parent.log.info(`Registered New Extension #${this.id} (${this.name_key}): ${this.name}`); - } - - log(msg, data) { - this.parent.log.info(`Ext #${this.id} (${this.name_key}): ${msg}`, data); - } - - error(msg, error) { - this.parent.log.error(`Ext #${this.id} (${this.name_key}): ${msg}`, error); - } - - register_metadata(key, data) { } // eslint-disable-line - unregister_metadata(key, data) { } // eslint-disable-line - update_metadata(key, full_update) { } // eslint-disable-line - - - _load_set(real_id, set_id, data) { - if ( ! data ) - return null; - - const emote_set = Object.assign({ - source: this.name, - icon: this.icon || null, - title: 'Global Emoticons', - _type: 0 - }, data, { - source_ext: this.id, - source_id: set_id, - id: real_id, - count: 0 - }); - - this.emote_sets[set_id] = emote_set; - this.parent.emotes.loadSetData(real_id, emote_set); - - return emote_set; - } - - - load_set(set_id, emote_set) { - const real_id = `${this.id}-${set_id}`; - return this._load_set(real_id, set_id, emote_set); - } - - - unload_set(set_id) { - const real_id = `${this.id}-${set_id}`, - emote_set = this.emote_sets[set_id]; - - if ( ! emote_set ) - return; - - this.unregister_global_set(set_id); - - // TODO: Unload sets - - return emote_set; - } - - - get_set(set_id) { - return this.emote_sets[set_id]; - } - - - register_global_set(set_id, emote_set) { - const real_id = `${this.id}-${set_id}`; - if ( emote_set ) - emote_set = this.load_set(set_id, emote_set); - else - emote_set = this.emote_sets[set_id]; - - if ( ! emote_set ) - throw new Error('Invalid set ID.'); - - if ( this.parent.emotes.emote_sets && ! this.parent.emotes.emote_sets[real_id] ) - this.parent.emotes.emote_sets[real_id] = emote_set; - - if ( this.global_sets.indexOf(set_id) === -1 ) - this.global_sets.push(set_id); - - if ( this.default_sets.indexOf(set_id) === -1 ) - this.default_sets.push(set_id); - - this.parent.emotes.global_sets.push(`api--${this.id}`, real_id); - this.parent.emotes.default_sets.push(`api--${this.id}`, real_id); - } - - unregister_global_set(set_id) { - const real_id = `${this.id}-${set_id}`, - emote_set = this.emote_sets[set_id]; - - if ( ! emote_set ) - return; - - let ind = this.global_sets.indexOf(set_id); - if ( ind !== -1 ) - this.global_sets.splice(ind,1); - - ind = this.default_sets.indexOf(set_id); - if ( ind !== -1 ) - this.default_sets.splice(ind,1); - - this.parent.emote.global_sets.remove(`api--${this.id}`, real_id); - this.parent.emote.default_sets.remove(`api--${this.id}`, real_id); - } - - - register_room_set(room_login, set_id, emote_set) { - const real_id = `${this.id}-${set_id}`, - room = this.parent.chat.getRoom(null, room_login, true); - - if ( ! room ) - throw new Error('Room not loaded'); - - if ( emote_set ) { - emote_set.title = emote_set.title || `Channel: ${room.data && room.data.display_name || room_login}`; - emote_set._type = emote_set._type || 1; - - emote_set = this.load_set(set_id, emote_set); - } else - emote_set = this.emote_sets[set_id]; - - if ( ! emote_set ) - throw new Error('Invalid set ID.'); - - if ( this.parent.emotes.emote_sets && ! this.parent.emotes.emote_sets[real_id] ) - this.parent.emotes.emote_sets[real_id] = emote_set; - - room.emote_sets.push(`api--${this.id}`, real_id); - emote_set.users++; - } - - unregister_room_set(room_login, set_id) { - const real_id = `${this.id}-${set_id}`, - emote_set = this.emote_sets[set_id], - room = this.parent.chat.getRoom(null, room_login, true); - - if ( ! emote_set || ! room ) - return; - - room.emote_sets.remove(`api--${this.id}`, real_id); - emote_set.users--; - } - - - add_badge() { } // eslint-disable-line - remove_badge() { } // eslint-disable-line - user_add_badge() { } // eslint-disable-line - user_remove_badge() { } // eslint-disable-line - room_add_user_badge() { } // eslint-disable-line - room_remove_user_badge() { } // eslint-disable-line - - user_add_set(username, set_id) { // eslint-disable-line - - } - - user_remove_set(username, set_id) { // eslint-disable-line - - } - - - retokenize_messages() { } // eslint-disable-line - - - register_chat_filter(filter) { - this.on('room-message', filter); - } - - unregister_chat_filter(filter) { - this.off('room-message', filter); - } - - - iterate_chat_views(func) { } // eslint-disable-line - iterate_rooms(func) { - if ( func === undefined ) - func = this.emit.bind(this, 'room-add'); - - const chat = this.parent.resolve('chat'); - for(const room_id in chat.rooms) - if ( has(chat.rooms, room_id) ) - func(room_id); - } - - register_on_room_callback(callback, dont_iterate) { - const thing = room_id => callback(room_id, this.register_room_set.bind(this, room_id)); - - thing.original_func = callback; - callback.__wrapped = thing; - - this.on('room-add', thing); - if ( ! dont_iterate ) - this.iterate_rooms(thing); - } - - unregister_on_room_callback(callback) { - if ( ! callback.__wrapped ) - return; - - this.off('room-add', callback.__wrapped); - callback.__wrapped = null; - } - -} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 070f9ba2..0c879806 100644 --- a/src/main.js +++ b/src/main.js @@ -9,7 +9,6 @@ import SettingsManager from './settings/index'; import {TranslationManager} from './i18n'; import SocketClient from './socket'; import Site from 'site'; -import LegacyAPI from './api'; import Vue from 'utilities/vue'; class FrankerFaceZ extends Module { @@ -39,8 +38,6 @@ class FrankerFaceZ extends Module { this.inject('socket', SocketClient); this.inject('site', Site); - this.inject('_api', LegacyAPI); - this.register('vue', Vue); @@ -121,13 +118,3 @@ FrankerFaceZ.utilities = { window.FrankerFaceZ = FrankerFaceZ; window.ffz = new FrankerFaceZ(); - -// Make FFZ:AP Run -FrankerFaceZ.chat_commands = {}; -FrankerFaceZ.settings_info = {}; -FrankerFaceZ.utils = { - process_int: a => a -} -window.App = true; -if ( window.jQuery ) - window.jQuery.noty = {themes: {}}; \ No newline at end of file diff --git a/src/settings/index.js b/src/settings/index.js index f7add32d..57927af5 100644 --- a/src/settings/index.js +++ b/src/settings/index.js @@ -13,15 +13,6 @@ import SettingsContext from './context'; import MigrationManager from './migration'; -const OVERRIDE_GET = { - ffz_enable_highlight_sound: false, - ffz_highlight_sound_volume: 0, - bttv_channel_emotes: true, - bttv_global_emotes: true, - bttv_gif_emotes: 1 -} - - // ============================================================================ // SettingsManager // ============================================================================ @@ -331,12 +322,7 @@ export default class SettingsManager extends Module { // ======================================================================== context(env) { return this.main_context.context(env) } - get(key) { - if ( has(OVERRIDE_GET, key) ) - return OVERRIDE_GET[key]; - - return this.main_context.get(key); - } + get(key) { return this.main_context.get(key); } uses(key) { return this.main_context.uses(key) } update(key) { return this.main_context.update(key) } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 3badb516..53846623 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -428,6 +428,18 @@ export default class ChatHook extends Module { } } + const old_host = this.onHostingEvent; + this.onHostingEvent = function (e, _t) { + t.emit('tmi:host', e, _t); + return old_host.call(i, e, _t); + } + + const old_unhost = this.onUnhostEvent; + this.onUnhostEvent = function (e, _t) { + t.emit('tmi:unhost', e, _t); + return old_unhost.call(i, e, _t); + } + this.postMessage = function(e) { const original = this._wrapped; if ( original ) { diff --git a/src/sites/twitch-twilight/modules/directory/following.js b/src/sites/twitch-twilight/modules/directory/following.js index 6f32b064..858c6674 100644 --- a/src/sites/twitch-twilight/modules/directory/following.js +++ b/src/sites/twitch-twilight/modules/directory/following.js @@ -112,6 +112,20 @@ export default class Following extends SiteModule { } }`); + this.apollo.registerModifier('FollowedChannels', `query { + currentUser { + followedLiveUsers { + nodes { + profileImageURL(width: 70) + stream { + type + createdAt + } + } + } + } + }`); + this.ChannelCard = this.fine.define( 'following-channel-card', n => n.renderGameBoxArt && n.renderContentType @@ -121,11 +135,13 @@ export default class Following extends SiteModule { this.modifyLiveUsers(res); this.modifyLiveHosts(res); }, false); - + this.on('settings:changed:directory.uptime', () => this.ChannelCard.forceUpdate()); this.on('settings:changed:directory.show-channel-avatars', () => this.ChannelCard.forceUpdate()); this.on('settings:changed:directory.show-boxart', () => this.ChannelCard.forceUpdate()); - + this.on('settings:changed:directory.hide-vodcasts', () => this.ChannelCard.forceUpdate()); + + this.apollo.registerModifier('FollowedChannels', res => this.modifyLiveUsers(res), false); this.apollo.registerModifier('FollowingLive_CurrentUser', res => this.modifyLiveUsers(res), false); this.apollo.registerModifier('FollowingHosts_CurrentUser', res => this.modifyLiveHosts(res), false); } @@ -170,13 +186,10 @@ export default class Following extends SiteModule { const s = node.hosting.stream.viewersCount = new Number(node.hosting.stream.viewersCount || 0); s.profileImageURL = node.hosting.profileImageURL; s.createdAt = node.hosting.stream.createdAt; - s.hostData = { - channel: node.hosting.login, - displayName: node.hosting.displayName - }; if (!this.hosts[node.hosting.displayName]) { this.hosts[node.hosting.displayName] = { + channel: node.hosting.login, nodes: [node], channels: [node.displayName] }; @@ -194,36 +207,48 @@ export default class Following extends SiteModule { return res; } + ensureQueries () { + if (this.router && this.router.match) { + this.apollo.ensureQuery( + 'FollowedChannels', + 'data.currentUser.followedLiveUsers.nodes.0.profileImageURL' + ); + + if (this.router.match[1] === 'following') { + this.apollo.ensureQuery( + 'FollowedIndex_CurrentUser', + n => + get('data.currentUser.followedLiveUsers.nodes.0.profileImageURL', n) !== undefined + || + get('data.currentUser.followedHosts.nodes.0.hosting.profileImageURL', n) !== undefined + ); + } else if (this.router.match[1] === 'live') { + this.apollo.ensureQuery( + 'FollowingLive_CurrentUser', + 'data.currentUser.followedLiveUsers.nodes.0.profileImageURL' + ); + } else if (this.router.match[1] === 'hosts') { + this.apollo.ensureQuery( + 'FollowingHosts_CurrentUser', + 'data.currentUser.followedHosts.nodes.0.hosting.profileImageURL' + ); + } + } + } + onEnable() { this.ChannelCard.ready((cls, instances) => { - if (this.router && this.router.match) { - if (this.router.match[1] === 'following') { - this.apollo.ensureQuery( - 'FollowedIndex_CurrentUser', - n => - get('data.currentUser.followedLiveUsers.nodes.0.profileImageURL', n) !== undefined - || - get('data.currentUser.followedHosts.nodes.0.hosting.profileImageURL', n) !== undefined - ); - } else if (this.router.match[1] === 'live') { - this.apollo.ensureQuery( - 'FollowingLive_CurrentUser', - 'data.currentUser.followedLiveUsers.nodes.0.profileImageURL' - ); - } else if (this.router.match[1] === 'hosts') { - this.apollo.ensureQuery( - 'FollowingHosts_CurrentUser', - 'data.currentUser.followedHosts.nodes.0.hosting.profileImageURL' - ); - } - } + this.ensureQueries(); for(const inst of instances) this.updateChannelCard(inst); }); - this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this); - this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this); - this.ChannelCard.on('unmount', inst => this.parent.clearUptime(inst), this); + this.ChannelCard.on('update', inst => { + this.ensureQueries(); + this.updateChannelCard(inst) + }, this); + this.ChannelCard.on('mount', this.updateChannelCard, this); + this.ChannelCard.on('unmount', this.parent.clearUptime, this); document.body.addEventListener('click', this.destroyHostMenu.bind(this)); } @@ -246,6 +271,7 @@ export default class Following extends SiteModule { this.hostMenu && this.hostMenu.remove(); + const hostData = this.hosts[inst.props.channelName]; const simplebarContentChildren = []; // Hosted Channel Header @@ -260,11 +286,11 @@ export default class Following extends SiteModule { simplebarContentChildren.push( e('a', { className: 'tw-interactable', - href: `/${inst.props.viewerCount.hostData.channel}`, + href: `/${hostData.channel}`, onclick: event => this.parent.hijackUserClick( event, - inst.props.viewerCount.hostData.channel, + hostData.channel, this.destroyHostMenu.bind(this) ) }, e('div', 'tw-align-items-center tw-flex tw-flex-row tw-flex-nowrap tw-mg-x-1 tw-mg-y-05', @@ -292,9 +318,8 @@ export default class Following extends SiteModule { ); // Hosting Channels Content - const hosts = this.hosts[inst.props.channelName]; - for (let i = 0; i < hosts.nodes.length; i++) { - const node = hosts.nodes[i]; + for (let i = 0; i < hostData.nodes.length; i++) { + const node = hostData.nodes[i]; simplebarContentChildren.push( e('a', { className: 'tw-interactable', @@ -346,16 +371,17 @@ export default class Following extends SiteModule { } updateChannelCard(inst) { - //if (!this.isRouteAcceptable()) return; - this.parent.updateUptime(inst, 'props.viewerCount.createdAt', '.tw-card .tw-aspect > div'); - + const container = this.fine.getHostNode(inst), card = container && container.querySelector && container.querySelector('.tw-card'); - + if ( container === null || card === null ) return; - + + if (inst.props.streamType === 'watch_party') + container.parentElement.classList.toggle('tw-hide', this.settings.get('directory.hide-vodcasts')); + // Remove old elements const hiddenBodyCard = card.querySelector('.tw-card-body.tw-hide'); if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('tw-hide'); @@ -367,11 +393,11 @@ export default class Following extends SiteModule { if (channelAvatar !== null) channelAvatar.remove(); if (inst.props.viewerCount.profileImageURL) { - const hosting = inst.props.channelNameLinkTo.state.content === 'live_host' && inst.props.viewerCount.hostData; + const hosting = inst.props.channelNameLinkTo.state.content === 'live_host' && this.hosts[inst.props.channelName]; let channel, displayName; if (hosting) { - channel = inst.props.viewerCount.hostData.channel; - displayName = inst.props.viewerCount.hostData.displayName; + channel = this.hosts[inst.props.channelName].channel; + displayName = inst.props.channelName; } const avatarSetting = this.settings.get('directory.show-channel-avatars'); @@ -380,11 +406,26 @@ export default class Following extends SiteModule { innerHTML: cardDiv.innerHTML }); + const broadcasterLogin = inst.props.linkTo.pathname.substring(1); + modifiedDiv.querySelector('.live-channel-card__channel').onclick = event => { + event.preventDefault(); + event.stopPropagation(); + + this.router.navigate('user', { userName: broadcasterLogin }); + }; + modifiedDiv.querySelector('.live-channel-card__videos').onclick = event => { + event.preventDefault(); + event.stopPropagation(); + + this.router.navigate('user-videos', { userName: broadcasterLogin }); + }; + let avatarDiv; if (avatarSetting === 1) { avatarDiv = e('a', { className: 'ffz-channel-avatar tw-mg-r-05 tw-mg-t-05', href: hosting ? `/${channel}` : inst.props.linkTo.pathname, + onclick: event => this.parent.hijackUserClick(event, broadcasterLogin) }, e('img', { title: inst.props.channelName, src: inst.props.viewerCount.profileImageURL @@ -393,7 +434,7 @@ export default class Following extends SiteModule { const avatarElement = e('a', { className: 'ffz-channel-avatar', href: hosting ? `/${channel}` : inst.props.linkTo.pathname, - onclick: event => this.parent.hijackUserClick(event, inst.props.streamNode.broadcaster.login) + onclick: event => this.parent.hijackUserClick(event, broadcasterLogin) }, e('div', 'live-channel-card__boxart tw-bottom-0 tw-absolute', e('figure', 'tw-aspect tw-aspect--align-top', e('img', { diff --git a/src/sites/twitch-twilight/modules/directory/index.js b/src/sites/twitch-twilight/modules/directory/index.js index 8bb89227..86ee08f5 100644 --- a/src/sites/twitch-twilight/modules/directory/index.js +++ b/src/sites/twitch-twilight/modules/directory/index.js @@ -106,6 +106,20 @@ export default class Directory extends SiteModule { this.ChannelCard.forceUpdate(); } }); + + + this.settings.add('directory.hide-vodcasts', { + default: false, + + ui: { + path: 'Directory > Channels >> Appearance', + title: 'Hide Vodcasts', + description: 'Hide vodcasts in the directories.', + component: 'setting-check-box' + }, + + changed: () => this.ChannelCard.forceUpdate() + }); } @@ -146,6 +160,10 @@ export default class Directory extends SiteModule { const hiddenPreview = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg'; const container = this.fine.getHostNode(inst); + + if (inst.props.streamNode.type === 'watch_party') + container.classList.toggle('tw-hide', this.settings.get('directory.hide-vodcasts')); + const img = container && container.querySelector && container.querySelector(`${uptimeSel} img`); if (img === null) return; diff --git a/src/sites/twitch-twilight/modules/host-options.vue b/src/sites/twitch-twilight/modules/host-options.vue new file mode 100644 index 00000000..8d4055c7 --- /dev/null +++ b/src/sites/twitch-twilight/modules/host-options.vue @@ -0,0 +1,171 @@ + + + + {{ t('metadata.host.title', 'Auto Host Management') }} + + + + + + + + + + + + + + {{ host.name }} + + + + + + + + + + + + + + + {{ t('metadata.host.add-channel', 'Add To Auto Host') }} + + + + + + + + + + + + + + {{ t('metadata.host.setting.auto-hosting.title', 'Auto Hosting') }} + + + + {{ t('metadata.host.setting.auto-hosting.description', 'Toggle all forms of auto hosting: teammates, host list, and similar channels.') }} + {{ t('metadata.host.setting.auto-hosting.link', 'Learn More') }} + + + + + + + {{ t('metadata.host.setting.team-hosting.title', 'Team Hosting') }} + + + + {{ t('metadata.host.setting.team-hosting.description', + 'Automatically host random channels from your team when you\'re not live. ' + + 'Team channels will be hosted before any channels in your host list.') }} + + + + + + + {{ t('metadata.host.setting.vodcast-hosting.title', 'Vodcast Hosting') }} + + + + {{ t('metadata.host.setting.vodcast-hosting.description', 'Include Vodcasts in auto host.') }} + {{ t('metadata.host.setting.vodcast-hosting.link', 'Learn about Vodcasts') }} + + + + + + + {{ t('metadata.host.setting.recommended-hosting.title', 'Auto Host Channels Similar To Yours') }} + + + + {{ t('metadata.host.setting.recommended-hosting.description', 'Streamers on your primary team & host list will always be hosted first') }} + + + + + + + {{ t('metadata.host.setting.strategy.title', 'Randomize Host Order') }} + + + + {{ t('metadata.host.setting.strategy.description', + 'If enabled, auto-hosts will be picked at random. ' + + 'Otherwise they\'re picked in order.') }} + + + + + + + + + + + + + + diff --git a/src/sites/twitch-twilight/modules/host_button.js b/src/sites/twitch-twilight/modules/host_button.js new file mode 100644 index 00000000..1dad0baf --- /dev/null +++ b/src/sites/twitch-twilight/modules/host_button.js @@ -0,0 +1,422 @@ +'use strict'; + +// ============================================================================ +// Host Button +// ============================================================================ + +import Module from 'utilities/module'; +import {createElement as e} from 'utilities/dom'; + +const HOST_ERRORS = { + COMMAND_EXECUTION: { + key: 'command-execution', + text: 'There was an error executing the host command. Please try again later.', + }, + CHAT_CONNECTION: { + key: 'chat-connection', + text: 'There was an issue connecting to chat. Please try again later.', + } +}; + +export default class HostButton extends Module { + constructor(...args) { + super(...args); + + this.should_enable = true; + + this.inject('site'); + this.inject('site.fine'); + this.inject('site.chat'); + this.inject('i18n'); + this.inject('metadata'); + this.inject('settings'); + + this.settings.add('metadata.host-button', { + default: true, + + ui: { + path: 'Channel > Metadata >> Player', + title: 'Host Button', + description: 'Show a host button with the current hosted channel in the tooltip.', + component: 'setting-check-box' + }, + + changed: () => { + const ffz_user = this.site.getUser(), + userLogin = ffz_user && ffz_user.login; + + if (userLogin) + this.joinChannel(userLogin); + + this.metadata.updateMetadata('host'); + } + }); + + this.metadata.definitions.host = { + order: 150, + button: true, + + disabled: () => { + return this._host_updating || this._host_error; + }, + + click: data => { + if (data.channel) this.sendHostUnhostCommand(data.channel.login); + }, + + popup: async (data, tip) => { + const vue = this.resolve('vue'), + _host_options_vue = import(/* webpackChunkName: "host-options" */ './host-options.vue'), + _autoHosts = this.fetchAutoHosts(), + _autoHostSettings = this.fetchAutoHostSettings(); + + const [, host_options_vue, autoHosts, autoHostSettings] = await Promise.all([vue.enable(), _host_options_vue, _autoHosts, _autoHostSettings]); + + this._auto_host_tip = tip; + tip.element.classList.remove('tw-pd-1'); + tip.element.classList.add('tw-balloon--lg'); + vue.component('host-options', host_options_vue.default); + return this.buildAutoHostMenu(vue, autoHosts, autoHostSettings, data.channel); + }, + + label: data => { + if (!this.settings.get('metadata.host-button')) { + return ''; + } + + const ffz_user = this.site.getUser(), + userLogin = ffz_user && ffz_user.login; + + if (data.channel && data.channel.login === userLogin) { + return ''; + } + + if (this._host_updating) { + return 'Updating...'; + } + + return (this._last_hosted_channel && this.isChannelHosted(data.channel && data.channel.login)) + ? this.i18n.t('metadata.host.button.unhost', 'Unhost') + : this.i18n.t('metadata.host.button.host', 'Host'); + }, + + tooltip: () => { + if (this._host_error) { + return this.i18n.t( + `metadata.host.button.tooltip.error.${this._host_error.key}`, + this._host_error.text); + } else { + return this.i18n.t('metadata.host.button.tooltip', + 'Currently hosting: %{channel}', + { + channel: this._last_hosted_channel || this.i18n.t('metadata.host.button.tooltip.none', 'None') + }); + } + } + }; + } + + isChannelHosted(channelLogin) { + return this._last_hosted_channel === channelLogin; + } + + sendHostUnhostCommand(channel) { + if (!this._chat_con) { + this._host_error = HOST_ERRORS.CHAT_CONNECTION; + this._host_updating = false; + return; + } + + const ffz_user = this.site.getUser(), + userLogin = ffz_user && ffz_user.login; + + const commandData = {channel: userLogin, username: channel}; + + this._host_updating = true; + this.metadata.updateMetadata('host'); + + this._host_feedback = setTimeout(() => { + if (this._last_hosted_channel === null) { + this._host_error = HOST_ERRORS.COMMAND_EXECUTION; + this._host_updating = false; + this.metadata.updateMetadata('host'); + } + }, 3000); + + if (this.isChannelHosted(channel)) { + this._chat_con.commands.unhost.execute(commandData); + } else { + this._chat_con.commands.host.execute(commandData); + } + } + + joinChannel(channel) { + if (this._chat_con) { + if (this.settings.get('metadata.host-button') && !this._chat_con.session.channelstate[`#${channel}`]) { + this._chat_con.joinChannel(channel); + } + } + } + + hookIntoChatConnection(inst) { + const userLogin = inst.props.userLogin; + + if (this._chat_con) { + this.joinChannel(userLogin); + return; + } + + this.on('tmi:host', e => { + if (e.channel.substring(1) !== userLogin) return; + + clearTimeout(this._host_feedback); + this._host_error = false; + this._last_hosted_channel = e.target; + + this._host_updating = false; + this.metadata.updateMetadata('host'); + }); + + this.on('tmi:unhost', e => { + if (e.channel.substring(1) !== userLogin) return; + + clearTimeout(this._host_feedback); + this._host_error = false; + this._last_hosted_channel = null; + + this._host_updating = false; + this.metadata.updateMetadata('host'); + }); + + const chatServiceClient = inst.chatService.client; + + this._chat_con = chatServiceClient; + if (this.settings.get('metadata.host-button')) + this.joinChannel(userLogin); + } + + onEnable() { + this.metadata.updateMetadata('host'); + + this.chat.ChatController.ready((cls, instances) => { + for(const inst of instances) { + if (inst && inst.chatService) this.hookIntoChatConnection(inst); + } + }); + + this.chat.ChatController.on('mount', this.hookIntoChatConnection, this); + } + + buildAutoHostMenu(vue, hosts, autoHostSettings, data) { + this._current_channel_id = data.id; + this.activeTab = this.activeTab || 'auto-host'; + + this.vueEl = new vue.Vue({ + el: e('div'), + render: h => h('host-options', { + hosts, + autoHostSettings, + activeTab: this.activeTab, + + addedToHosts: this.currentRoomInHosts(), + addToAutoHosts: () => this.addCurrentRoomToHosts(), + rearrangeHosts: event => this.rearrangeHosts(event.oldIndex, event.newIndex), + removeFromHosts: event => this.removeUserFromHosts(event), + setActiveTab: tab => { + this.vueEl.$children[0]._data.activeTab = this.activeTab = tab; + }, + updatePopper: () => { + if (this._auto_host_tip) this._auto_host_tip.update(); + }, + updateCheckbox: e => { + const t = e.target, + setting = t.dataset.setting; + let state = t.checked; + + if ( setting === 'strategy' ) + state = state ? 'random' : 'ordered'; + else if ( setting === 'deprioritize_vodcast' ) + state = ! state; + + this.updateAutoHostSetting(setting, state); + } + }) + }); + + return this.vueEl.$el; + } + + async fetchAutoHosts() { + const user = this.site.getUser(); + if ( ! user ) + return; + + let data; + try { + data = await fetch('https://api.twitch.tv/kraken/autohost/list', { + headers: { + 'Accept': 'application/vnd.twitchtv.v4+json', + 'Authorization': `OAuth ${user.authToken}` + } + }).then(r => { + if ( r.ok ) + return r.json(); + + throw r.status; + }); + + } catch(err) { + this.log.error('Error loading auto host list.', err); + return; + } + + return this.autoHosts = data.targets; + } + + async fetchAutoHostSettings() { + const user = this.site.getUser(); + if ( ! user ) + return; + + let data; + try { + data = await fetch('https://api.twitch.tv/kraken/autohost/settings', { + headers: { + 'Accept': 'application/vnd.twitchtv.v4+json', + 'Authorization': `OAuth ${user.authToken}` + } + }).then(r => { + if ( r.ok ) + return r.json(); + + throw r.status; + }); + + } catch(err) { + this.log.error('Error loading auto host settings.', err); + return; + } + + return this.autoHostSettings = data.settings; + } + + queueHostUpdate() { + if (this._host_update_timer) clearTimeout(this._host_update_timer); + + this._host_update_timer = setTimeout(() => { + this._host_update_timer = undefined; + this.updateAutoHosts(this.autoHosts); + }, 1000); + } + + rearrangeHosts(oldIndex, newIndex) { + const host = this.autoHosts.splice(oldIndex, 1)[0]; + this.autoHosts.splice(newIndex, 0, host); + + this.queueHostUpdate(); + } + + currentRoomInHosts() { + return this.getAutoHostIDs(this.autoHosts).includes(parseInt(this._current_channel_id, 10)); + } + + addCurrentRoomToHosts() { + const newHosts = this.autoHosts.slice(0); + newHosts.push({ _id: parseInt(this._current_channel_id, 10)}); + + this.updateAutoHosts(newHosts); + } + + removeUserFromHosts(event) { + const id = event.target.closest('.ffz--host-user').dataset.id; + + const newHosts = []; + for (let i = 0; i < this.autoHosts.length; i++) { + if (this.autoHosts[i]._id != id) newHosts.push(this.autoHosts[i]); + } + + this.updateAutoHosts(newHosts); + } + + getAutoHostIDs(hosts) { // eslint-disable-line class-methods-use-this + const ids = []; + if (hosts) { + for (let i = 0; i < hosts.length; i++) { + ids.push(hosts[i]._id); + } + } + return ids; + } + + async updateAutoHosts(newHosts) { + const user = this.site.getUser(); + if ( ! user ) + return; + + let data; + try { + const form = new URLSearchParams(); + const autoHosts = this.getAutoHostIDs(newHosts); + form.append('targets', autoHosts.join(',')); + + data = await fetch('https://api.twitch.tv/kraken/autohost/list', { + headers: { + 'Accept': 'application/vnd.twitchtv.v4+json', + 'Authorization': `OAuth ${user.authToken}` + }, + method: autoHosts.length ? 'PUT' : 'DELETE', + body: autoHosts.length ? form : undefined + }).then(r => { + if ( r.ok ) + return r.json(); + + throw r.status; + }); + + } catch(err) { + this.log.error('Error updating auto host list.', err); + return; + } + + this.autoHosts = data.targets; + if (this.vueEl) { + this.vueEl.$children[0]._data.hosts = this.autoHosts; + this.vueEl.$children[0]._data.addedToHosts = this.currentRoomInHosts(); + } + } + + async updateAutoHostSetting(setting, newValue) { + const user = this.site.getUser(); + if ( ! user ) + return; + + let data; + try { + const form = new URLSearchParams(); + form.append(setting, newValue); + + data = await fetch('https://api.twitch.tv/kraken/autohost/settings', { + headers: { + 'Accept': 'application/vnd.twitchtv.v4+json', + 'Authorization': `OAuth ${user.authToken}` + }, + method: 'PUT', + body: form + }).then(r => { + if ( r.ok ) + return r.json(); + + throw r.status; + }); + + } catch(err) { + this.log.error('Error updating auto host setting.', err); + return; + } + + this.autoHostSettings = data.settings; + if (this.vueEl) { + this.vueEl.$children[0]._data.autoHostSettings = this.autoHostSettings; + } + } +} \ No newline at end of file diff --git a/src/sites/twitch-twilight/styles/host_options.scss b/src/sites/twitch-twilight/styles/host_options.scss new file mode 100644 index 00000000..a917a704 --- /dev/null +++ b/src/sites/twitch-twilight/styles/host_options.scss @@ -0,0 +1,53 @@ +.ffz-auto-host-options { + .scrollable-area { + max-height: 25vh; + } + + .handle { + cursor: move; + cursor: -webkit-grabbing; + } + + .sortable-ghost { + opacity: 0.4; + } + + .ffz--host-user { + .handle { + padding: 0 0.4rem 0 0; + } + + .ffz--host-remove-user { + > figure { + padding: 0.4rem 0.2rem; + } + + &:hover { background: #a94444 !important } + } + } + + > header { + padding: .9rem 1rem .9rem 2rem; + } + + .ffz-checkbox-description { + padding-left: 2.2rem; + } + + .host-options__tabs-container { + height: 3rem; + + > .host-options__tab { + position: relative; + top: -.1rem; + cursor: pointer; + display: inline-block; + line-height: 3rem; + margin-right: .5rem; + + &:hover, &.active { + border-top: 1px solid #6441a4; + } + } + } +} diff --git a/src/sites/twitch-twilight/styles/main.scss b/src/sites/twitch-twilight/styles/main.scss index 95eb4972..9d5ce81e 100644 --- a/src/sites/twitch-twilight/styles/main.scss +++ b/src/sites/twitch-twilight/styles/main.scss @@ -8,4 +8,6 @@ @import 'chat'; @import 'directory'; -@import 'fixes'; \ No newline at end of file +@import 'fixes'; + +@import 'host_options'; \ No newline at end of file
{{ host.name }}