Sleazy Fork is available in English.
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.
// ==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');
})();