yoharnu / Twitch Auto-Set Source Quality (Invisible)

// ==UserScript==
// @name         Twitch Auto-Set Source Quality (Invisible)
// @namespace    https://openuserjs.org/scripts/yoharnu/
// @version      1.1
// @description  Automatically sets Twitch player quality to Source, 1440p, or 1080p (in that order) using an invisible menu interaction. Features robust retry logic and supports Single Page Application (SPA) navigation.
// @author       yoharnu
// @match        https://player.twitch.tv/*
// @match        https://www.twitch.tv/*
// @grant        none
// @license      MIT
// @updateURL    https://openuserjs.org/meta/yoharnu/Twitch_Auto-Set_Source_Quality_(Invisible).meta.js
// @downloadURL  https://openuserjs.org/install/yoharnu/Twitch_Auto-Set_Source_Quality_(Invisible).user.js
// @copyright    2026, yoharnu (https://openuserjs.org/users/yoharnu)
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // CONFIGURATION
    // ==========================================
    const CONFIG = {
        debug: false,                // Set to true to see console logs
        maxRetries: 30,              // Max attempts to click a menu item before giving up
        retryInterval: 250,          // Time (ms) between click attempts
        menuAnimationWait: 200,      // Time (ms) to wait for menus to slide in/render
        safetyTimeout: 5000,         // Time (ms) to force-remove the invisible mask if script hangs
        selectors: {
            settingsButton: '[data-a-target="player-settings-button"]',
            qualityMenuItem: '[data-a-target="player-settings-menu-item-quality"]',
            qualityOption: '[data-a-target="player-settings-submenu-quality-option"]'
        }
    };

    // ==========================================
    // UTILITIES
    // ==========================================
    function log(msg) {
        if (CONFIG.debug) console.log(`[TwitchQuality] ${msg}`);
    }

    // --- CSS Visibility Management ---
    // Hides the settings menu while we interact with it to prevent flickering.
    // Uses 'opacity: 0' instead of 'display: none' so React event handlers still fire.
    function lockVisibility() {
        if (document.getElementById('twitch-quality-mask')) return;
        
        const style = document.createElement('style');
        style.id = 'twitch-quality-mask';
        style.innerHTML = `
            /* Target the white "speech bubble" box wrapper */
            /* We hide the parent container so the white box itself is invisible */
            .tw-balloon:has(${CONFIG.selectors.qualityMenuItem}),
            .tw-balloon:has(${CONFIG.selectors.qualityOption}) {
                opacity: 0 !important;
                transition: none !important;
            }
            
            /* Fallback: Hide the menu items directly if parent selector fails */
            ${CONFIG.selectors.qualityMenuItem},
            ${CONFIG.selectors.qualityOption} {
                opacity: 0 !important;
            }
        `;
        document.head.appendChild(style);
        log("UI Mask Applied (Menu Hidden)");
    }

    function unlockVisibility() {
        const style = document.getElementById('twitch-quality-mask');
        if (style) {
            style.remove();
            log("UI Mask Removed (Menu Visible)");
        }
    }

    // --- Robust Clicker ---
    // Tries to click 'clickSelector' and waits for 'expectSelector' to appear.
    // If 'expectSelector' doesn't appear, it assumes the click failed and retries.
    function clickAndValidate(clickSelector, expectSelector, attempt = 1, onSuccess) {
        if (attempt > CONFIG.maxRetries) {
            log(`Failed to open ${expectSelector} after ${attempt} attempts.`);
            unlockVisibility(); // Emergency unlock so user isn't stuck
            return;
        }

        const elementToClick = document.querySelector(clickSelector);
        
        if (!elementToClick) {
            // Element not found yet? Wait and retry.
            setTimeout(() => clickAndValidate(clickSelector, expectSelector, attempt + 1, onSuccess), CONFIG.retryInterval);
            return;
        }

        elementToClick.click();

        // Check if the click succeeded (did the expected menu appear?)
        setTimeout(() => {
            const expectedElement = document.querySelector(expectSelector);
            if (expectedElement) {
                if (onSuccess) onSuccess(expectedElement);
            } else {
                // Click registered, but menu didn't open. Retry.
                clickAndValidate(clickSelector, expectSelector, attempt + 1, onSuccess);
            }
        }, CONFIG.retryInterval);
    }

    // ==========================================
    // MAIN LOGIC
    // ==========================================
    function applyQualitySettings() {
        log("Starting quality selection process...");

        // 1. Optimization: Set LocalStorage preference
        // This hints to Twitch to use "Source" (chunked) on next player load
        try {
            localStorage.setItem('video-quality', JSON.stringify({default: "chunked"}));
        } catch (e) {}

        // 2. Hide the UI
        lockVisibility();

        // 3. Begin Interaction Chain: Click Settings -> Wait for Quality Menu
        clickAndValidate(
            CONFIG.selectors.settingsButton,
            CONFIG.selectors.qualityMenuItem,
            1,
            (qualityMenuButton) => {
                
                // 4. Click "Quality" -> Wait for Options
                qualityMenuButton.click();

                setTimeout(() => {
                    const options = Array.from(document.querySelectorAll(CONFIG.selectors.qualityOption));
                    
                    if (options.length === 0) {
                        log("Quality menu empty. Retrying flow...");
                        // Close menu and restart if we hit a UI glitch
                        const settingsBtn = document.querySelector(CONFIG.selectors.settingsButton);
                        if (settingsBtn) settingsBtn.click();
                        setTimeout(applyQualitySettings, 500);
                        return;
                    }

                    // 5. Select Best Quality
                    const target = findBestQuality(options);

                    if (target) {
                        log(`Selecting quality: ${target.innerText}`);
                        target.click();
                    } else {
                        log("No suitable quality option found.");
                    }

                    // 6. Close Menu & Cleanup
                    setTimeout(() => {
                        const settingsBtn = document.querySelector(CONFIG.selectors.settingsButton);
                        if (settingsBtn) settingsBtn.click();
                        
                        // Wait a tiny bit for the menu to close before revealing UI
                        setTimeout(unlockVisibility, 250);
                    }, 100);

                }, CONFIG.menuAnimationWait);
            }
        );

        // Safety: Always unhide after 5 seconds in case something crashes
        setTimeout(unlockVisibility, CONFIG.safetyTimeout);
    }

    // Helper: Logic to pick Source > 1440p > 1080p (Ignoring disabled/unavailable options)
    function findBestQuality(optionElements) {
        
        // Filter out any options that are visually disabled (contain a disabled input)
        const availableOptions = optionElements.filter(el => {
            return !el.querySelector('input:disabled');
        });

        // Search Priority 1: Source
        let bestMatch = availableOptions.find(el => {
            const text = el.innerText.toLowerCase();
            return text.includes("source") || text.includes("(source)");
        });

        // Search Priority 2: 1440p
        if (!bestMatch) bestMatch = availableOptions.find(el => el.innerText.includes("1440p"));
        
        // Search Priority 3: 1080p
        if (!bestMatch) bestMatch = availableOptions.find(el => el.innerText.includes("1080p"));
        
        return bestMatch;
    }

    // ==========================================
    // INITIALIZATION & OBSERVERS
    // ==========================================
    
    // 1. Run on first load
    setTimeout(applyQualitySettings, 2500);

    // 2. Run on URL change (Single Page App navigation)
    let lastUrl = location.href;
    const pageObserver = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            log("Navigation detected. Re-running script...");
            setTimeout(applyQualitySettings, 4000); // Wait for new player to attach
        }
    });
    
    pageObserver.observe(document.body, { childList: true, subtree: true });

})();