Twitch Combos Overlay Blocker

Blocks Twitch's combos overlay using CSS and DOM manipulation

// ==UserScript==
// @name         Twitch Combos Overlay Blocker
// @namespace    https://github.com/nebhay/twitch-combos-blocker
// @version      1.0.0
// @description  Blocks Twitch's combos overlay using CSS and DOM manipulation
// @author       nebhay
// @match        https://www.twitch.tv/*
// @grant        none
// @run-at       document-start
// @license      MIT
// @homepageURL  https://github.com/nebhay/twitch-combos-blocker
// @supportURL   https://github.com/nebhay/twitch-combos-blocker/issues
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CONFIG = {
        DEBUG: true,
        SELECTORS: [
            '[data-a-target*="onetap"]',
            'div[class*="onetap" i]',
            '.onetap-overlay'
        ]
    };

    /**
     * Log messages with consistent formatting
     * @param {string} message - The message to log
     * @param {string} type - The log type (info, success, warning, error)
     * @param {any} data - Additional data to log
     */
    function log(message, type = 'info', data = null) {
        if (!CONFIG.DEBUG) return;

        const colors = {
            info: 'color: blue; font-weight: bold;',
            success: 'color: green; font-weight: bold;',
            warning: 'color: orange; font-weight: bold;',
            error: 'color: red; font-weight: bold;'
        };

        const prefix = '[Combos Blocker]';
        if (data) {
            console.log(`%c${prefix} ${message}`, colors[type], data);
        } else {
            console.log(`%c${prefix} ${message}`, colors[type]);
        }
    }

    /**
     * Apply CSS rules to hide combos elements
     */
    function applyCSSRules() {
        const style = document.createElement('style');
        style.id = 'twitch-combos-blocker-styles';

        style.textContent = `
            /* Hide combos overlay containers */
            ${CONFIG.SELECTORS.join(',\n            ')} {
                display: none !important;
                visibility: hidden !important;
                opacity: 0 !important;
                pointer-events: none !important;
                z-index: -9999 !important;
            }

            /* Prevent body scroll lock when overlay would be present */
            body.ReactModal__Body--open {
                overflow: auto !important;
            }

            /* Additional selectors for combos variants */
            [data-test-selector*="onetap"],
            [aria-label*="onetap" i],
            [title*="onetap" i] {
                display: none !important;
                visibility: hidden !important;
            }
        `;

        // Insert into head or fallback to document
        const target = document.head || document.documentElement;
        target.appendChild(style);

        log('CSS rules applied successfully', 'success');
    }

    /**
     * Check if an element matches combos criteria
     * @param {Element} element - The element to check
     * @returns {boolean} True if element appears to be a combos element
     */
    function isCombosElement(element) {
        if (!element || element.nodeType !== 1) return false;

        // Check data attributes
        const dataTarget = element.dataset?.aTarget;
        if (dataTarget && dataTarget.includes('onetap')) return true;

        const testSelector = element.dataset?.testSelector;
        if (testSelector && testSelector.toLowerCase().includes('onetap')) return true;

        // Check class names
        const className = element.className || '';
        const classStr = typeof className === 'string' ? className : className.toString?.() || '';
        if (classStr.toLowerCase().includes('onetap')) return true;

        // Check aria-label and title attributes
        const ariaLabel = element.getAttribute?.('aria-label') || '';
        const title = element.getAttribute?.('title') || '';
        if (ariaLabel.toLowerCase().includes('onetap') || title.toLowerCase().includes('onetap')) {
            return true;
        }

        return false;
    }

    /**
     * Remove combos elements from the DOM
     * @param {Element} element - The element to remove
     */
    function removeCombosElement(element) {
        const details = {
            tagName: element.tagName,
            className: element.className?.toString?.() || element.className || '',
            dataTarget: element.dataset?.aTarget,
            id: element.id,
            outerHTML: element.outerHTML?.substring(0, 200) + '...'
        };

        log('Removing combos element:', 'warning', details);
        element.remove();
    }

    /**
     * Set up MutationObserver to watch for new combos elements
     */
    function setupDOMObserver() {
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType !== 1) return; // Skip non-element nodes

                    // Check if the node itself is a combos element
                    if (isCombosElement(node)) {
                        removeCombosElement(node);
                        return;
                    }

                    // Check for combos elements within the added node
                    if (node.querySelectorAll) {
                        const combosElements = node.querySelectorAll(CONFIG.SELECTORS.join(', '));
                        if (combosElements.length > 0) {
                            log(`Found ${combosElements.length} combos element(s) in added content`, 'warning');
                            combosElements.forEach(removeCombosElement);
                        }
                    }
                });
            });
        });

        // Start observing
        const target = document.body || document.documentElement;
        observer.observe(target, {
            childList: true,
            subtree: true
        });

        log('DOM observer started', 'success');
        return observer;
    }

    /**
     * Perform initial cleanup of existing combos elements
     */
    function initialCleanup() {
        const existingElements = document.querySelectorAll(CONFIG.SELECTORS.join(', '));
        if (existingElements.length > 0) {
            log(`Removing ${existingElements.length} existing combos element(s)`, 'warning');
            existingElements.forEach(removeCombosElement);
        }
    }

    /**
     * Initialize the blocker
     */
    function init() {
        log('Initializing Combos Blocker', 'info');

        // Apply CSS rules immediately
        applyCSSRules();

        // Set up DOM observer
        const observer = setupDOMObserver();

        // Perform initial cleanup when DOM is ready
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', initialCleanup);
        } else {
            initialCleanup();
        }

        // Cleanup on page unload
        window.addEventListener('beforeunload', () => {
            if (observer) {
                observer.disconnect();
                log('Observer disconnected', 'info');
            }
        });

        log('Combos Blocker initialized successfully', 'success');
    }

    // Start the blocker
    init();

})();