// ==UserScript==
// @name CB Tipping Spree
// @author aravvn
// @namespace aravvn.tools
// @version 1.8.1
// @description Inline Gatling tipping spree inside Chaturbate's tip popup. Pattern tipping, pause/resume, progress bar, optional color cycle, and fast scheduling
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @match https://www.testbed.cb.dev/*
// @exclude https://chaturbate.com/
// @grant GM_getValue
// @grant GM_setValue
// @icon https://chaturbate.com/favicon.ico
// @license CC-BY-NC-SA-4.0
// ==/UserScript==
(() => {
'use strict';
/** ---------- Utils ---------- */
const API_BASE = location.origin; // same-origin always
const $ = (sel, root = document) => root.querySelector(sel);
const cr = (tag, props = {}, children = []) => {
const el = Object.assign(document.createElement(tag), props);
if (!Array.isArray(children)) children = [children];
for (const c of children) el.append(c);
return el;
};
const getCookie = (name) =>
document.cookie.split('; ').map(v => v.split('=')).find(([k]) => k === name)?.[1] || '';
const getCSRF = () => {
const c = getCookie('csrftoken');
if (c) return decodeURIComponent(c);
const i = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (i?.value) return i.value;
const m = document.querySelector('meta[name="csrf-token"], meta[name="csrf"]');
if (m?.content) return m.content;
return '';
};
// Locales handled as subdomains (de.chaturbate.com) and as optional first path segment (fallback).
const LOCALES = new Set([
'en','de','fr','es','it','pt','ru','tr','nl','pl','sv','da','no','fi','cs','sk','hu',
'ro','bg','el','uk','sr','hr','he','ar','fa','hi','th','id','ms','vi','ja','ko','zh'
]);
function getRoomSlug() {
// If hostname is like de.chaturbate.com, strip the locale subdomain and read first path segment.
const partsHost = location.hostname.split('.');
const isChaturbateBase = partsHost.slice(-2).join('.') === 'chaturbate.com';
if (isChaturbateBase && partsHost.length > 2) {
const maybeLocale = partsHost[0];
if (LOCALES.has(maybeLocale)) {
const seg = location.pathname.split('/').filter(Boolean)[0];
return seg || null;
}
}
// Fallback: check path-based locale (/de/room)
const partsPath = location.pathname.split('/').filter(Boolean);
if (partsPath.length >= 2 && LOCALES.has(partsPath[0])) return partsPath[1] || null;
return partsPath[0] || null;
}
const hasPattern = (perTip) => (perTip || '').includes(';');
const dynMinInterval = (perTip, colorCycle) => (hasPattern(perTip) || colorCycle ? 0.1 : 0.001);
const clampInterval = (interval, perTip, colorCycle) =>
Math.max(dynMinInterval(perTip, colorCycle), +interval || 0);
/** ---------- Color Cycle ---------- */
const FIXED_COLORS = ['lightpurple', 'darkpurple', 'darkblue', 'lightblue'];
let colorIdx = 0;
const nextColor = () => {
const c = FIXED_COLORS[colorIdx % FIXED_COLORS.length];
colorIdx = (colorIdx + 1) % FIXED_COLORS.length;
return c;
};
async function postViewerChatSettings(room, colorOverride) {
const csrf = getCSRF();
const url = `${API_BASE}/api/viewerchatsettings/${room}/`;
const payload = new URLSearchParams({
font_size: '10pt',
show_emoticons: 'true',
emoticon_autocomplete_delay: '0',
sort_users_key: 't',
room_entry_for: 'none',
room_leave_for: 'none',
c2c_notify_limit: '300',
silence_broadcasters: 'false',
allowed_chat: 'all',
collapse_notices: 'true',
highest_token_color: colorOverride || '',
mod_expire: '1',
max_pm_age: '720',
font_family: 'Arial, Helvetica',
font_color: '',
tip_volume: '0',
csrfmiddlewaretoken: csrf
});
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRFToken': csrf, 'X-Requested-With': 'XMLHttpRequest' },
body: payload
});
return res.ok;
} catch {
return false;
}
}
/** ---------- Send Tip ---------- */
function makeTipForm(room, amount, anonymous, message) {
const csrf = getCSRF();
const body = new URLSearchParams({
csrfmiddlewaretoken: csrf,
tip_amount: String(amount),
tip_type: anonymous ? 'anonymous' : 'public',
message: message || ''
});
const url = `${API_BASE}/tipping/send_tip/${encodeURIComponent(room)}/`;
return { url, body };
}
async function sendTipFetch(room, amount, anonymous, message, keepalive = false) {
const { url, body } = makeTipForm(room, amount, anonymous, message);
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRFToken': getCSRF(), 'X-Requested-With': 'XMLHttpRequest' },
body,
keepalive
});
return res.ok;
} catch {
return false;
}
}
/** ---------- Settings ---------- */
const KEY = 'gatling.v2.inline.settings';
const defaults = {
perTip: '',
numTimes: 10,
interval: 5,
colorCycle: false,
anon: false,
advBurst: 100,
advConc: 6,
advKeepalive: true
};
const load = () => Object.assign({}, defaults, GM_getValue(KEY, {}));
const save = (s) => GM_setValue(KEY, s);
const parsePattern = (perTip, numTimes) => {
if (hasPattern(perTip)) {
return perTip.split(';').map(v => +v).filter(n => Number.isFinite(n) && n > 0);
}
const v = +perTip;
const n = Math.max(1, +numTimes || 1);
return Number.isFinite(v) && v > 0 ? Array(n).fill(v) : [];
};
/** ---------- Controller ---------- */
const controller = (() => {
let running = false, paused = false, stopped = false;
let queue = [], total = 0, nextIndex = 0, doneCount = 0, room = null;
let ui = null, rafId = 0, inFlight = 0, startTime = 0;
const setUIRef = (ref) => { ui = ref; };
const getNote = () => document.querySelector('.tipMessageInput')?.value || '';
function setProgress(done, total) {
if (!ui) return;
const pct = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
ui.bar.style.width = `${pct}%`;
ui.metaDone.textContent = `${done} / ${total} done`;
}
function state(phase) {
if (!ui) return;
if (phase === 'idle' || phase === 'done') {
ui.start.disabled = false; ui.pause.disabled = true; ui.stop.disabled = true;
ui.pause.textContent = 'Pause';
} else if (phase === 'running') {
ui.start.disabled = true; ui.pause.disabled = false; ui.stop.disabled = false;
ui.pause.textContent = paused ? 'Resume' : 'Pause';
} else if (phase === 'paused') {
ui.start.disabled = true; ui.pause.disabled = false; ui.stop.disabled = false;
ui.pause.textContent = 'Resume';
}
}
async function dispatchOneAt(index, s) {
const amount = queue[index];
if (s.colorCycle) {
const color = nextColor();
await postViewerChatSettings(room, color);
}
await sendTipFetch(room, amount, s.anon, getNote(), s.advKeepalive && !s.colorCycle);
doneCount++;
setProgress(doneCount, total);
}
function fastPump() {
if (!running || stopped) return;
const s = load();
if (paused) { rafId = requestAnimationFrame(fastPump); return; }
const interval = clampInterval(s.interval, s.perTip, s.colorCycle);
const ultraFast = interval < 0.01 && !hasPattern(s.perTip) && !s.colorCycle;
const now = performance.now();
const elapsed = (now - startTime) / 1000;
const targetScheduled = interval <= 0 ? total : Math.floor(elapsed / interval);
const toSchedule = Math.max(0, Math.min(total - nextIndex, targetScheduled - nextIndex));
const conf = load();
const MAX_CONC = ultraFast ? Math.max(1, Math.min(32, (conf.advConc|0) || 1)) : 1;
const MAX_BURST = ultraFast ? Math.max(1, Math.min(500, (conf.advBurst|0) || 1)) : 5;
let launched = 0;
while (
launched < Math.min(MAX_BURST, toSchedule) &&
inFlight < MAX_CONC &&
nextIndex < total &&
running && !paused
) {
const myIndex = nextIndex++;
inFlight++; launched++;
Promise.resolve(dispatchOneAt(myIndex, s)).finally(() => { inFlight--; });
}
if (doneCount >= total) { running = false; state('done'); return; }
rafId = requestAnimationFrame(fastPump);
}
async function normalStep() {
if (!running || stopped) return;
const s = load();
if (paused) { setTimeout(normalStep, 50); state('paused'); return; }
state('running');
if (doneCount >= total) { running = false; state('done'); return; }
if (nextIndex < total) {
const myIndex = nextIndex++;
await dispatchOneAt(myIndex, s);
}
if (doneCount < total) {
const waitSec = clampInterval(s.interval, s.perTip, s.colorCycle);
setTimeout(normalStep, waitSec * 1000);
} else {
running = false; state('done');
}
}
function start() {
const slug = getRoomSlug();
if (!slug) { alert('No room detected.'); return; }
const s = load();
queue = parsePattern(s.perTip, s.numTimes);
if (!queue.length) { alert('Invalid amount/pattern. Example: "10" or "5;10;15"'); return; }
room = slug;
total = queue.length;
nextIndex = 0;
doneCount = 0;
inFlight = 0;
running = true; paused = false; stopped = false;
setProgress(0, total);
state('running');
const interval = clampInterval(s.interval, s.perTip, s.colorCycle);
startTime = performance.now();
if (interval < 0.01 && !hasPattern(s.perTip) && !s.colorCycle) {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(fastPump);
} else {
normalStep();
}
}
function stop() {
stopped = true; running = false; paused = false;
try { cancelAnimationFrame(rafId); } catch {}
state('done');
}
function togglePause() {
if (!running) return;
paused = !paused;
state(paused ? 'paused' : 'running');
}
function updateTotalsPreview() {
if (!ui) return;
const s = load();
const pattern = parsePattern(s.perTip, s.numTimes);
const minI = dynMinInterval(s.perTip, s.colorCycle);
const effI = Math.max(minI, +s.interval || 0);
const totalN = pattern.length;
const sum = pattern.reduce((a,b)=>a+b,0);
const duration = totalN * effI;
ui.metaDone.textContent = `0 / ${totalN} done`;
ui.metaTotals.textContent = `Total: ${sum} | Duration: ${duration.toFixed(3)}s`;
ui.bar.style.width = '0%';
}
return { setUIRef, start, stop, togglePause, updateTotalsPreview };
})();
/** ---------- Inline UI (compact + Advanced) ---------- */
function buildInlineUI(container) {
if ($('#gatling-inline', container)) return;
const s = load();
// Anchor somewhere stable inside the callout; prefer the row with "Toggle this window"
const flexRow = [...container.querySelectorAll('div')]
.find(d => getComputedStyle(d).display.includes('flex') && d.textContent.includes('Toggle this window'));
const anchor = flexRow ? flexRow : container.lastElementChild;
const wrap = cr('div', { id: 'gatling-inline', style: `
margin: 6px; padding: 6px; border: 1px solid #2b2f36; border-radius: 6px;
background: #101317; color: #e7e7ea; box-sizing: border-box; font-size: 12px;
`});
const grid = cr('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:4px;align-items:end;' });
const label = (t)=>cr('label',{
style:'font-size:10px;color:#aab0bb;display:block;margin-bottom:2px;', textContent:t
});
const inputStyle = [
'max-width:120px','width:auto','padding:3px 5px',
'border:1px solid #2c323a','border-radius:4px',
'background:#0d1014','color:#e7e7ea','font-size:11px','line-height:1.1'
].join(';');
const mkInput = (type, val) => cr('input',{type, value:val, style:inputStyle});
const inPer = mkInput('text', s.perTip); inPer.placeholder = 'e.g., 5;10;15';
const inNum = mkInput('number', String(s.numTimes)); inNum.min='1'; inNum.step='1';
const inInt = mkInput('number', String(s.interval)); inInt.step='0.001';
const chkAnon = cr('input',{type:'checkbox',checked:!!s.anon, style:'transform:scale(0.85);'});
const chkCol = cr('input',{type:'checkbox',checked:!!s.colorCycle, style:'transform:scale(0.85);'});
// Advanced section
const advToggle = cr('a', { href:'#', textContent:'Advanced', style:'font-size:10px;margin-left:8px;color:#9cc2ff;text-decoration:underline;cursor:pointer;' });
const advBox = cr('div', { style:'display:none;margin-top:6px;gap:6px;align-items:end;' });
const smallInput = (val, min, max) => {
const el = mkInput('number', String(val));
el.style.maxWidth = '90px';
el.step = '1';
el.min = String(min); el.max = String(max);
return el;
};
const inBurst = smallInput(s.advBurst, 1, 500);
const inConc = smallInput(s.advConc, 1, 32);
const chkKeep = cr('input',{type:'checkbox',checked:!!s.advKeepalive, style:'transform:scale(0.85);'});
advBox.append(
cr('div', {}, [label('Burst/frame'), inBurst]),
cr('div', {}, [label('Concurrency'), inConc]),
cr('div', { style:'display:flex;align-items:center;gap:4px;' }, [
chkKeep, cr('span',{style:'font-size:11px;', textContent:'keepalive (fetch)'})
])
);
grid.append(
cr('div', {}, [label('Amount / Pattern'), inPer]),
cr('div', {}, [label('Count (if no pattern)'), inNum]),
cr('div', {}, [label('Interval (sec)'), inInt]),
cr('div', { style:'display:flex;align-items:center;gap:4px;' }, [
chkAnon, cr('span',{style:'font-size:11px;', textContent:'Anonymous'}), advToggle
]),
cr('div', { style:'display:flex;align-items:center;gap:4px;' }, [
chkCol, cr('span',{style:'font-size:11px;', textContent:'Color Cycle'})
])
);
const hr = cr('div', { style: 'height:1px;background:#20242b;margin:6px 0;' });
const btnStyle = (bg) => [
'flex:1','padding:8px 10px','border-radius:8px','border:1px solid #2f3640',
`background:${bg}`,'color:#fff','font-weight:700','cursor:pointer',
'text-align:center','font-size:12px','line-height:1.1'
].join(';');
const btnStart = cr('button', { textContent:'Start', style: btnStyle('#1a7f37') });
const btnPause = cr('button', { textContent:'Pause', style: btnStyle('#25303a') });
const btnStop = cr('button', { textContent:'Stop', style: btnStyle('#cc3232') });
const btnRow = cr('div', { style:'display:flex;gap:6px;flex-wrap:wrap;' }, [btnStart, btnPause, btnStop]);
const progress = cr('div', { style:'width:100%;height:6px;background:#0c0f12;border:1px solid #2a2f37;border-radius:999px;overflow:hidden;margin-top:6px;' },
[cr('div', { className:'bar', style:'height:100%;width:0%;background:linear-gradient(90deg,#22c55e,#06b6d4,#6366f1);transition:width .2s;' })]
);
const meta = cr('div', { style:'display:flex;justify-content:space-between;font-size:11px;color:#aab0bb;margin-top:4px;' }, [
cr('span', { className:'done', textContent:'0 / 0 done' }),
cr('span', { className:'totals', textContent:'Total: 0 | Duration: 0s' })
]);
wrap.append(grid, advBox, hr, btnRow, progress, meta);
anchor?.parentElement?.insertBefore(wrap, anchor);
// Wire UI to controller
controller.setUIRef({
start: btnStart, pause: btnPause, stop: btnStop,
bar: progress.firstElementChild,
metaDone: meta.firstElementChild,
metaTotals: meta.lastElementChild
});
// Buttons
btnPause.disabled = true; btnStop.disabled = true;
btnStart.addEventListener('click', () => {
controller.start();
btnStart.disabled = true; btnPause.disabled = false; btnStop.disabled = false;
});
btnPause.addEventListener('click', () => controller.togglePause());
btnStop.addEventListener('click', () => { controller.stop(); btnStart.disabled=false; btnPause.disabled=true; btnStop.disabled=true; });
// Advanced toggle
advToggle.addEventListener('click', (e) => {
e.preventDefault();
advBox.style.display = advBox.style.display === 'none' ? 'grid' : 'none';
advBox.style.gridTemplateColumns = '1fr 1fr';
});
// Dynamic interval min + persistence
const syncIntervalMin = () => {
const min = dynMinInterval(inPer.value, chkCol.checked);
inInt.min = String(min);
const cur = parseFloat(inInt.value || '0');
if (Number.isFinite(cur) && cur < min) inInt.value = String(min);
};
const persist = () => {
const s2 = {
perTip: inPer.value.trim(),
numTimes: Math.max(1, parseInt(inNum.value || '1', 10)),
interval: parseFloat(inInt.value || '0'),
colorCycle: !!chkCol.checked,
anon: !!chkAnon.checked,
advBurst: Math.max(1, Math.min(500, parseInt(inBurst.value || '100', 10))),
advConc: Math.max(1, Math.min(32, parseInt(inConc.value || '6', 10))),
advKeepalive: !!chkKeep.checked
};
s2.interval = clampInterval(s2.interval, s2.perTip, s2.colorCycle);
save(s2);
controller.updateTotalsPreview();
};
[inPer, chkCol].forEach(el => el.addEventListener('input', () => { syncIntervalMin(); persist(); }));
[inNum, inInt, inBurst, inConc].forEach(el => el.addEventListener('input', persist));
[chkAnon, chkKeep].forEach(el => el.addEventListener('change', persist));
// initial
syncIntervalMin();
controller.updateTotalsPreview();
}
/** ---------- Observe callout & inject ---------- */
function isVisible(el) {
if (!el) return false;
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden';
}
function hookCalloutOnce() {
const callout = $('#SplitModeTipCallout');
if (!callout) return false;
if (!$('#gatling-inline', callout)) buildInlineUI(callout);
return true;
}
function startObservers() {
const bodyObs = new MutationObserver(() => {
const callout = $('#SplitModeTipCallout');
if (callout && isVisible(callout) && !$('#gatling-inline', callout))
buildInlineUI(callout);
});
bodyObs.observe(document.body, { childList: true, subtree: true });
const visObs = new MutationObserver(() => {
const callout = $('#SplitModeTipCallout');
if (callout && isVisible(callout) && !$('#gatling-inline', callout))
buildInlineUI(callout);
});
const tryAttachVisObs = () => {
const c = $('#SplitModeTipCallout');
if (c) visObs.observe(c, { attributes: true, attributeFilter: ['style','class'] });
};
let tries = 0;
const t = setInterval(() => {
if (hookCalloutOnce()) { tryAttachVisObs(); clearInterval(t); }
if (++tries > 30) clearInterval(t);
}, 300);
}
/** ---------- Bootstrap ---------- */
(function init() {
startObservers();
setTimeout(hookCalloutOnce, 200); // if already open
})();
})();