1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-02 16:08:31 +00:00

Hide Vodcasts & Host Button (#353)

* Fix occasional vanishing of stream card uptime, avatar and other info

* Additionally fix routing issues with the modified avatar div

* Add hide vodcast functionality

* Add `vuedraggable` to dependencies

* Remove LegacyAPI

* Add host button with aut-host management menu

Messy code, but it works.

* Only add `disabled` class to button if chat connection isn't there

* Fix for host button not updating it's text properly after un-/hosting

* Add tooltip to host button and use alternative way to get the chat inst.

* Rework host button stuff into new metadata

All is functional, even though the code might be a mess.

* Implement Auto-Host settings tab

* Fix reassignment to const, hehe

* Custom TMI events for Host and Unhost, plus use existing chat connection

* Code adjustments, disabling the button when host is loading, etc.

* Address code-review suggestions

Translation support and a few other fixes

* Remove inline styling

* Show error in tooltip if hosting didn't work properly or similar issues

* Address change requests

* Fix mixup

* Fix host options not having a background

* Fix styling for the host options

This adds a small border

* Hide host button on own channel

* Fix popper

* Move `isChannelHosted` method further up

* Adjust handle of auto host menu and fix vodcast hiding

* Replace loading icon with text

* Add setting for host button; Also another small issue

* Fix joining your own channel multiple times
This commit is contained in:
Lordmau5 2017-12-14 05:43:56 +01:00 committed by Mike
parent ac35ee5fab
commit 941aab9feb
11 changed files with 767 additions and 367 deletions

View file

@ -42,6 +42,7 @@
"sortablejs": "^1.6.1", "sortablejs": "^1.6.1",
"vue": "^2.5.2", "vue": "^2.5.2",
"vue-clickaway": "^2.1.0", "vue-clickaway": "^2.1.0",
"vue-template-compiler": "^2.5.2" "vue-template-compiler": "^2.5.2",
"vuedraggable": "^2.15.0"
} }
} }

View file

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

View file

