LinogeFly / Hide unwanted streams on Twitch

// ==UserScript==
// @name         Hide unwanted streams on Twitch
// @description  Blocks content that you don't want to see on twitch.tv, such as channels, games, videos etc.
// @namespace    https://github.com/LinogeFly/hide-unwanted-streams-on-twitch
// @version      1.3.28
// @author       LinogeFly
// @license      MIT
// @supportURL   https://www.reddit.com/message/compose/?to=LinogeFly
// @include      http://*.twitch.tv/*
// @include      https://*.twitch.tv/*
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.deleteValue
// @run-at       document-start
// @require      https://code.jquery.com/jquery-1.11.2.min.js
// @require      https://www.promisejs.org/polyfills/promise-6.1.0.min.js
// ==/UserScript==

// Constants

var husot = husot || {};

husot.constants = husot.constants || {};
husot.constants.blockedChannelsSettingsKey = 'blockedChannels';
husot.constants.blockedGamesSettingsKey = 'blockedGames';
husot.constants.blockedChannelsListEmpty = 'No Blocked Channels';
husot.constants.blockedGamesListEmpty = 'No Blocked Games';
husot.constants.modalDialogShowingSpeed = 150;
husot.constants.allowedUrls = [
    '^https?://([a-zA-Z]+\.)?twitch.tv/directory/?$',
    '^https?://([a-zA-Z]+\.)?twitch.tv/directory/all(/?|/.+)$',
    '^https?://([a-zA-Z]+\.)?twitch.tv/directory/game/.+',
    '^https?://([a-zA-Z]+\.)?twitch.tv/communities/[^/]+/?$'
];
husot.constants.blockedItemType = husot.constants.blockedItemType || {};
husot.constants.blockedItemType.game = 'game';
husot.constants.blockedItemType.channel = 'channel';

// DOM Listener module

var husot = husot || {};
husot.domListener = husot.domListener || {};

