Stripchat Auto Quality

Automatically sets the highest available video quality on Stripchat. Uses React Fiber internals to interact with the native player controls — no synthetic clicks needed. Includes smart retry logic and a manual ⚙️ MaxQ button.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Stripchat Auto Quality
// @namespace    http://tampermonkey.net/
// @version      2.6
// @description  Automatically sets the highest available video quality on Stripchat. Uses React Fiber internals to interact with the native player controls — no synthetic clicks needed. Includes smart retry logic and a manual ⚙️ MaxQ button.
// @author       Thiago
// @license      MIT
// @match        https://stripchat.com/*
// @match        https://*.stripchat.com/*
// @run-at       document-idle
// @grant        none
// @homepageURL  https://greasyfork.org/en/scripts/thiagomongeme
// @supportURL   https://greasyfork.org/en/scripts/thiagomongeme
// ==/UserScript==

// Changelog:
// v2.6 - Fixed 720p/960p not being selected when the player auto-mode already matched the best resolution.
//        The script now always clicks the manual quality button to prevent automatic player downgrade.
//        Watchdog now resets quality confirmation every 30s to detect and recover from player-triggered downgrades.
//        Fixed race condition where the quality menu tooltip wasn't found due to fixed sleep timing.
// v2.3 - Replaced synthetic DOM clicks with React Fiber internals for reliable quality selection.
// v2.2 - Previous release.