@ -9,7 +9,6 @@ import SettingsManager from './settings/index';
import {TranslationManager} from './i18n'; import {TranslationManager} from './i18n';
import SocketClient from './socket'; import SocketClient from './socket';
import Site from 'site'; import Site from 'site';
import LegacyAPI from './api';
import Vue from 'utilities/vue'; import Vue from 'utilities/vue';
class FrankerFaceZ extends Module { class FrankerFaceZ extends Module {
@ -39,8 +38,6 @@ class FrankerFaceZ extends Module {
this.inject('socket', SocketClient); this.inject('socket', SocketClient);
this.inject('site', Site); this.inject('site', Site);
this.inject('_api', LegacyAPI);
this.register('vue', Vue); this.register('vue', Vue);
@ -121,13 +118,3 @@ FrankerFaceZ.utilities = {
window.FrankerFaceZ = FrankerFaceZ; window.FrankerFaceZ = FrankerFaceZ;
window.ffz = new 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: {}};

View file

@ -13,15 +13,6 @@ import SettingsContext from './context';
import MigrationManager from './migration'; 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 // SettingsManager
// ============================================================================ // ============================================================================
@ -331,12 +322,7 @@ export default class SettingsManager extends Module {
// ======================================================================== // ========================================================================
context(env) { return this.main_context.context(env) } context(env) { return this.main_context.context(env) }
get(key) { get(key) { return this.main_context.get(key); }
if ( has(OVERRIDE_GET, key) )
return OVERRIDE_GET[key];
return this.main_context.get(key);
}
uses(key) { return this.main_context.uses(key) } uses(key) { return this.main_context.uses(key) }
update(key) { return this.main_context.update(key) } update(key) { return this.main_context.update(key) }

View file

@ -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) { this.postMessage = function(e) {
const original = this._wrapped; const original = this._wrapped;
if ( original ) { if ( original ) {

View file

@ -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( this.ChannelCard = this.fine.define(
'following-channel-card', 'following-channel-card',
n => n.renderGameBoxArt && n.renderContentType n => n.renderGameBoxArt && n.renderContentType
@ -121,11 +135,13 @@ export default class Following extends SiteModule {
this.modifyLiveUsers(res); this.modifyLiveUsers(res);
this.modifyLiveHosts(res); this.modifyLiveHosts(res);
}, false); }, false);
this.on('settings:changed:directory.uptime', () => this.ChannelCard.forceUpdate()); 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-channel-avatars', () => this.ChannelCard.forceUpdate());
this.on('settings:changed:directory.show-boxart', () => 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('FollowingLive_CurrentUser', res => this.modifyLiveUsers(res), false);
this.apollo.registerModifier('FollowingHosts_CurrentUser', res => this.modifyLiveHosts(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); const s = node.hosting.stream.viewersCount = new Number(node.hosting.stream.viewersCount || 0);
s.profileImageURL = node.hosting.profileImageURL; s.profileImageURL = node.hosting.profileImageURL;
s.createdAt = node.hosting.stream.createdAt; s.createdAt = node.hosting.stream.createdAt;
s.hostData = {
channel: node.hosting.login,
displayName: node.hosting.displayName
};
if (!this.hosts[node.hosting.displayName]) { if (!this.hosts[node.hosting.displayName]) {
this.hosts[node.hosting.displayName] = { this.hosts[node.hosting.displayName] = {
channel: node.hosting.login,
nodes: [node], nodes: [node],
channels: [node.displayName] channels: [node.displayName]
}; };
@ -194,36 +207,48 @@ export default class Following extends SiteModule {
return res; 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() { onEnable() {
this.ChannelCard.ready((cls, instances) => { this.ChannelCard.ready((cls, instances) => {
if (this.router && this.router.match) { this.ensureQueries();
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'
);
}
}
for(const inst of instances) this.updateChannelCard(inst); for(const inst of instances) this.updateChannelCard(inst);
}); });
this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this); this.ChannelCard.on('update', inst => {
this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this); this.ensureQueries();
this.ChannelCard.on('unmount', inst => this.parent.clearUptime(inst), this); 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)); document.body.addEventListener('click', this.destroyHostMenu.bind(this));
} }
@ -246,6 +271,7 @@ export default class Following extends SiteModule {
this.hostMenu && this.hostMenu.remove(); this.hostMenu && this.hostMenu.remove();
const hostData = this.hosts[inst.props.channelName];
const simplebarContentChildren = []; const simplebarContentChildren = [];
// Hosted Channel Header // Hosted Channel Header
@ -260,11 +286,11 @@ export default class Following extends SiteModule {
simplebarContentChildren.push( simplebarContentChildren.push(
e('a', { e('a', {
className: 'tw-interactable', className: 'tw-interactable',
href: `/${inst.props.viewerCount.hostData.channel}`, href: `/${hostData.channel}`,
onclick: event => onclick: event =>
this.parent.hijackUserClick( this.parent.hijackUserClick(
event, event,
inst.props.viewerCount.hostData.channel, hostData.channel,
this.destroyHostMenu.bind(this) 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', }, 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 // Hosting Channels Content
const hosts = this.hosts[inst.props.channelName]; for (let i = 0; i < hostData.nodes.length; i++) {
for (let i = 0; i < hosts.nodes.length; i++) { const node = hostData.nodes[i];
const node = hosts.nodes[i];
simplebarContentChildren.push( simplebarContentChildren.push(
e('a', { e('a', {
className: 'tw-interactable', className: 'tw-interactable',
@ -346,16 +371,17 @@ export default class Following extends SiteModule {
} }
updateChannelCard(inst) { updateChannelCard(inst) {
//if (!this.isRouteAcceptable()) return;
this.parent.updateUptime(inst, 'props.viewerCount.createdAt', '.tw-card .tw-aspect > div'); this.parent.updateUptime(inst, 'props.viewerCount.createdAt', '.tw-card .tw-aspect > div');
const container = this.fine.getHostNode(inst), const container = this.fine.getHostNode(inst),
card = container && container.querySelector && container.querySelector('.tw-card'); card = container && container.querySelector && container.querySelector('.tw-card');
if ( container === null || card === null ) if ( container === null || card === null )
return; return;
if (inst.props.streamType === 'watch_party')
container.parentElement.classList.toggle('tw-hide', this.settings.get('directory.hide-vodcasts'));
// Remove old elements // Remove old elements
const hiddenBodyCard = card.querySelector('.tw-card-body.tw-hide'); const hiddenBodyCard = card.querySelector('.tw-card-body.tw-hide');
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('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 (channelAvatar !== null) channelAvatar.remove();
if (inst.props.viewerCount.profileImageURL) { 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; let channel, displayName;
if (hosting) { if (hosting) {
channel = inst.props.viewerCount.hostData.channel; channel = this.hosts[inst.props.channelName].channel;
displayName = inst.props.viewerCount.hostData.displayName; displayName = inst.props.channelName;
} }
const avatarSetting = this.settings.get('directory.show-channel-avatars'); const avatarSetting = this.settings.get('directory.show-channel-avatars');
@ -380,11 +406,26 @@ export default class Following extends SiteModule {
innerHTML: cardDiv.innerHTML 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; let avatarDiv;
if (avatarSetting === 1) { if (avatarSetting === 1) {
avatarDiv = e('a', { avatarDiv = e('a', {
className: 'ffz-channel-avatar tw-mg-r-05 tw-mg-t-05', className: 'ffz-channel-avatar tw-mg-r-05 tw-mg-t-05',
href: hosting ? `/${channel}` : inst.props.linkTo.pathname, href: hosting ? `/${channel}` : inst.props.linkTo.pathname,
onclick: event => this.parent.hijackUserClick(event, broadcasterLogin)
}, e('img', { }, e('img', {
title: inst.props.channelName, title: inst.props.channelName,
src: inst.props.viewerCount.profileImageURL src: inst.props.viewerCount.profileImageURL
@ -393,7 +434,7 @@ export default class Following extends SiteModule {
const avatarElement = e('a', { const avatarElement = e('a', {
className: 'ffz-channel-avatar', className: 'ffz-channel-avatar',
href: hosting ? `/${channel}` : inst.props.linkTo.pathname, 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('div', 'live-channel-card__boxart tw-bottom-0 tw-absolute',
e('figure', 'tw-aspect tw-aspect--align-top', e('figure', 'tw-aspect tw-aspect--align-top',
e('img', { e('img', {

View file

@ -106,6 +106,20 @@ export default class Directory extends SiteModule {
this.ChannelCard.forceUpdate(); 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 hiddenPreview = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
const container = this.fine.getHostNode(inst); 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`); const img = container && container.querySelector && container.querySelector(`${uptimeSel} img`);
if (img === null) return; if (img === null) return;

View file

@ -0,0 +1,171 @@
<template lang="html">
<div class="ffz-auto-host-options tw-c-background">
<header class="tw-full-width tw-align-items-center tw-flex tw-flex-nowrap">
<h4>{{ t('metadata.host.title', 'Auto Host Management') }}</h4>
</header>
<div class="tab tw-overflow-hidden"
v-show="activeTab === 'auto-host'"
:class="{ active: activeTab === 'auto-host'}">
<section class="tw-border-t tw-full-width tw-full-height">
<main class="tw-flex-grow-1 scrollable-area" data-simplebar="init">
<div class="simplebar-scroll-content">
<draggable v-model="hosts" class="simplebar-content" :options="{
draggable: '.ffz--host-user',
animation: 150,
}" @update="rearrangeHosts">
<div v-for="host in hosts" class="tw-border-t ffz--host-user" :key="host._id" :data-id="host._id">
<div class="tw-interactable">
<div class="tw-align-items-center tw-flex tw-flex-row tw-flex-nowrap tw-mg-x-1">
<figure class="ffz-i-ellipsis-vert handle"></figure>
<div class="ffz-channel-avatar">
<img :src="host.logo" :alt="host.display_name + '(' + host.name + ')'">
</div>
<p class="tw-ellipsis tw-flex-grow-1 tw-mg-l-1 tw-font-size-5">{{ host.name }}</p>
<div class="tw-flex-grow-1 tw-pd-x-2"></div>
<button class="tw-button-icon tw-mg-x-05 ffz--host-remove-user" @click="removeFromHosts">
<figure class="ffz-i-trash"></figure>
</button>
</div>
</div>
</div>
</draggable>
</div>
</main>
</section>
<header class="tw-border-t tw-full-width tw-align-items-center tw-flex tw-flex-noxwrap tw-pd-1">
<div class="tw-flex-grow-1 tw-pd-x-2"></div>
<button class="tw-button tw-button--hollow tw-mg-x-05" :class="{'tw-button--disabled': addedToHosts}" @click="addToAutoHosts">
<span class="tw-button__text">{{ t('metadata.host.add-channel', 'Add To Auto Host') }}</span>
</button>
</header>
</div>
<div class="tab tw-overflow-hidden"
v-show="activeTab === 'settings'"
:class="{ active: activeTab === 'settings'}">
<section class="tw-border-t tw-full-width tw-full-height">
<main class="tw-flex-grow-1 scrollable-area" data-simplebar="init">
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<div class="tw-pd-1">
<div class="ffz--widget ffz--checkbox">
<div class="tw-flex tw-align-items-center">
<input type="checkbox" class="tw-checkbox__input"
id="autoHostSettings:enabled"
data-setting="enabled"
:checked="autoHostSettings.enabled"
@change="updateCheckbox">
<label for="autoHostSettings:enabled" class="tw-checkbox__label">
{{ t('metadata.host.setting.auto-hosting.title', 'Auto Hosting') }}
</label>
</div>
<section class="tw-c-text-alt-2 ffz-checkbox-description">
{{ t('metadata.host.setting.auto-hosting.description', 'Toggle all forms of auto hosting: teammates, host list, and similar channels.') }}<br>
<a href="https://blog.twitch.tv/grow-your-community-with-auto-hosting-e80c1460f6e1" target="_blank" rel="noopener">{{ t('metadata.host.setting.auto-hosting.link', 'Learn More') }}</a>
</section>
</div>
<div class="ffz--widget ffz--checkbox">
<div class="tw-flex tw-align-items-center">
<input type="checkbox" class="tw-checkbox__input"
id="autoHostSettings:team_host"
data-setting="team_host"
:checked="autoHostSettings.team_host"
@change="updateCheckbox">
<label for="autoHostSettings:team_host" class="tw-checkbox__label">
{{ t('metadata.host.setting.team-hosting.title', 'Team Hosting') }}
</label>
</div>
<section class="tw-c-text-alt-2 ffz-checkbox-description">
{{ 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.') }}
</section>
</div>
<div class="ffz--widget ffz--checkbox">
<div class="tw-flex tw-align-items-center">
<input type="checkbox" class="tw-checkbox__input"
id="autoHostSettings:vodcast_hosting"
data-setting="deprioritize_vodcast"
:checked="!autoHostSettings.deprioritize_vodcast"
@change="updateCheckbox">
<label for="autoHostSettings:vodcast_hosting" class="tw-checkbox__label">
{{ t('metadata.host.setting.vodcast-hosting.title', 'Vodcast Hosting') }}
</label>
</div>
<section class="tw-c-text-alt-2 ffz-checkbox-description">
{{ t('metadata.host.setting.vodcast-hosting.description', 'Include Vodcasts in auto host.') }}
<a href="https://blog.twitch.tv/vodcast-brings-the-twitch-community-experience-to-uploads-54098498715" target="_blank" rel="noopener">{{ t('metadata.host.setting.vodcast-hosting.link', 'Learn about Vodcasts') }}</a>
</section>
</div>
<div class="ffz--widget ffz--checkbox">
<div class="tw-flex tw-align-items-center">
<input type="checkbox" class="tw-checkbox__input"
id="autoHostSettings:recommended_host"
data-setting="recommended_host"
:checked="autoHostSettings.recommended_host"
@change="updateCheckbox">
<label for="autoHostSettings:recommended_host" class="tw-checkbox__label">
{{ t('metadata.host.setting.recommended-hosting.title', 'Auto Host Channels Similar To Yours') }}
</label>
</div>
<section class="tw-c-text-alt-2 ffz-checkbox-description">
{{ t('metadata.host.setting.recommended-hosting.description', 'Streamers on your primary team &amp; host list will always be hosted first') }}
</section>
</div>
<div class="ffz--widget ffz--checkbox">
<div class="tw-flex tw-align-items-center">
<input type="checkbox" class="tw-checkbox__input"
id="autoHostSettings:strategy"
data-setting="strategy"
:checked="autoHostSettings.strategy === 'random'"
@change="updateCheckbox">
<label for="autoHostSettings:strategy" class="tw-checkbox__label">
{{ t('metadata.host.setting.strategy.title', 'Randomize Host Order') }}
</label>
</div>
<section class="tw-c-text-alt-2 ffz-checkbox-description">
{{ t('metadata.host.setting.strategy.description',
'If enabled, auto-hosts will be picked at random. ' +
'Otherwise they\'re picked in order.') }}
</section>
</div>
</div>
</div>
</div>
</main>
</section>
</div>
<footer>
<div class="host-options__tabs-container tw-border-t">
<div id="host-options__auto-host" class="host-options__tab tw-pd-x-1"
@click="setActiveTab('auto-host')"
:class="{active: activeTab === 'auto-host'}">
<span>{{ t('metadata.host.tab.auto-host', 'Auto Host') }}</span>
</div>
<div id="host-options__settings" class="host-options__tab tw-pd-x-1"
@click="setActiveTab('settings')"
:class="{active: activeTab === 'settings'}">
<span>{{ t('metadata.host.tab.settings', 'Settings') }}</span>
</div>
</div>
</footer>
</div>
</template>
<script>
import draggable from 'vuedraggable';
export default {
components: {
draggable
},
data() {
return this.$vnode.data;
},
updated() {
this.updatePopper();
}
}
</script>

View file

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

View file

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

View file

@ -8,4 +8,6 @@
@import 'chat'; @import 'chat';
@import 'directory'; @import 'directory';
@import 'fixes'; @import 'fixes';
@import 'host_options';