NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 });
})();