husot.domListener = (function () {
    var MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
    var observer = new MutationObserver(function (mutations) {
        // Don't process page if its URL is not allowed
        if (!isUrlAllowed(document.URL)) {
            return;
        }

        modifyThumbs(mutations, husot.thumbs.streamThumbsManager);
        modifyThumbs(mutations, husot.thumbs.gameThumbsManager);
    });

    function start() {
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function stop() {
        observer.disconnect();
    }

    function modifyThumbs(mutations, thumbsManager) {
        // Process thumbs if they were added to the DOM
        if (!isThumbsAdded(mutations, thumbsManager.getDomListnerThumbSelector())) { return }

        // Add overlay menu
        stop();
        thumbsManager.addThumbOverlays();
        start();

        // Hide blocked thumbs
        thumbsManager.hideThumbs();
    }

    function isUrlAllowed(url) {
        return husot.constants.allowedUrls.some(function (item) {
            return (new RegExp(item)).test(decodeURIComponent(url));
        });
    }

    function isThumbsAdded(mutations, selector) {
        return mutations.some(function (item) {
            return $(item.addedNodes).find(selector).filter(function () {
                // Check that thumbnail is hidden explicitly and not because an ancestor element is hidden
                return $(this).css('display') !== 'none';
            }).length !== 0;
        });
    }

    return {
        start: start,
        isUrlAllowed: isUrlAllowed
    };
})();

// Exceptions

var husot = husot || {};
husot.exceptions = husot.exceptions || {};

husot.exceptions.abstractFunctionCall = function () {
    return 'Cannot call abstract function'
};

husot.exceptions.notImplemented = function () {
    return 'Method or operation is not implemented'
};

husot.exceptions.argumentNullOrEmpty = function (argumentName) {
    return 'Argument "{0}" is undefined or empty'.format(argumentName);
};

husot.exceptions.argumentOneElementExpected = function (argumentName) {
    return 'More than one element in argument "{0}"'.format(argumentName);
};

husot.exceptions.elementNotFound = function (elementName) {
    return '{0} not found. CSS selector must be broken.'.format(elementName);
};

husot.exceptions.elementNotFoundFor = function (elementName, forName) {
    return '{0} not found for {1}. CSS selector must be broken.'.format(elementName, forName);
};

// Helper functions

if (!String.format) {
    String.prototype.format = function () {
        var args = arguments;
        return this.replace(/{(\d+)}/g, function (match, number) {
            return typeof args[number] != 'undefined' ? args[number] : match;
        });
    };
};

if (!String.trimSlash) {
    String.prototype.trimSlash = function () {
        return this.replace(/^\/|\/$/g, '');
    };
};

// HTML Templates
// TODO: Move into separate files

var husot = husot || {};
husot.htmlLayout = husot.htmlLayout || {};

husot.htmlLayout.streamOverlay = '\
    <div class="husot-thumbOverlay">\
        <ul class="husot-thumbOverlay-menu">\
            <li><a class="husot-blockStreamBtn" href="javascript:void(0);">Block</a></li>\
            <li class="husot-thumbOverlay-menu-separator"> | </li>\
            <li><a class="husot-showSettingsBtn" href="javascript:void(0);">Settings</a></li>\
        </ul>\
    </div>';

husot.htmlLayout.settingsWindow = '\
    <div class="husot-settings husot-modalWindow">\
        <ul class="husot-settings-nav">\
            <li class="husot-settings-nav-item">\
                <a class="husot-settings-nav-item-name" data-husot-contentPanelId="husot-settings-blockedChannelsList">Blocked Channels</a>\
            </li>\
            <li class="husot-settings-nav-item">\
                <a class="husot-settings-nav-item-name" data-husot-contentPanelId="husot-settings-blockedGamesList">Blocked Games</a>\
            </li>\
        </ul>\
        <ul class="husot-settings-blockedList" id="husot-settings-blockedChannelsList"></ul>\
        <ul class="husot-settings-blockedList" id="husot-settings-blockedGamesList"></ul>\
        <div class="husot-settings-footer">\
            <a href="#" class="husot-modalClose husot-button">Close</a>\
        </div>\
    </div>';

husot.htmlLayout.modalDialogOverlay = '<div class="husot-modalOverlay"></div>';

husot.htmlLayout.blockedListItem = '\
    <li class="husot-settings-blockedList-item">\
        <div class="husot-settings-blockedList-item-name">{0}</div>\
        <a class="husot-settings-blockedList-item-unblockBtn husot-button" href="javascript:void(0);">Unblock</a>\
    </li>';

husot.htmlLayout.blockedListItemEmpty = '<li><div class="husot-settings-blockedList-item-empty">{0}</div></li>';

// Log manager

var husot = husot || {};
husot.log = husot.log || {};

husot.log.info = function (obj) {
    console.log('HUSOT: ' + obj);
};

husot.log.error = function (obj) {
    console.error('HUSOT: ' + obj);
};

husot.log.debug = function (obj) {
    if (typeof husot.debug === 'undefined') {
        return;
    }

    console.log('HUSOT DEBUG: ' + obj);
};

// Modal Dialog module

var husot = husot || {};
husot.modalDialog = husot.modalDialog || {};

husot.modalDialog = (function () {
    function initOverlay() {
        if ($('.husot-modalOverlay').length) {
            return;
        }

        var $overlay = $(husot.htmlLayout.modalDialogOverlay);
        $overlay.click(close);
        $(document.body).append($overlay);
    }

    function create($modalWindow) {
        $modalWindow.click(function (event) {
            event.stopPropagation();
        });
        $modalWindow.find('.husot-modalClose').click(close);
        $('.husot-modalOverlay').append($modalWindow);
    }

    function show($modalWindow) {
        $('.husot-modalOverlay').fadeIn(husot.constants.modalDialogShowingSpeed);
        $modalWindow.fadeIn({ queue: false, duration: husot.constants.modalDialogShowingSpeed });
        $modalWindow.animate({ 'margin-top': '40px' }, husot.constants.modalDialogShowingSpeed);
    }

    function close(event) {
        $('.husot-modalOverlay').fadeOut(husot.constants.modalDialogShowingSpeed);
        var $modalWindow = $('.husot-modalWindow');
        $modalWindow.fadeOut(husot.constants.modalDialogShowingSpeed);
        $modalWindow.animate({ 'margin-top': '50px' }, husot.constants.modalDialogShowingSpeed);
    }

    return {
        initOverlay: initOverlay,
        create: create,
        show: show,
        close: close,
    };
})();

// Inject javaScript into main window

var husot = husot || {};
husot.injector = husot.injector || {};

husot.injector.addScripts = function () {
    var script = document.createElement('script');
    script.textContent = 'document.addEventListener(\"husot.loadMoreThumbs\",function(){$(\"#directory-list\").trigger(\"scroll\")});';
    (document.head || document.documentElement).appendChild(script);
    script.parentNode.removeChild(script);
};

// Application settings

var husot = husot || {};
husot.settings = husot.settings || {};

husot.settings.BlockedItems = function (settingsKey) {
    this._settingsKey = settingsKey;
    this._blockedItems; // For caching
};

husot.settings.BlockedItems.prototype = {
    _get: function (name, callback) {
        this.list(function (items) {
            var $item = $.grep(items, function (x) { return x === name; });

            if (!$item.length) {
                callback();
            } else {
                callback($item[0]);
            }
        });
    },
    add: function (name, callback) {
        var self = this,
            start = new Date().getTime();

        // Initial checks
        if (typeof name === 'undefined' || name === '') {
            return;
        }

        husot.log.debug('husot.settings.BlockedItems.add() starts');
        self._get(name, function (item) {
            // Don't process if already in the list
            if (typeof item !== 'undefined') {
                callback();
                return;
            }

            // Add to the list
            self.list(function (items) {
                items.push(name);

                husot.settings.setValue(self._settingsKey, JSON.stringify(items), function () {
                    // Invalidate cached list of blocked items
                    self._blockedItems = undefined;

                    husot.log.debug('husot.settings.BlockedItems.add() ends after {0} ms'.format((new Date().getTime()) - start));

                    callback();
                });
            });
        });
    },
    remove: function (name, callback) {
        var self = this;

        // Initial checks
        if (typeof name === 'undefined' || name === '') {
            callback();
            return;
        }

        this._get(name, function (item) {
            // Don't process if not in the list
            if (typeof item === 'undefined') {
                callback();
                return;
            }

            // Remove from the list
            self.list(function (items) {
                var index = $.inArray(item, items);
                items.splice(index, 1);
                husot.settings.setValue(self._settingsKey, JSON.stringify(items), function () {
                    // Invalidate cached list of blocked items
                    self._blockedItems = undefined;

                    callback();
                });
            });
        });
    },
    list: function (callback) {
        var self = this,
            start = new Date().getTime();

        if (typeof self._blockedItems === 'undefined') {
            husot.log.debug('husot.settings.BlockedItems.list() starts');
            husot.settings.getValue(self._settingsKey, '[]', function (item) {
                // Convert to JSON
                var items = JSON.parse(item);

                items = items.map(function (x) {
                    // Backward compatibility
                    // Previously, items were stored not as array of strings but as objects with 'name' property.
                    // So trying to fetch 'name' property first.
                    if (typeof x.name !== 'undefined') {
                        return x.name;
                    }

                    return x;
                });

                // Sort by name alphabetically
                items.sort(function (a, b) {
                    return a.localeCompare(b);
                });

                // Save in cache
                self._blockedItems = items;

                husot.log.debug('husot.settings.BlockedItems.list() ends after {0} ms'.format((new Date().getTime()) - start));

                // Return
                if (typeof callback !== 'undefined') {
                    callback(self._blockedItems);
                }
            });
        } else {
            if (typeof callback !== 'undefined') {
                callback(self._blockedItems);
            }
        }
    }
};

// Settings UI

var husot = husot || {};
husot.settings = husot.settings || {};
husot.settings.ui = husot.settings.ui || {};

// class Tab

husot.settings.ui.Tab = function ($blockedList, blockedItemsManager, emptyText, thumbsManager) {
    this._$blockedList = $blockedList;
    this._blockedItemsManager = blockedItemsManager;
    this._emptyText = emptyText;
    this._thumbsManager = thumbsManager;
};

husot.settings.ui.Tab.prototype = (function () {
    return {
        _unblockBtn_onClick: function (self, sender) {
            var $sender = $(sender);
            var $blockedListItem = $sender.closest('.husot-settings-blockedList-item');
            var name = $blockedListItem.find('.husot-settings-blockedList-item-name').text();

            self._blockedItemsManager.remove(name, function () {
                self.loadBlockedItems();

                self._thumbsManager.showThumbs(name);
            });
        },
        loadBlockedItems: function () {
            var self = this;
            this._blockedItemsManager.list(function (items) {
                self._$blockedList.empty();

                if (items.length === 0) {
                    self._$blockedList.append(husot.htmlLayout.blockedListItemEmpty.format(self._emptyText));
                    return;
                }

                items.forEach(function (item) {
                    var $blockedListItem = $(husot.htmlLayout.blockedListItem.format(item));
                    var $unblockBtn = $('.husot-settings-blockedList-item-unblockBtn', $blockedListItem);
                    $unblockBtn.click(function () {
                        self._unblockBtn_onClick(self, this);
                    });
                    self._$blockedList.append($blockedListItem);
                });
            });
        },
        activate: function () {
            husot.settings.ui.activateTab(this._$blockedList.attr('id'));
        }
    }
})();

// class Window

husot.settings.ui.Window = function () {
    var create = function () {
        var $settingsWindow = $(husot.htmlLayout.settingsWindow);
        $('.husot-settings-nav-item-name', $settingsWindow).click(navItem_onClick);
        husot.modalDialog.create($settingsWindow);
    }

    // Event handlers for switching content of the tabs
    var navItem_onClick = function () {
        var tabId = $(this).attr('data-husot-contentPanelId');
        husot.settings.ui.activateTab(tabId);
    }

    // Class initialization

    create();

    this._blockedChannelsTab = new husot.settings.ui.Tab(
        $('#husot-settings-blockedChannelsList'),
        husot.settings.blockedChannels,
        husot.constants.blockedChannelsListEmpty,
        husot.thumbs.streamThumbsManager
    );

    this._blockedGamesTab = new husot.settings.ui.Tab(
        $('#husot-settings-blockedGamesList'),
        husot.settings.blockedGames,
        husot.constants.blockedGamesListEmpty,
        husot.thumbs.gameThumbsManager
    );
}

husot.settings.ui.Window.prototype = {
    init: function (blockedItemType) {
        // Load tab content
        this._blockedChannelsTab.loadBlockedItems();
        this._blockedGamesTab.loadBlockedItems();

        // Activate tab
        if (typeof blockedItemType === 'undefined' || blockedItemType === '') {
            return;
        }
        if (blockedItemType === husot.constants.blockedItemType.channel) {
            this._blockedChannelsTab.activate();
            return;
        }
        if (blockedItemType === husot.constants.blockedItemType.game) {
            this._blockedGamesTab.activate();
            return;
        }

        throw Error('Unknown blockedItemType');
    }
}

// Helper static functions

husot.settings.ui.activateTab = function (tabId) {
    // Hide all tabs
    $('.husot-settings-blockedList').hide();
    $('.husot-settings-nav-item-name').removeClass('husot-settings-nav-item-name-active');

    // Show active tab
    var $tabHeader = $('.husot-settings-nav-item-name[data-husot-contentPanelId={0}]'.format(tabId));
    var $tab = $('#{0}'.format(tabId));
    $tab.show();
    $tabHeader.addClass('husot-settings-nav-item-name-active');
};

// Video Thumbnails module

var husot = husot || {};
husot.thumbs = husot.thumbs || {};

// abstract class ThumbsManagerBase

husot.thumbs.ThumbsManagerBase = function () {
}

husot.thumbs.ThumbsManagerBase.prototype = {
    _getContainerJQuery: function () {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    _getThumbJQuery: function () {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    _addThumbOverlay: function ($thumb) {
        var self = this;

        // Initial checks
        if ($thumb.find('.husot-thumbOverlay').length) {
            return;
        };

        // Add overlay
        var $thumbOverlay = $(husot.htmlLayout.streamOverlay);
        $thumb.append($thumbOverlay);

        // Add event handlers for overlay buttons
        $thumbOverlay.find('.husot-blockStreamBtn').click(function (event) {
            event.stopPropagation();
        });
        $thumbOverlay.find('.husot-blockStreamBtn').click(function () {
            self._blockBtn_onClick(self, this)
        });
        $thumbOverlay.find('.husot-showSettingsBtn').click(function (event) {
            event.stopPropagation();
        });
        $thumbOverlay.find('.husot-showSettingsBtn').click(function () {
            self._showSettingsBtn_onClick(self, this);
        });

        // Add hover event handler to a stream/video thumb in order to hide/show overlay menu
        $thumb.hover(function () {
            $thumbOverlay.show();
        }, function () {
            $thumbOverlay.hide();
        });
    },
    _blockBtn_onClick: function (self, sender) {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    _showSettingsBtn_onClick: function (self, sender) {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    // Triggers "infinite scroll" feature on Twitch to load more stream/video thumbnails
    // in order to make sure that page it is filled with new thumbnails after some thumbnails were hidden.
    _loadMoreThumbs: function () {
        husot.log.debug('Triggering "infinite scroll" to load more thumbs');

        // Raise injected custom event that triggers "infinite scroll" feature on Twitch.
        var event = document.createEvent('Event');
        event.initEvent('husot.loadMoreThumbs', true, true);
        document.dispatchEvent(event);
    },
    _notifyAboutHiddenThumbs: function (count) {
        // Initial checks
        if (count === 0) { return }

        husot.log.info('{0} thumbnail{1} {2} hidden'.format(
            count,
            (count > 1 ? 's' : ''),
            (count > 1 ? 'were' : 'was')
        ));
    },
    _notifyAboutShownThumbs: function (count) {
        // Initial checks
        if (count === 0) { return }

        husot.log.info('{0} thumbnail{1} {2} shown'.format(
            count,
            (count > 1 ? 's' : ''),
            (count > 1 ? 'were' : 'was')
        ));
    },
    getDomListnerThumbSelector: function () {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    addThumbOverlays: function () {
        var self = this;

        // Initial checks
        var $thumbs = self._getThumbJQuery();
        if (!$thumbs.length) { return; }

        $thumbs.each(function () {
            self._addThumbOverlay($(this));
        });
    },
    hideThumbs: function () {
        throw Error(husot.exceptions.abstractFunctionCall());
    },
    showThumbs: function (name) {
        throw Error(husot.exceptions.abstractFunctionCall());
    }
};

// class StreamThumbsManager: ThumbsManagerBase

husot.thumbs.StreamThumbsManager = function () {
    husot.thumbs.ThumbsManagerBase.call(this);
}

husot.thumbs.StreamThumbsManager.prototype = Object.create(husot.thumbs.ThumbsManagerBase.prototype);
husot.thumbs.StreamThumbsManager.prototype.constructor = husot.thumbs.StreamThumbsManager;

husot.thumbs.StreamThumbsManager.prototype.getDomListnerThumbSelector = function () {
    return '[data-a-target="live-channel-card-title-link"]';
}

husot.thumbs.StreamThumbsManager.prototype._getContainerJQuery = function () {
    return $('[data-target="directory-game__card_container"]').parent();
}

husot.thumbs.StreamThumbsManager.prototype._getThumbJQuery = function () {
    return $('[data-target="directory-game__card_container"]').find('figure:first');
}

husot.thumbs.StreamThumbsManager.prototype._getGameName = function ($thumbContainer) {
    var self = this;

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }

    var $game = $thumbContainer.find('[data-a-target="live-channel-card-game-link"]')
        .filter(function () {
            // Check that game thumbnail is hidden explicitly and not because an ancestor element is hidden
            return $(this).css('display') !== 'none';
        });

    if (!$game.length) {
        return '';
    }

    return $game.attr('title').trim();
}

husot.thumbs.StreamThumbsManager.prototype._getChannelName = function ($thumbContainer) {
    var self = this;

    var map = [
        {
            urls: [
                '^https?://([a-zA-Z]+\.)?twitch.tv/directory/all(/?|/.+)$'
            ],
            name: function () {
                return $thumbContainer.find('[data-a-target="live-channel-card-title-link"]').attr('href').trimSlash();
            }
        },
        // Default (should be the last one)
        {
            name: function() {
                return $thumbContainer.find('[data-a-target="live-channel-card-title-link"]').attr('href').trimSlash();
            }
        }
    ];

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }

    return self._getChannelNameForUrl(map);
}

husot.thumbs.StreamThumbsManager.prototype._getChannelNameForUrl = function (map) {
    var result;

    map.forEach(function (item) {
        if (result) {  // Selector has been found already
            return;
        };

        if (!item.urls) { // Gets default selector that doesn't have URLs
            result = item.name();
            return;
        };

        var isMatch = item.urls.some(function (url) {
            return (new RegExp(url)).test(decodeURIComponent(document.URL));
        });

        if (isMatch) {
            result = item.name();
            return;
        }
    });

    if (typeof result === 'undefined') {
        throw Error('CSS selector for "Channel name" not found.');
    }

    return result;
}

husot.thumbs.StreamThumbsManager.prototype._showSettingsBtn_onClick = function (self, sender) {
    husot.settings.ui.window.init(husot.constants.blockedItemType.channel);
    husot.modalDialog.show($('.husot-settings'));
}

husot.thumbs.StreamThumbsManager.prototype._blockBtn_onClick = function (self, sender) {
    var $sender = $(sender);

    var $thumbContainer = $sender.closest(self._getContainerJQuery());

    // Initial checks
    if (!$thumbContainer.length) {
        throw Error(husot.exceptions.elementNotFound('Thumb container'));
    };

    var name = self._getChannelName($thumbContainer);

    husot.settings.blockedChannels.add(name, function () {
        self._hideThumbs(name);
        self._loadMoreThumbs();
    });
}

husot.thumbs.StreamThumbsManager.prototype._hideThumbs = function (name) {
    var self = this;

    var $thumbContainers = self._getThumbContainersForChannel(name).filter(':visible');

    // Initial checks
    if (!$thumbContainers.length) {
        throw Error(husot.exceptions.elementNotFoundFor('Thumb container', '"{0}" channel'.format(name)));
    };

    $thumbContainers.hide();
    self._notifyAboutHiddenThumbs($thumbContainers.length);
}

husot.thumbs.StreamThumbsManager.prototype._getThumbContainersForChannel = function (name) {
    var self = this;

    var $thumbContainers = self._getContainerJQuery();

    // No stream thumbs on the page
    if (!$thumbContainers.length) {
        return $();
    }

    return $thumbContainers.filter(function () {
        var $this = $(this);
        var channelName = self._getChannelName($this);

        return channelName.toLowerCase() === name.toLowerCase();
    });
}

husot.thumbs.StreamThumbsManager.prototype._getThumbContainersForGame = function (name) {
    var self = this;

    var $thumbContainers = self._getContainerJQuery();

    // No stream thumbs on the page
    if (!$thumbContainers.length) {
        return $();
    }

    return $thumbContainers.filter(function () {
        var $this = $(this);
        var gameName = self._getGameName($this);

        return gameName.toLowerCase() === name.toLowerCase();
    });
}

husot.thumbs.StreamThumbsManager.prototype._isThumbMustBeHiddenForChannel = function ($thumbContainer, blockedChannels) {
    var self = this;

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }
    if ($thumbContainer.length !== 1) {
        throw Error(husot.exceptions.argumentOneElementExpected('$thumbContainer'));
    }

    var channelName = self._getChannelName($thumbContainer);

    return blockedChannels.some(function (item) {
        return channelName.toLowerCase() === item.toLowerCase();
    });
};

husot.thumbs.StreamThumbsManager.prototype._isThumbMustBeHiddenForGame = function ($thumbContainer, blockedGames) {
    var self = this;

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }
    if ($thumbContainer.length !== 1) {
        throw Error(husot.exceptions.argumentOneElementExpected('$thumbContainer'));
    }

    var gameName = self._getGameName($thumbContainer);
    if (typeof gameName === 'undefined' || gameName === '') { // Game name is optional in this manager
        return false;
    }

    return blockedGames.some(function (item) {
        return gameName.toLowerCase() === item.toLowerCase();
    });
};

husot.thumbs.StreamThumbsManager.prototype.hideThumbs = function () {
    var self = this;

    var start = new Date().getTime();
    husot.log.debug('StreamThumbsManager.hideThumbs() starts');

    // Load block lists for channels and games
    var blockedChannelsPromise = new Promise(function (resolve, reject) {
        husot.settings.blockedChannels.list(function (items) {
            resolve(items);
        });
    });
    var blockedGamesPromise = new Promise(function (resolve, reject) {
        husot.settings.blockedGames.list(function (items) {
            resolve(items);
        });
    });

    // Hide thumbnails after block lists are loaded
    Promise.all([blockedChannelsPromise, blockedGamesPromise]).then(function (values) {
        var blockedChannels = values[0];
        var blockedGames = values[1];

        // Get visible thumbs
        $thumbContainers = self._getContainerJQuery().filter(':visible');

        // Enumerate visible thumbs and hide those that must be hidden
        var hiddenThumbsCount = 0;
        $thumbContainers.each(function () {
            var $item = $(this);

            // Hide for channels
            if (self._isThumbMustBeHiddenForChannel($item, blockedChannels)) {
                $item.hide();
                hiddenThumbsCount++;
                return;
            }

            // Hide for games
            if (self._isThumbMustBeHiddenForGame($item, blockedGames)) {
                $item.hide();
                hiddenThumbsCount++;
                return;
            }
        });

        if (hiddenThumbsCount > 0) {
            self._notifyAboutHiddenThumbs(hiddenThumbsCount);
            self._loadMoreThumbs();
        }

        husot.log.debug('StreamThumbsManager.hideThumbs() ends after {0} ms'.format((new Date().getTime()) - start));
    });
}

husot.thumbs.StreamThumbsManager.prototype.showThumbs = function (name) {
    var self = this;

    var $thumbContainers = self._getThumbContainersForChannel(name).filter(':hidden');

    // Initial checks
    if (!$thumbContainers.length) { return };

    new Promise(function (resolve, reject) { // Load block list for games
        husot.settings.blockedGames.list(function (items) {
            resolve(items);
        });
    })
    .then(function (blockedGames) { // Show thumbnails after block list is loaded
        var shownThumbsCount = 0;
        $thumbContainers.each(function () {
            var $item = $(this);

            // Don't hide if game is in the block list
            if (self._isThumbMustBeHiddenForGame($item, blockedGames)) { return }

            $item.show();
            shownThumbsCount++;
        });

        self._notifyAboutShownThumbs(shownThumbsCount);
    });
}

husot.thumbs.StreamThumbsManager.prototype.showThumbsForGame = function (name) {
    var self = this;

    var $thumbContainers = self._getThumbContainersForGame(name).filter(':hidden');

    // Initial checks
    if (!$thumbContainers.length) { return };

    new Promise(function (resolve, reject) { // Load block list for channels
        husot.settings.blockedChannels.list(function (items) {
            resolve(items);
        });
    })
    .then(function (blockedChannels) { // Show thumbnails after block list is loaded
        var shownThumbsCount = 0;
        $thumbContainers.each(function () {
            var $item = $(this);

            // Don't hide if channel is in the block list
            if (self._isThumbMustBeHiddenForChannel($item, blockedChannels)) { return }

            $item.show();
            shownThumbsCount++;
        });

        self._notifyAboutShownThumbs(shownThumbsCount);
    });
}

// class GameThumbsManager: ThumbsManagerBase

husot.thumbs.GameThumbsManager = function (streamThumbsManager) {
    husot.thumbs.ThumbsManagerBase.call(this);

    this._streamThumbsManager = streamThumbsManager;
}

husot.thumbs.GameThumbsManager.prototype = Object.create(husot.thumbs.ThumbsManagerBase.prototype);
husot.thumbs.GameThumbsManager.prototype.constructor = husot.thumbs.GameThumbsManager;

husot.thumbs.GameThumbsManager.prototype.getDomListnerThumbSelector = function () {
    return '[data-target="directory-page__card-container"]';
}

husot.thumbs.GameThumbsManager.prototype._getContainerJQuery = function () {
    return $('[data-target="directory-page__card-container"]').parent();
}

husot.thumbs.GameThumbsManager.prototype._getThumbJQuery = function () {
    return $('[data-target="directory-page__card-container"]').find('figure:first');
}

husot.thumbs.GameThumbsManager.prototype._getGameName = function($thumbContainer) {
    var self = this;

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }

    var $game = $thumbContainer.find('.tw-card-body .tw-box-art-card__title');

    if (!$game.length) {
        throw Error(husot.exceptions.elementNotFound('Game name'));
    }

    return $game.text().trim();
}

husot.thumbs.GameThumbsManager.prototype._showSettingsBtn_onClick = function (self, sender) {
    husot.settings.ui.window.init(husot.constants.blockedItemType.game);
    husot.modalDialog.show($('.husot-settings'));
}

husot.thumbs.GameThumbsManager.prototype._blockBtn_onClick = function (self, sender) {
    var $sender = $(sender);

    var $thumbContainer = $sender.closest(self._getContainerJQuery());

    // Initial checks
    if (!$thumbContainer.length) {
        throw Error(husot.exceptions.elementNotFound('Thumb container'));
    };

    var name = self._getGameName($thumbContainer);

    husot.settings.blockedGames.add(name, function () {
        self._hideThumbs(name);
        self._loadMoreThumbs();
    });
}

husot.thumbs.GameThumbsManager.prototype._hideThumbs = function (name) {
    var self = this;

    var $thumbContainer = self._getThumbContainer(name).filter(':visible');

    // Initial checks
    if (!$thumbContainer.length) {
        throw Error(husot.exceptions.elementNotFoundFor('Thumb container', '"{0}" game'.format(name)));
    };

    $thumbContainer.hide();
    self._notifyAboutHiddenThumbs($thumbContainer.length);
}

husot.thumbs.GameThumbsManager.prototype._getThumbContainer = function (name) {
    var self = this;

    var $thumbContainers = self._getContainerJQuery();

    // No game thumbs on the page
    if (!$thumbContainers.length) {
        return $();
    }

    return $thumbContainers.filter(function () {
        var $this = $(this);
        var gameName = self._getGameName($this);

        return gameName.toLowerCase() === name.toLowerCase();
    });
}

husot.thumbs.GameThumbsManager.prototype._isThumbMustBeHidden = function ($thumbContainer, blockedGames) {
    var self = this;

    // Initial checks
    if (typeof $thumbContainer === 'undefined' || !$thumbContainer.length) {
        throw Error(husot.exceptions.argumentNullOrEmpty('$thumbContainer'));
    }
    if ($thumbContainer.length !== 1) {
        throw Error(husot.exceptions.argumentOneElementExpected('$thumbContainer'));
    }

    var name = self._getGameName($thumbContainer);

    return blockedGames.some(function (item) {
        return name.toLowerCase() === item.toLowerCase();
    });
};

husot.thumbs.GameThumbsManager.prototype._showThumbs = function (name) {
    var self = this;

    var $thumbContainer = self._getThumbContainer(name).filter(':hidden');

    // Initial checks
    if (!$thumbContainer.length) { return };

    $thumbContainer.show();
    self._notifyAboutShownThumbs($thumbContainer.length);
};

husot.thumbs.GameThumbsManager.prototype.hideThumbs = function () {
    var self = this;

    var start = new Date().getTime();
    husot.log.debug('GameThumbsManager.hideThumbs() starts');

    new Promise(function (resolve, reject) { // Load blocked list for games
        husot.settings.blockedGames.list(function (items) {
            resolve(items);
        });
    })
    .then(function (blockedGames) { // Hide thumbnails after block list is loaded
        // Get visible thumbs
        $thumbContainers = self._getContainerJQuery().filter(':visible');

        // Enumerate visible thumbs and hide those that must be hidden
        var hiddenThumbsCount = 0;
        $thumbContainers.each(function () {
            var $item = $(this);

            // Hide game if needed
            if (self._isThumbMustBeHidden($item, blockedGames)) {
                $item.hide();
                hiddenThumbsCount++;
            }
        });

        if (hiddenThumbsCount > 0) {
            self._notifyAboutHiddenThumbs(hiddenThumbsCount);
            self._loadMoreThumbs();
        }

        husot.log.debug('GameThumbsManager.hideThumbs() ends after {0} ms'.format((new Date().getTime()) - start));
    });
};

husot.thumbs.GameThumbsManager.prototype.showThumbs = function (name) {
    var self = this;

    self._showThumbs(name);
    self._streamThumbsManager.showThumbsForGame(name);
};

// Embedded CSS styles

(function () {
    // Run only in top frame
    if (window.top !== window.self) {
        return;
    }

    var style = document.createElement('style');
    style.textContent = '.husot-button{background:#6441a5;color:#fff;padding:0 10px;font-size:12px;text-align:center;cursor:pointer;height:30px;display:inline-block;line-height:30px}.husot-button:hover{color:#fff;background:#7550ba;text-decoration:none}.husot-modalOverlay{display:none;background:rgba(33,18,51,.9);position:fixed;z-index:10000;top:0;bottom:0;left:0;padding:20px 0;width:100%;overflow-x:hidden;overflow-y:auto}.husot-modalWindow{display:none;position:relative;opacity:1;margin:50px auto 0}.husot-settings{font:12px "Helvetica Neue",Helvetica,Arial,sans-serif;color:#32323e;width:360px;background:#FFF;padding:20px}.husot-settings-nav{list-style:none;padding:0;margin:0 0 5px;border-bottom:1px solid rgba(0,0,0,.2);overflow:auto}.husot-settings-nav-item{float:left;margin-right:20px}.husot-settings-nav-item-name{font-size:14px;line-height:24px;padding-bottom:7px;display:inline-block;cursor:pointer;text-decoration:none;color:#6441a5;border-bottom:1px solid transparent}.husot-settings-nav-item-name:hover{color:#19191f;border-bottom:1px solid rgba(0,0,0,.35);text-decoration:none}.husot-settings-nav-item-name-active,.husot-settings-nav-item-name-active:hover{color:#19191f;border-bottom:1px solid #19191f}.husot-settings-blockedList{list-style:none;padding:0;margin:0 0 5px;overflow-y:auto;min-height:240px;max-height:400px}.husot-settings-blockedList-item{margin:5px 0;clear:both;overflow:auto}.husot-settings-blockedList-item-name{font-size:14px;display:inline-block;width:100%;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;height:30px;float:left;line-height:30px;padding-right:80px}.husot-settings-blockedList-item-empty{text-align:center;font-size:16px;color:#bbb;font-style:italic;padding:20px 0}.husot-settings-blockedList-item-unblockBtn{float:right;max-width:80px;overflow:hidden;margin-left:-80px}.husot-settings-footer{text-align:center;width:100%;margin:0 0 5px;padding:20px 0 0;border-top:1px solid #dedede}.husot-settings-footer .husot-button{min-width:70px}.husot-thumbOverlay{display:none;position:absolute;top:auto!important;right:auto!important;bottom:5px!important;left:5px!important;color:#fff;background-color:#000;padding:0 5px;opacity:.75;font:12px "Helvetica Neue",Helvetica,Arial,sans-serif;line-height:22px;width:auto!important;min-height:auto!important}.husot-thumbOverlay a{color:#fff!important;text-decoration:none!important}.husot-thumbOverlay a:hover{color:#fff!important;text-decoration:underline!important}.husot-thumbOverlay-menu{list-style:none;padding:0;margin:0}.husot-thumbOverlay-menu li,.husot-thumbOverlay-menu-separator{display:inline}';
    (document.head || document.documentElement).appendChild(style);
})();

// Application settings (Userscript specific)

var husot = husot || {};
husot.settings = husot.settings || {};

husot.settings.setValue = function (key, value, callback) {
    GM.setValue(key, value).then(function () {
        callback();
    }, function (reason) { // rejection
        husot.log.error(reason);
    });
};

husot.settings.getValue = function (key, defaultValue, callback) {
    GM.getValue(key).then(function (value) {
        if (typeof value === 'undefined' || value === '') {
            callback(defaultValue);
        }

        callback(value);
    }, function (reason) { // rejection
        husot.log.error(reason);
    });
};

// Application start

var husot = husot || {};

husot.main = function () {
    // Run only in top frame
    if (window.top !== window.self) {
        return;
    }

    husot.settings.blockedChannels = new husot.settings.BlockedItems(husot.constants.blockedChannelsSettingsKey);
    husot.settings.blockedChannels.list(); // Warm up settings so by the hiding time they will be loaded, most likely
    husot.settings.blockedGames = new husot.settings.BlockedItems(husot.constants.blockedGamesSettingsKey);
    husot.settings.blockedGames.list(); // Warm up settings so by the hiding time they will be loaded, most likely

    husot.thumbs.streamThumbsManager = new husot.thumbs.StreamThumbsManager();
    husot.thumbs.gameThumbsManager = new husot.thumbs.GameThumbsManager(husot.thumbs.streamThumbsManager);

    husot.modalDialog.initOverlay();
    husot.settings.ui.window = new husot.settings.ui.Window();
    husot.injector.addScripts();
    husot.domListener.start();
};

$(document).ready(function () {
    husot.main();
});