(function () {
    'use strict';

    const TAG = '[AutoQ-v2.6]';
    const PREFER_FPS_OVER_HEIGHT = false;

    // Quantas vezes tenta em loop rápido após entrar na sala
    const MAX_FAST_ATTEMPTS = 30;
    const FAST_RETRY_MS = 3000;

    // Watchdog periódico (só age se qualidade não estiver confirmada)
    const WATCHDOG_MS = 30000;

    // Profundidade na árvore fiber onde está o onClick real (confirmado em debug ao vivo)
    const FIBER_ONCLICK_DEPTH = 3;



    let fastAttempts = 0;
    let fastTimer = null;
    let watchdogTimer = null;
    let busy = false;
    let lastPath = '';
    let lastSuccessAt = 0;
    let qualityConfirmed = false;   // true quando confirmamos que está na melhor qualidade
    let confirmedBestScore = -1;   // score da melhor qualidade confirmada

    function log(...args) { console.log(TAG, ...args); }
    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

    // ─── Helpers ─────────────────────────────────────────────────────────────

    function isRoomPath(p = location.pathname) {
        const parts = p.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
        if (parts.length !== 1) return false;
        const blocked = new Set([
            'girls','tags','login','logout','signup','register','support','privacy',
            'terms','billing','accounts','settings','studio','search','favorites',
            'followed','top','category','categories','users','model','models'
        ]);
        return !blocked.has(parts[0].toLowerCase());
    }

    function visible(el) {
        if (!el) return false;
        const r = el.getBoundingClientRect();
        const s = getComputedStyle(el);
        return r.width > 1 && r.height > 1 &&
               s.display !== 'none' && s.visibility !== 'hidden' &&
               Number(s.opacity || 1) > 0.02;
    }

    function parseQuality(text) {
        if (!text) return null;
        const m = String(text).match(/(\d{3,4})\s*p\s*(\d{2,3})?/i);
        if (!m) return null;
        const height = Number(m[1]);
        const fps = m[2] ? Number(m[2]) : 30;
        if (height < 100) return null;
        const score = PREFER_FPS_OVER_HEIGHT ? fps * 10000 + height : height * 1000 + fps;
        return { raw: String(text).trim(), height, fps, score };
    }

    // ─── React Fiber helpers ─────────────────────────────────────────────────

    function getReactFiber(el) {
        if (!el) return null;
        const key = Object.keys(el).find(k =>
            k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
        );
        return key ? el[key] : null;
    }

    function getReactProps(el) {
        if (!el) return null;
        const key = Object.keys(el).find(k => k.startsWith('__reactProps'));
        return key ? el[key] : null;
    }

    // Sobe N níveis na árvore fiber e retorna o nó
    function fiberAncestor(fiber, depth) {
        let node = fiber;
        for (let i = 0; i < depth && node; i++) node = node.return;
        return node;
    }

    // Chama o onClick de um nó fiber com um evento React mínimo
    function callFiberOnClick(fiberNode, nativeTarget) {
        const mp = fiberNode && fiberNode.memoizedProps;
        if (!mp || !mp.onClick) return false;
        try {
            mp.onClick({
                nativeEvent: new MouseEvent('click', { bubbles: true }),
                currentTarget: nativeTarget,
                target: nativeTarget,
                preventDefault: () => {},
                stopPropagation: () => {},
                isPropagationStopped: () => false,
                isDefaultPrevented: () => false,
                persist: () => {}
            });
            return true;
        } catch (e) {
            log('callFiberOnClick error:', e);
            return false;
        }
    }

    // ─── Detecta qualidade atual sem abrir menu ────────────────────────────────

    // Lê o badge/label de qualidade que o player exibe no botão (ex: "HD", "1080p")
    function getCurrentQualityScore() {
        const btn = findQualityButton();
        if (!btn) return -1;
        const text = (
            btn.getAttribute('data-resolution') ||
            btn.getAttribute('title') ||
            btn.textContent || ''
        ).trim();
        // O badge HD no player indica alta qualidade mas sem número;
        // também tenta ler o label interno que o Stripchat insere
        const badge = btn.querySelector('[class*="badge"],[class*="label"],[class*="Badge"],[class*="Label"]');
        const badgeText = badge ? badge.textContent.trim() : '';
        const parsed = parseQuality(text) || parseQuality(badgeText);
        return parsed ? parsed.score : -1;
    }

    // Lê o score da melhor opção disponível sem abrir o menu
    // Usa o último valor conhecido após abertura do menu
    function isAlreadyBest() {
        if (!qualityConfirmed) return false;
        const cur = getCurrentQualityScore();
        // Se não consegue ler score atual, confia no flag confirmado
        if (cur <= 0) return qualityConfirmed;
        return cur >= confirmedBestScore;
    }

    // ─── Acionar botão de qualidade ──────────────────────────────────────────

    function findQualityButton() {
        const selectors = [
            'button.player-resolution[title*="Qualidade"]',
            'button.player-resolution[title*="Quality"]',
            'button.player-resolution[title*="Video"]',
            'button.player-resolution[title*="Vídeo"]',
            'button.player-resolution',
            '.player-resolution'
        ];
        for (const s of selectors) {
            try {
                const els = Array.from(document.querySelectorAll(s))
                    .filter(e => e.id !== 'sc-auto-quality-btn');
                const v = els.find(visible) || els[0];
                if (v) return v;
            } catch (_) {}
        }
        return null;
    }

    // Mostra os controles do player (hover para ativar)
    function triggerPlayerHover() {
        const video = document.querySelector('video');
        const root = video?.closest('[class*="Player"],[class*="player"],[class*="ViewCam"]')
                     || video?.parentElement;
        if (!root) return;
        const r = root.getBoundingClientRect();
        if (r.width < 2) return;
        const x = r.left + r.width / 2;
        const y = r.top + r.height / 2;
        for (const type of ['mouseenter', 'mouseover', 'mousemove', 'pointermove']) {
            try {
                root.dispatchEvent(new MouseEvent(type, {
                    bubbles: true, cancelable: true, composed: true, view: window,
                    clientX: x, clientY: y
                }));
            } catch (_) {}
        }
    }

    // Clica no botão de qualidade via fiber (abre ou fecha o menu — é um toggle)
    function clickQualityButton() {
        const btn = findQualityButton();
        if (!btn) return false;
        const fiber = getReactFiber(btn);
        if (fiber) {
            const ancestor = fiberAncestor(fiber, FIBER_ONCLICK_DEPTH);
            if (ancestor?.memoizedProps?.onClick) {
                return callFiberOnClick(ancestor, btn);
            }
            // Fallback: outros depths
            for (const d of [1, 2, 4, 0]) {
                const a = fiberAncestor(fiber, d);
                if (a?.memoizedProps?.onClick) {
                    return callFiberOnClick(a, btn);
                }
            }
        }
        try { btn.click(); return true; } catch (_) { return false; }
    }

    // Aguarda o tooltip do menu aparecer no DOM (até maxMs)
    async function waitForMenu(maxMs = 2000) {
        const step = 80;
        let elapsed = 0;
        while (elapsed < maxMs) {
            if (getVisibleQualityOptions().length > 0) return true;
            await sleep(step);
            elapsed += step;
        }
        return false;
    }

    // Abre o menu de qualidade (toggle ON)
    async function openQualityMenu() {
        triggerPlayerHover();
        await sleep(150);

        if (!findQualityButton()) {
            log('botão de qualidade não encontrado');
            return false;
        }

        log('abrindo menu via fiber depth', FIBER_ONCLICK_DEPTH);
        clickQualityButton();
        // Espera o tooltip aparecer no DOM em vez de sleep fixo
        const appeared = await waitForMenu(2000);
        if (!appeared) {
            log('tooltip não apareceu em 2s, tentando click nativo...');
            const btn = findQualityButton();
            if (btn) { try { btn.click(); } catch(_) {} }
            await waitForMenu(1500);
        }
        return true;
    }

    // Fecha o menu clicando no botão de novo (toggle OFF)
    async function closeQualityMenu() {
        if (!getVisibleQualityOptions().length) return; // já fechado
        clickQualityButton();
        await sleep(300);
    }

    // ─── Ler opções de qualidade do DOM (após menu aberto) ───────────────────

    function getVisibleQualityOptions() {
        const candidates = [];
        const seen = new Set();

        // Scopes: tooltips/menus visíveis com dados de resolução
        const scopeSelectors = [
            '.player-resolution-tooltip',
            '[class*="resolution-tooltip"]',
            '[class*="ResolutionTooltip"]',
            '[class*="quality-tooltip"]',
            '[class*="qualityTooltip"]',
            '[class*="tooltip"]',
            '[class*="popover"]',
            '[class*="dropdown"]',
            '[class*="menu"]',
            '[class*="player-resolution-tooltip"]'
        ];

        const scopes = [];
        for (const s of scopeSelectors) {
            try {
                for (const el of document.querySelectorAll(s)) {
                    if (!visible(el)) continue;
                    const t = el.textContent || '';
                    if (/\d{3,4}\s*p/i.test(t) || el.querySelector('[data-resolution]')) {
                        scopes.push(el);
                    }
                }
            } catch (_) {}
        }

        const searchIn = scopes.length ? scopes : [document];

        for (const scope of searchIn) {
            const els = scope.querySelectorAll(
                '[data-resolution], button, [role="button"], [role="option"], [role="menuitem"], li, div, span'
            );
            for (const el of els) {
                if (!visible(el)) continue;
                const text = (
                    el.getAttribute('data-resolution') ||
                    el.getAttribute('data-quality') ||
                    el.textContent || ''
                ).trim();
                const parsed = parseQuality(text);
                if (!parsed) continue;
                if (!/^\d{3,4}\s*p/i.test(parsed.raw)) continue;

                const r = el.getBoundingClientRect();
                if (r.width > 800 || r.height > 200) continue;

                const key = `${parsed.height}|${parsed.fps}|${Math.round(r.left)}|${Math.round(r.top)}`;
                if (seen.has(key)) continue;
                seen.add(key);

                const isActive = (() => {
                    let n = el;
                    for (let i = 0; i < 6 && n; i++) {
                        if (/active|selected|current/i.test(n.className || '') ||
                            n.getAttribute?.('aria-selected') === 'true') return true;
                        n = n.parentElement;
                    }
                    return false;
                })();

                // Marca como isAutoResolution se for o span de resolução automática (não clicável diretamente)
                const isAutoResolution = (typeof el.className === 'string' && el.className.includes('auto-resolution'));
                candidates.push({ element: el, ...parsed, active: isActive, isAutoResolution });
            }
        }

        // Ordena por score desc; em empate, opções clicáveis (não-auto) têm prioridade
        return candidates.sort((a, b) => {
            if (b.score !== a.score) return b.score - a.score;
            return (a.isAutoResolution ? 1 : 0) - (b.isAutoResolution ? 1 : 0);
        });
    }

    // Clica em uma opção de qualidade via fiber onClick ou .click() nativo
    function clickQualityOption(el) {
        // Tenta via React fiber primeiro
        const fiber = getReactFiber(el);
        if (fiber) {
            for (const d of [0, 1, 2, 3]) {
                const a = fiberAncestor(fiber, d);
                if (a?.memoizedProps?.onClick) {
                    if (callFiberOnClick(a, el)) {
                        log('opção clicada via fiber depth', d);
                        return true;
                    }
                }
            }
        }
        // Fallback: .click() nativo
        try { el.click(); return true; } catch (_) {}
        return false;
    }

    // ─── Lógica principal ─────────────────────────────────────────────────────

    async function setQuality(reason = 'manual') {
        if (busy) return false;
        if (!isRoomPath()) return false;

        // Se já confirmamos que está na melhor qualidade, não abre menu de novo
        // (exceto chamada manual via botão ou console)
        const isManual = reason === 'button' || reason === 'console';
        if (!isManual && isAlreadyBest()) {
            log(`[${reason}] qualidade já confirmada (score=${confirmedBestScore}), pulando`);
            return true;
        }

        busy = true;
        log(`[${reason}] abrindo menu...`);

        try {
            const opened = await openQualityMenu();
            if (!opened) {
                log('não conseguiu abrir menu');
                return false;
            }

            const options = getVisibleQualityOptions();

            // Menu não ficou visível — fecha garantido e deixa retry continuar
            if (!options.length) {
                log('menu não ficou aberto após click');
                await closeQualityMenu();
                return false;
            }

            const best = options[0];
            const active = options.find(o => o.active);

            log('opções:', options.map(o => `${o.raw}${o.active ? '[ativo]' : ''}${o.isAutoResolution ? '[auto]' : ''}`));

            // Melhor opção clicável (não é o span de auto-resolution)
            const bestClickable = options.find(o => !o.isAutoResolution);

            // Se não existe nenhuma opção clicável (stream só tem auto-span)
            if (!bestClickable) {
                log('sem opções clicáveis — auto já na melhor:', best.raw);
                qualityConfirmed = true;
                confirmedBestScore = best.score;
                lastSuccessAt = Date.now();
                stopFastRetry('already best (auto-only): ' + best.raw);
                await closeQualityMenu();
                return true;
            }

            // Se a melhor opção clicável já está ativa, confirma
            if (active && active.score >= bestClickable.score && !active.isAutoResolution) {
                log('já na melhor qualidade (manual ativo):', active.raw);
                qualityConfirmed = true;
                confirmedBestScore = bestClickable.score;
                lastSuccessAt = Date.now();
                stopFastRetry('already best: ' + active.raw);
                await closeQualityMenu();
                return true;
            }

            log('selecionando:', bestClickable.raw);
            clickQualityOption(bestClickable.element);
            await sleep(600);

            qualityConfirmed = true;
            confirmedBestScore = bestClickable.score;
            lastSuccessAt = Date.now();
            stopFastRetry('quality set: ' + bestClickable.raw);
            await closeQualityMenu();
            return true;

        } finally {
            setTimeout(() => { busy = false; }, 800);
        }
    }

    // ─── Retry / Watchdog ─────────────────────────────────────────────────────

    function stopFastRetry(reason) {
        if (fastTimer) { clearInterval(fastTimer); fastTimer = null; }
        log('fast retry parado:', reason);
    }

    function startFastRetry() {
        fastAttempts = 0;
        stopFastRetry('restart');
        fastTimer = setInterval(() => {
            if (++fastAttempts > MAX_FAST_ATTEMPTS) {
                stopFastRetry('max attempts');
                return;
            }
            setQuality('fast-retry-' + fastAttempts);
        }, FAST_RETRY_MS);
    }

    function startWatchdog() {
        if (watchdogTimer) clearInterval(watchdogTimer);
        watchdogTimer = setInterval(() => {
            if (document.hidden || busy || !isRoomPath()) return;
            // Reseta confirmação para forçar re-verificação real (detecta downgrade automático do player)
            qualityConfirmed = false;
            setQuality('watchdog');
        }, WATCHDOG_MS);
        log('watchdog ativo (re-verifica a cada ' + WATCHDOG_MS/1000 + 's)');
    }

    function startExecution(reason = 'start') {
        fastAttempts = 0;
        lastSuccessAt = 0;
        qualityConfirmed = false;
        confirmedBestScore = -1;
        log('iniciando execução:', reason);

        // Tentativas escalonadas para pegar o player em diferentes momentos de load
        [800, 1500, 3000, 5000, 8000, 13000, 20000].forEach(ms =>
            setTimeout(() => setQuality(`initial-${ms}ms`), ms)
        );

        startFastRetry();
        startWatchdog();
    }


    // ─── Botão manual ─────────────────────────────────────────────────────────

    function createButton() {
        if (document.getElementById('sc-auto-quality-btn')) return;
        const btn = document.createElement('button');
        btn.id = 'sc-auto-quality-btn';
        btn.textContent = '⚙️ MaxQ';
        btn.title = 'Forçar melhor qualidade (Stripchat)';
        Object.assign(btn.style, {
            position: 'fixed', right: '12px', bottom: '196px', zIndex: '2147483647',
            background: 'rgba(0,0,0,0.86)', color: '#fff',
            border: '1px solid rgba(255,255,255,0.25)', borderRadius: '10px',
            padding: '8px 11px', font: '13px Arial,sans-serif', cursor: 'pointer'
        });
        btn.addEventListener('click', ev => {
            ev.preventDefault();
            ev.stopPropagation();
            fastAttempts = 0;
            setQuality('button');
            startFastRetry();
        });
        document.body.appendChild(btn);
    }

    function watchRoomChange() {
        setInterval(() => {
            if (location.pathname !== lastPath) {
                lastPath = location.pathname;
                if (isRoomPath()) {
                    log('nova sala:', lastPath);
                    startExecution('room-change');
                }
            }
        }, 1000);
    }

    // ─── Init ─────────────────────────────────────────────────────────────────

    window.SCAutoQuality = {
        run: () => { qualityConfirmed = false; return setQuality('console'); },
        open: () => openQualityMenu(),
        options: () => getVisibleQualityOptions(),
        resetConfirm: () => { qualityConfirmed = false; confirmedBestScore = -1; log('confirmação resetada'); },
        status: () => ({
            busy, fastAttempts, lastSuccessAt,
            qualityConfirmed, confirmedBestScore,
            isRoom: isRoomPath(),
            btn: !!findQualityButton(),
            fiberDepths: (() => {
                const btn = findQualityButton();
                if (!btn) return [];
                const f = getReactFiber(btn);
                if (!f) return [];
                return [0,1,2,3,4,5].map(d => {
                    const a = fiberAncestor(f, d);
                    return { depth: d, hasOnClick: !!a?.memoizedProps?.onClick, type: a?.type?.name || a?.type?.displayName || typeof a?.type };
                });
            })()
        })
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            createButton();
            watchRoomChange();
            lastPath = location.pathname;
            if (isRoomPath()) startExecution('initial');
        }, { once: true });
    } else {
        createButton();
        watchRoomChange();
        lastPath = location.pathname;
        if (isRoomPath()) startExecution('initial');
    }

    log('v2.6 carregado - React Fiber Click + Smart Retry');
})();