Preplan timed tips and token amounts with balance checks, progress, and a live countdown
// ==UserScript==
// @name Chaturbate Auto Tip
// @namespace urn:uuid:34b02b3e-f82c-448e-ad76-319969ef2ddf
// @version 1.0.7
// @match https://chaturbate.com/*
// @require https://code.jquery.com/jquery-4.0.0.min.js
// @require https://code.jquery.com/ui/1.14.2/jquery-ui.min.js
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @icon https://www.google.com/s2/favicons?sz=32&domain=chaturbate.com
// @icon64 https://www.google.com/s2/favicons?sz=64&domain=chaturbate.com
// @license MIT
// @run-at document-idle
// @description Preplan timed tips and token amounts with balance checks, progress, and a live countdown
// ==/UserScript==
/* globals jQuery, $, unsafeWindow */
/* eslint-disable dot-notation, no-multi-spaces */
var ID_PREFIX = '#autoTip'
var CLASS_PREFIX = '.autoTip'
var CLASS_INPUT = CLASS_PREFIX + '_input';
var HTML_IDS = {
'BUTTON': 'button',
'BUTTON_BG': 'button_bg',
'POPUP': 'popup',
'AMOUNT': 'amount',
'COUNT': 'count',
'SCHEDULE': 'schedule',
'BALANCE': 'balance',
'AFTER': 'after',
'START': 'start',
'STOP': 'stop',
'TOTAL': 'total',
'ETA': 'eta',
'PROGRESS': 'progress',
'SENT': 'sent',
'NEXT': 'next',
'REMAINING': 'remaining',
'STATUS': 'status',
'CLOSE': 'close'
}
for (var key in HTML_IDS) {
HTML_IDS[key] = ID_PREFIX + '_' + HTML_IDS[key];
}
var defaultSendTipButton = null;
var autoTipControls = null;
var initialized = false;
var runRoomName = null;
var runRoomPath = null;
var runRoomType = null;
var runViewerName = null;
var observedRoomName = null;
var observedRoomType = null;
var observedViewerName = null;
var tipsLeft = 0;
var tipFunctionTimeout = null;
var tippingRun = 0;
var tipsSent = 0;
var tokensSent = 0;
var allTimeTips = 0;
var allTimeTokens = 0;
var totalTips = 0;
var nextTipAt = null;
var countdownInterval = null;
var runState = 'idle';
var requestInFlight = false;
var activeRequest = null;
var inlineProgressDismissed = false;
var showAllSamples = false;
var LONG_WAIT_CONFIRM_MS = 10 * 60 * 1000;
var previewPlan = null;
var activePlan = null;
var juration = loadJuration();
waitForKeyElements("#sendTipButton", initialize, false);
function initialize(sendTipButton) {
defaultSendTipButton = sendTipButton;
if (initialized) {
defaultSendTipButton.after(autoTipControls);
updateStartAvailability();
return;
}
createTipperButton();
createTipperPopup();
injectCSS();
improveDefaultTipPopup();
loadPreviousSettings();
loadAllTimeTotals();
restoreRunProgress();
initializeButtonCallbacks();
updateTipperButton();
initialized = true;
// Room navigation can replace the native button without reloading the userscript.
setInterval(checkRoomNavigation, 250);
};
function createTipperButton() {
autoTipControls = $('<div id="autoTip_controls">' +
'<div id="autoTip_button_bg" class="autoTip_trigger" role="button" tabindex="0" ' +
'aria-haspopup="dialog" aria-controls="autoTip_popup" aria-expanded="false"></div>' +
'<div id="autoTip_inline_progress" style="display:none">' +
'<span id="autoTip_inline_count">0/0</span>' +
'<span id="autoTip_inline_timer" role="timer" aria-live="off" style="display:none">' +
'<svg id="autoTip_inline_clock" viewBox="0 0 16 16" aria-hidden="true" focusable="false">' +
'<circle cx="8" cy="8" r="6"></circle><path d="M8 4.5v3.8l2.5 1.5"></path></svg>' +
'<span id="autoTip_inline_time_value"></span></span>' +
'<span class="autoTip_inline_track" role="progressbar" aria-label="Tips sent" ' +
'aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">' +
'<span id="autoTip_inline_fill"></span>' +
'<span id="autoTip_inline_segments"></span></span>' +
'<span id="autoTip_inline_next"></span>' +
'<button type="button" id="autoTip_quick_stop" aria-label="Stop auto tipping" title="Stop auto tipping">■</button>' +
'<button type="button" id="autoTip_dismiss_progress" aria-label="Dismiss Auto Tip progress" ' +
'title="Dismiss progress" style="display:none">×</button>' +
'</div></div>');
defaultSendTipButton.after(autoTipControls);
}
function currentRoomName() {
var route = /^\/([A-Za-z0-9_]+)\/?$/.exec(location.pathname);
if (!route) return null;
var roomNames = document.querySelectorAll('[data-testid="roomSubject-roomName"]');
if (!roomNames.length) return null;
var name = null;
for (var i = 0; i < roomNames.length; i++) {
var displayedName = roomNames[i].textContent.trim();
if (!/^[A-Za-z0-9_]+$/.test(displayedName) ||
displayedName.toLowerCase() !== route[1].toLowerCase() ||
(name && displayedName.toLowerCase() !== name.toLowerCase())) return null;
name = displayedName;
}
return name;
}
function currentRoomType() {
var control = document.querySelector('[data-testid="start-private-show"]');
if (!control) return null;
var label = control.textContent.trim().replace(/\s+/g, ' ');
if (label === 'Leave Private Show') return 'private';
if (label === 'Start Private Show') return 'public';
return null;
}
function currentViewerName() {
var pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
var context = pageWindow['$reactAppContext'];
var username = context && context.logged_in_user && context.logged_in_user.username;
return typeof username === 'string' && /^[A-Za-z0-9_]+$/.test(username) ?
username : null;
}
function checkRoomNavigation() {
var roomName = currentRoomName();
var roomType = currentRoomType();
var viewerName = currentViewerName();
if (roomName !== observedRoomName || roomType !== observedRoomType ||
viewerName !== observedViewerName) {
observedRoomName = roomName;
observedRoomType = roomType;
observedViewerName = viewerName;
if (runState !== 'sending' && runState !== 'waiting') updateStartAvailability();
}
if (runState !== 'sending' && runState !== 'waiting') return;
if (roomName === runRoomName && location.pathname === runRoomPath &&
roomType === runRoomType && viewerName === runViewerName) return;
stopTipping('Stopped because the room, show type, or account changed. Start a new plan.' +
(requestInFlight ? ' A tip already being sent may still complete.' : ''), 'stopped');
$('#autoTip_progress_heading').text('Previous run');
}
function updateTipperButton() {
var label = tipsLeft === 0 ? 'AUTO TIP' : 'AUTO TIP (' + tipsLeft + ')';
$(HTML_IDS['BUTTON_BG']).text(label);
}
function createTipperPopup() {
$('body').append(`
<section class="overlay_popup" id="autoTip_popup" role="dialog" aria-label="Auto Tip" tabindex="-1">
<div class="title">
<span>Auto Tip</span>
<button type="button" id="autoTip_close" aria-label="Close dialog">×</button>
</div>
<div class="body">
<section class="autoTip_section autoTip_settings" aria-labelledby="autoTip_settings_heading">
<h3 id="autoTip_settings_heading">Settings</h3>
<form id="autoTip_form">
<div class="autoTip_grid">
<div>
<div class="autoTip_label_row">
<label for="autoTip_amount">
<svg class="autoTip_token_icon" viewBox="0 0 22 20" aria-hidden="true" focusable="false">
<path id="autoTip_token_shape" d="M16.606 5.216a.27.27 0 0 1-.5 0l-.25-.575a2.2 2.2 0 0 0-1.133-1.15l-.692-.307a.286.286 0 0 1 0-.517l.675-.3c.517-.233.925-.65 1.15-1.182l.258-.617c.092-.225.409-.225.509 0l.258.617a2.2 2.2 0 0 0 1.15 1.182l.675.3c.217.1.217.417 0 .517l-.691.308c-.5.225-.909.633-1.134 1.15l-.25.574zM19.606 7.449l.1-.225c.175-.4.484-.717.875-.883l.3-.133a.214.214 0 0 0 0-.384l-.283-.125a1.74 1.74 0 0 1-.892-.916l-.1-.241c-.067-.167-.3-.167-.375 0l-.1.241a1.71 1.71 0 0 1-.892.916l-.283.125a.214.214 0 0 0 0 .384l.3.133c.392.175.7.491.875.883l.1.225c.075.166.3.166.367 0zM14.29 6.732c3.125 3.907 3.933 8.455 1.8 10.163l-2.576 2.058c-2.133 1.707-6.392-.075-9.525-3.982C.922 11.13.089 6.674 2.081 4.9l.108-.092 2.575-2.057c2.133-1.708 6.392.075 9.525 3.981zm-7.718 6.173c-1.916-2.39-2.958-5.023-2.991-7.097l-.35.283c-1.209.966-.575 4.557 2.058 7.847s5.859 4.64 7.125 3.782l.075-.059.359-.283c-2.017-.483-4.359-2.082-6.276-4.473zm6.426-5.148c-2.634-3.29-5.992-4.698-7.209-3.724S5.214 8.59 7.847 11.88s5.992 4.698 7.21 3.724c1.216-.975.574-4.557-2.06-7.847z" fill="currentColor"/>
</svg>Amount per tip</label>
<details class="autoTip_amount_help">
<summary aria-label="Amount per tip format help">?</summary>
<div class="autoTip_amount_tooltip" id="autoTip_amount_help_text">
Enter <code>1</code> for a fixed amount, or <code>1-5</code> to choose a whole-token amount from 1 to 5 separately for each tip. You can review the sampled amounts before sending.
</div>
</details>
</div>
<input type="text" id="autoTip_amount" class="autoTip_input"
placeholder="1 or 1-5" spellcheck="false" aria-describedby="autoTip_amount_help_text"></div>
<div><label for="autoTip_count">Number of tips</label>
<input type="number" min="1" step="1" id="autoTip_count" class="autoTip_input"></div>
</div>
<div class="autoTip_timing">
<label for="autoTip_schedule">Timing</label>
<input type="text" id="autoTip_schedule" class="autoTip_input"
placeholder="5s or 1s-1m" spellcheck="false"
aria-describedby="autoTip_immediate autoTip_delay_preview">
<div class="autoTip_timing_support">
<p class="autoTip_immediate" id="autoTip_immediate">First tip sends immediately.</p>
<button type="button" id="autoTip_timing_help_toggle" aria-expanded="false"
aria-controls="autoTip_timing_help">Timing formats ▾</button>
</div>
<div class="autoTip_timing_help" id="autoTip_timing_help" hidden>
<div class="autoTip_timing_example"><code>5s</code><span>5 seconds between tips</span></div>
<div class="autoTip_timing_example"><code>1s-1m</code><span>A new wait from 1 second to 1 minute for each gap</span></div>
<div class="autoTip_timing_example"><code>3s, 4s-6s</code><span>First gap 3 seconds; later gaps 4–6 seconds</span></div>
<p class="autoTip_timing_note">Planned wait covers pauses. Server response time may extend the run.</p>
</div>
</div>
<p class="autoTip_delay_preview" id="autoTip_delay_preview"></p>
</form>
</section>
<section class="autoTip_section autoTip_plan" aria-labelledby="autoTip_plan_heading">
<div class="autoTip_plan_heading">
<h3 id="autoTip_plan_heading">Plan</h3>
<button type="button" id="autoTip_reroll" aria-label="Resample random tip amounts and waits"
title="Resample random tip amounts and waits; confirmations reset">↻</button>
</div>
<div class="autoTip_plan_metrics">
<div>
<span class="autoTip_metric_label"><svg viewBox="0 0 22 20" aria-hidden="true" focusable="false"><use href="#autoTip_token_shape"></use></svg>Tokens to send</span>
<strong id="autoTip_total">—</strong>
</div>
<div title="Planned pauses only; server response time can add to the run">
<span class="autoTip_metric_label"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" class="autoTip_clock_icon"><circle cx="8" cy="8" r="6"/><path d="M8 4.5v3.8l2.5 1.5"/></svg>Planned wait</span>
<strong id="autoTip_eta">—</strong>
</div>
<div class="autoTip_metric_secondary">
<span class="autoTip_metric_label"><svg viewBox="0 0 22 20" aria-hidden="true" focusable="false"><use href="#autoTip_token_shape"></use></svg>Available</span>
<strong id="autoTip_balance">—</strong>
</div>
<div class="autoTip_metric_secondary">
<span class="autoTip_metric_label"><svg viewBox="0 0 22 20" aria-hidden="true" focusable="false"><use href="#autoTip_token_shape"></use></svg>After plan</span>
<strong id="autoTip_after">—</strong>
</div>
</div>
<small id="autoTip_range"></small>
<details class="autoTip_samples"><summary>Show tip-by-tip plan</summary>
<div id="autoTip_plan_preview"></div><button type="button" id="autoTip_show_all_samples" hidden>Show all sampled tips</button></details>
<div class="autoTip_confirmations">
<label><input type="checkbox" id="autoTip_confirm_tokens" disabled>
<span id="autoTip_confirm_tokens_label">100+ token plans require confirmation</span></label>
<label><input type="checkbox" id="autoTip_confirm_time" disabled>
<span id="autoTip_confirm_time_label">10+ minute plans require confirmation</span></label>
</div>
<p id="autoTip_start_hint" role="status"></p>
<div class="autoTip_actions">
<button type="button" id="autoTip_start" aria-describedby="autoTip_start_hint autoTip_immediate">Send first tip now</button>
<button type="button" id="autoTip_stop" disabled>Stop</button>
</div>
</section>
<section class="autoTip_section autoTip_progress" id="autoTip_progress_section"
aria-labelledby="autoTip_progress_heading" hidden>
<h3 id="autoTip_progress_heading">Progress</h3>
<div class="autoTip_progress_grid">
<div><span>Tips sent</span><strong id="autoTip_progress">0 / 0</strong></div>
<div><span>Tokens sent</span><strong id="autoTip_sent">0 tokens</strong></div>
<div><span>Next tip</span><strong id="autoTip_next">—</strong></div>
<div><span>Wait remaining</span><strong id="autoTip_remaining">—</strong></div>
</div>
<p id="autoTip_status" role="status">Ready</p>
</section>
<p class="autoTip_lifetime">All-time: <strong id="autoTip_all_tips">0</strong> tips ·
<strong id="autoTip_all_tokens">0</strong> tokens</p>
</div>
</section>`);
}
function injectCSS() {
$(HTML_IDS['POPUP']).css({ display: 'none', position: 'fixed', left: 0, top: 0, 'z-index': 99999 });
$(HTML_IDS['POPUP']).draggable({ handle: '.title', cancel: '#autoTip_close' });
$('head').append(`<style id="autoTip_styles">
#autoTip_controls{position:static!important;display:inline-flex!important;align-items:center!important;gap:8px!important;vertical-align:top!important;margin:11px 0 11px 4px!important;white-space:nowrap!important}
#autoTip_button_bg{position:static!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;float:none!important;margin:0!important;width:auto!important;max-width:none!important;min-width:0!important;height:24px!important;padding:3px 10px!important;box-sizing:border-box!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important;background:#008b13!important;border:1px solid #008b13!important;border-radius:5px!important;color:#fff!important;opacity:1!important;cursor:pointer!important;font:12px/1.4 UbuntuMedium,Helvetica,Arial,sans-serif!important}
#autoTip_inline_progress{display:inline-flex;align-items:center;gap:7px;margin-left:0;height:24px;box-sizing:border-box;padding:0 7px;
border:1px solid #52677a;border-radius:6px;background:#152434;color:#e8f3fa;
font:12px Arial,sans-serif;vertical-align:middle;white-space:nowrap}
#autoTip_inline_count{min-width:55px;text-align:right;font-weight:700}
#autoTip_inline_timer{flex:0 0 74px;width:74px;align-items:center;justify-content:flex-start;gap:4px;color:#b8d5e2;font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
#autoTip_inline_clock{display:block;width:12px;height:12px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
#autoTip_inline_progress .autoTip_inline_track{display:block;position:relative;width:112px;height:8px;
overflow:hidden;border-radius:5px;background:#435567}
#autoTip_inline_fill{display:block;position:absolute;left:0;top:0;width:0;height:100%;
border-radius:5px;background:#24c5a5;transition:width .25s ease}
#autoTip_inline_segments{display:flex;position:absolute;inset:0}
#autoTip_inline_segments span{flex:1;border-right:1px solid rgba(255,255,255,.55)}
#autoTip_inline_next{display:inline-flex;align-items:center;gap:5px;min-width:42px;font-size:11px;color:#b8d5e2}
#autoTip_inline_tip_pill{display:inline-flex;align-items:center;gap:3px;padding:2px 6px;border-radius:999px;background:#e7f3f9;color:#125478;font-weight:700;white-space:nowrap}
#autoTip_inline_tip_pill .autoTip_token_icon{width:12px;height:12px;flex:none;color:inherit}
#autoTip_inline_progress[data-state="error"] #autoTip_inline_fill{background:#e96565}
#autoTip_inline_progress[data-state="stopped"] #autoTip_inline_fill{background:#a5b4c1}
#autoTip_quick_stop{display:inline-flex;align-items:center;justify-content:center;width:21px;height:21px;padding:0;border:1px solid #922018;border-radius:4px;background:#c53027;color:#fff;cursor:pointer;font-size:10px;line-height:1}
#autoTip_quick_stop:hover{background:#a8251d}
#autoTip_quick_stop:focus-visible{outline:2px solid #fff;outline-offset:2px}
#autoTip_dismiss_progress{display:inline-flex;align-items:center;justify-content:center;width:20px;height:21px;padding:0;border:0;background:transparent;color:#dce7ef;cursor:pointer;font-size:19px;line-height:1}
#autoTip_dismiss_progress:hover{color:#fff}
#autoTip_dismiss_progress:focus-visible{outline:2px solid #fff;outline-offset:2px}
#autoTip_popup{position:fixed!important;width:390px!important;max-width:calc(100vw - 16px)!important;
max-height:calc(100vh - 16px)!important;overflow:auto!important;padding:0!important;box-sizing:border-box!important;
border:1px solid #bfd0dc!important;border-radius:12px!important;background:#fff!important;color:#223344!important;
box-shadow:0 18px 48px rgba(15,23,42,.3)!important;font:14px/1.4 Arial,sans-serif!important}
#autoTip_popup *{box-sizing:border-box!important}
#autoTip_popup .title{display:flex!important;align-items:center!important;justify-content:space-between!important;
padding:11px 16px!important;background:#edf5f9!important;color:#125478!important;
font-size:17px!important;font-weight:700!important;cursor:move!important}
#autoTip_popup .body{padding:0 16px 12px!important;background:#fff!important}
#autoTip_popup .autoTip_section{margin:0!important;padding:13px 0!important;border-bottom:1px solid #e2e8ee!important}
#autoTip_popup .autoTip_section h3{margin:0 0 9px!important;color:#194d69!important;
font-size:12px!important;font-weight:700!important;letter-spacing:.04em!important;text-transform:uppercase!important}
#autoTip_popup .autoTip_grid{display:grid!important;grid-template-columns:1fr 1fr!important;gap:10px!important}
#autoTip_popup .autoTip_timing{margin-top:12px!important}
#autoTip_popup label{display:block!important;margin-bottom:5px!important;color:#475569!important;
font-size:12px!important;font-weight:700!important}
#autoTip_popup .autoTip_input{display:block!important;width:100%!important;height:36px!important;
margin:0!important;padding:7px 9px!important;border:1px solid #b9cad6!important;border-radius:6px!important;
background:#fff!important;color:#1e293b!important;font-size:14px!important}
#autoTip_popup .autoTip_input:focus{outline:2px solid #1a81ae!important;outline-offset:1px!important}
#autoTip_popup .autoTip_label_row{display:flex!important;align-items:center!important;gap:5px!important;position:relative!important;margin-bottom:5px!important}
#autoTip_popup .autoTip_label_row label{display:inline-flex!important;align-items:center!important;gap:5px!important;margin:0!important}
#autoTip_popup .autoTip_token_icon{width:15px!important;height:15px!important;flex:none!important;color:#52677a!important}
#autoTip_popup .autoTip_amount_help{position:relative!important}
#autoTip_popup .autoTip_amount_help summary{display:flex!important;align-items:center!important;justify-content:center!important;width:16px!important;height:16px!important;border:1px solid #9ab0be!important;border-radius:50%!important;color:#35637c!important;cursor:pointer!important;font-size:11px!important;font-weight:700!important;list-style:none!important}
#autoTip_popup .autoTip_amount_help summary::-webkit-details-marker{display:none!important}
#autoTip_popup .autoTip_amount_tooltip{position:absolute!important;z-index:5!important;top:22px!important;left:-115px!important;width:255px!important;max-width:calc(100vw - 42px)!important;padding:8px 10px!important;border:1px solid #b8cbd8!important;border-radius:6px!important;background:#fff!important;box-shadow:0 6px 18px rgba(15,23,42,.17)!important;color:#334155!important;font-size:11px!important;line-height:1.45!important;font-weight:400!important}
#autoTip_popup .autoTip_timing_support{display:flex!important;align-items:baseline!important;justify-content:space-between!important;gap:8px!important;flex-wrap:wrap!important;margin-top:5px!important}
#autoTip_popup .autoTip_immediate{margin:0!important;color:#38566a!important;font-size:11px!important;font-weight:700!important}
#autoTip_popup #autoTip_timing_help_toggle{padding:0!important;border:0!important;background:transparent!important;color:#0d658e!important;cursor:pointer!important;font:600 12px/1.3 Arial,sans-serif!important}
#autoTip_popup .autoTip_timing_help[hidden]{display:none!important}
#autoTip_popup .autoTip_timing_help{margin-top:7px!important;padding:7px 9px!important;border-radius:6px!important;background:#f5f8fa!important;color:#475569!important;font-size:11px!important}
#autoTip_popup .autoTip_timing_example{display:grid!important;grid-template-columns:60px minmax(0,1fr)!important;align-items:start!important;gap:8px!important;margin:0!important;padding:4px 0!important;border-bottom:1px solid #e3eaf0!important;line-height:1.35!important}
#autoTip_popup .autoTip_timing_example:last-of-type{border-bottom:0!important}
#autoTip_popup .autoTip_timing_example code{justify-self:start!important;white-space:nowrap!important;font:600 11px/1.35 Arial,Helvetica,sans-serif!important;padding:1px 3px!important}
#autoTip_popup .autoTip_timing_example span{display:block!important;float:none!important;max-width:100%!important}
#autoTip_popup .autoTip_timing_note{display:block!important;clear:both!important;float:none!important;width:100%!important;margin:6px 0 0!important;color:#64748b!important;line-height:1.35!important}
#autoTip_popup .autoTip_samples summary{cursor:pointer!important;color:#0d658e!important}
#autoTip_popup code{padding:1px 3px!important;border-radius:3px!important;background:#edf4f7!important;color:#24536b!important}
#autoTip_popup .autoTip_delay_preview{margin:8px 0 0!important;color:#24536b!important;font-size:12px!important;font-weight:600!important}
#autoTip_popup .autoTip_plan_heading{display:flex!important;align-items:center!important;justify-content:space-between!important;margin-bottom:10px!important;padding-right:9px!important}
#autoTip_popup .autoTip_plan_heading h3{margin:0!important}
#autoTip_popup .autoTip_plan_metrics{display:grid!important;grid-template-columns:minmax(0,1fr) minmax(0,1fr)!important;gap:7px 8px!important}
#autoTip_popup .autoTip_plan_metrics>div{min-width:0!important;padding:8px 10px!important;border-radius:7px!important;background:#eef7fa!important}
#autoTip_popup .autoTip_plan_metrics .autoTip_metric_secondary{background:#f5f8fa!important}
#autoTip_popup .autoTip_metric_label{display:flex!important;align-items:center!important;gap:5px!important;color:#475569!important;font-size:11px!important;white-space:nowrap!important}
#autoTip_popup .autoTip_metric_label svg{width:13px!important;height:13px!important;flex:none!important;fill:currentColor!important;color:#647e8f!important}
#autoTip_popup .autoTip_metric_label .autoTip_clock_icon{fill:none!important;stroke:currentColor!important;stroke-width:1.5!important;stroke-linecap:round!important;stroke-linejoin:round!important}
#autoTip_popup .autoTip_plan_metrics strong{display:block!important;margin-top:2px!important;color:#0d658e!important;font-size:18px!important;overflow-wrap:anywhere!important}
#autoTip_popup .autoTip_metric_secondary strong{color:#334155!important;font-size:15px!important}
#autoTip_popup #autoTip_after[data-insufficient=true]{color:#b42318!important}
#autoTip_popup #autoTip_range:not(:empty){display:block!important;margin-top:5px!important;color:#b42318!important;font-size:11px!important}
#autoTip_popup .autoTip_samples{margin-top:5px!important;color:#64748b!important;font-size:11px!important}
#autoTip_popup #autoTip_plan_preview{display:flex!important;flex-wrap:wrap!important;align-items:center!important;gap:4px!important;max-height:112px!important;overflow:auto!important;margin-top:6px!important}
#autoTip_popup .autoTip_sample_pill{display:inline-flex!important;align-items:center!important;gap:4px!important;padding:3px 7px!important;border-radius:999px!important;background:#e7f3f9!important;color:#125478!important;white-space:nowrap!important;font-size:11px!important;font-weight:700!important}
#autoTip_popup .autoTip_sample_pill .autoTip_token_icon{width:12px!important;height:12px!important;color:inherit!important}
#autoTip_popup .autoTip_sample_wait{display:inline-flex!important;align-items:center!important;gap:3px!important;margin-left:3px!important;padding-left:6px!important;border-left:1px solid #bfd6e3!important;color:#475569!important;font-weight:400!important}
#autoTip_popup #autoTip_show_all_samples{margin-top:5px!important;padding:0!important;border:0!important;background:transparent!important;color:#0d658e!important;cursor:pointer!important;font-size:11px!important}
#autoTip_popup #autoTip_reroll{width:26px!important;height:26px!important;padding:0!important;
border:1px solid #c7d9e3!important;border-radius:6px!important;background:#f6fafc!important;
color:#0d658e!important;font-size:20px!important;line-height:1!important;cursor:pointer!important}
#autoTip_popup #autoTip_reroll:disabled{opacity:.35!important;cursor:default!important}
#autoTip_popup .autoTip_confirmations{display:grid!important;gap:3px!important;margin-top:10px!important}
#autoTip_popup .autoTip_confirmations label{display:flex!important;align-items:center!important;gap:7px!important;
margin:0!important;font-size:11px!important}
#autoTip_popup .autoTip_confirmations input{margin:0!important}
#autoTip_popup .autoTip_confirmations label:has(input:disabled){opacity:.5!important}
#autoTip_popup #autoTip_start_hint{margin:6px 0 0!important;min-height:14px!important;color:#b42318!important;font-size:11px!important}
#autoTip_popup .autoTip_actions{display:flex!important;gap:8px!important;margin-top:7px!important}
#autoTip_popup .autoTip_actions button{min-width:80px!important;height:42px!important;padding:8px 13px!important;border-radius:6px!important;cursor:pointer!important;font:600 15px/1.2 Arial,sans-serif!important}
#autoTip_popup #autoTip_start{min-width:150px!important;background:#168043!important;border:1px solid #116735!important;color:#fff!important;font-weight:700!important}
#autoTip_popup #autoTip_stop{background:#fff!important;border:1px solid #b8c8d2!important;color:#334155!important}
#autoTip_popup #autoTip_stop:not(:disabled){border-color:#b42318!important;color:#b42318!important}
#autoTip_popup #autoTip_stop:not(:disabled):hover{background:#fff3f1!important}
#autoTip_popup .autoTip_actions button:focus-visible{outline:2px solid #145d83!important;outline-offset:2px!important}
#autoTip_popup .autoTip_actions button:disabled{opacity:.45!important;cursor:default!important}
#autoTip_popup .autoTip_progress[hidden]{display:none!important}
#autoTip_popup .autoTip_progress{margin-top:0!important}
#autoTip_popup .autoTip_progress_grid{display:grid!important;grid-template-columns:1fr 1fr!important;gap:7px 15px!important}
#autoTip_popup .autoTip_progress[data-state=stopped] .autoTip_progress_grid>div:nth-child(n+3),
#autoTip_popup .autoTip_progress[data-state=complete] .autoTip_progress_grid>div:nth-child(n+3),
#autoTip_popup .autoTip_progress[data-state=error] .autoTip_progress_grid>div:nth-child(n+3){display:none!important}
#autoTip_popup .autoTip_progress_grid>div{display:flex!important;flex-direction:column!important;
padding:7px 9px!important;border-radius:6px!important;background:#f5f7f9!important}
#autoTip_popup .autoTip_progress_grid span{color:#64748b!important;font-size:11px!important}
#autoTip_popup .autoTip_progress_grid strong{color:#0d658e!important;font-size:14px!important}
#autoTip_popup #autoTip_status{margin:8px 0 0!important;color:#475569!important;font-size:12px!important}
#autoTip_popup #autoTip_status[data-state=error]{color:#b42318!important}
#autoTip_popup #autoTip_status[data-state=complete]{color:#177245!important}
#autoTip_popup .autoTip_lifetime{margin:9px 0 0!important;color:#64748b!important;font-size:11px!important}
#autoTip_popup #autoTip_close{padding:0!important;border:0!important;background:transparent!important;
color:#475569!important;font-size:23px!important;line-height:1!important;cursor:pointer!important}
</style>`);
}
function positionTipperPopup() {
var button = $(HTML_IDS['BUTTON_BG'])[0];
var popup = $(HTML_IDS['POPUP']);
if (!button || !popup.length) return;
var rect = button.getBoundingClientRect();
var width = popup.outerWidth();
var height = popup.outerHeight();
var gap = 8;
var left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
var above = rect.top - height - gap;
var top = above >= 8 ? above : rect.bottom + gap;
top = Math.max(8, Math.min(top, window.innerHeight - height - 8));
popup.css({ 'left': left + 'px', 'top': top + 'px' });
}
function improveDefaultTipPopup() {
$('#SplitModeTipCallout').draggable();
$('#SplitModeTipCallout .tipMessageInput').parent()
.append('<input type="checkbox" id="tip_keepopen"></input>')
.append('<label for="tip_keepopen">Keep this window open after tipping</label>');
var tipPopup = $('#SplitModeTipCallout');
var keepOpenCheckbox = $('#tip_keepopen');
var popupIsForcedOpen = false;
var tipPopupForm = $('#SplitModeTipCallout form');
tipPopupForm.on('submit', onFormSubmit);
$('body').on('click', function (ev) {
if ($('#SplitModeTipCallout .sendTip button[type="submit"]').is(ev.target)) {
popupIsForcedOpen = false;
return;
}
if (!popupIsForcedOpen || tipPopup.has(ev.target).length) {
return;
}
if (tipPopup.is(':visible')) {
tipPopup.hide();
}
popupIsForcedOpen = false;
});
function onFormSubmit() {
if (!keepOpenCheckbox.is(':checked')) {
return;
}
if (!tipPopup.is(':visible')) {
tipPopup.show();
popupIsForcedOpen = true;
keepPopupOpen();
}
}
function keepPopupOpen() {
if (!tipPopup.is(':visible')) {
defaultSendTipButton.trigger('click');
tipPopup.show();
return;
}
setTimeout(() => {
keepPopupOpen();
}, 100);
}
}
function parseAmountInput(value) {
var match = String(value).trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if (!match) throw new Error('Amount must be a whole number or a range, such as 1-3.');
var min = Number(match[1]);
var max = match[2] === undefined ? min : Number(match[2]);
if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min < 1 || max < min) {
throw new Error('Amount range must contain positive whole numbers in ascending order.');
}
return { min: min, max: max };
}
function parseTimingPart(value) {
var parts = value.split('-');
if (parts.length > 2 || parts.some(function (part) { return !part.trim(); })) {
throw new Error('Use a duration or a range such as 4s-6s.');
}
var min = parseDuration(parts[0]);
var max = parts.length === 2 ? parseDuration(parts[1]) : min;
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min || max === 0) {
throw new Error('Timing range must be positive and in ascending order.');
}
return { min: min, max: max };
}
function parseSchedule(value) {
var parts = String(value).split(',');
if (parts.length > 2 || !parts[0].trim() || (parts.length === 2 && !parts[1].trim())) {
throw new Error('Use 5s, 1s-1m, or 3s, 4s-6s for timing.');
}
var firstGap = null;
if (parts.length === 2) {
firstGap = parseDuration(parts[0]);
if (!Number.isFinite(firstGap) || firstGap < 0) {
throw new Error('The first gap must be a non-negative duration.');
}
}
return { firstGap: firstGap, later: parseTimingPart(parts[parts.length - 1].trim()) };
}
function randomInteger(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function buildPlan() {
var countText = String($(HTML_IDS['COUNT']).val()).trim();
if (!/^\d+$/.test(countText)) throw new Error('Number of tips must be a positive whole number.');
var count = Number(countText);
if (!Number.isSafeInteger(count) || count < 1 || count > 10000) {
throw new Error('Number of tips must be between 1 and 10,000.');
}
var amount = parseAmountInput($(HTML_IDS['AMOUNT']).val());
var schedule = parseSchedule($(HTML_IDS['SCHEDULE']).val());
if (!Number.isSafeInteger(count * amount.max)) throw new Error('Planned token total is too large.');
var amounts = [];
var delays = [];
var total = 0;
var waitMs = 0;
for (var i = 0; i < count; i++) {
var tokens = randomInteger(amount.min, amount.max);
amounts.push(tokens);
total += tokens;
if (i === count - 1) continue;
var seconds = i === 0 && schedule.firstGap !== null ? schedule.firstGap :
getRandomNumber(schedule.later.min, schedule.later.max);
var delay = Math.round(seconds * 1000);
if (delay > 2147483647) throw new Error('Each wait must be less than 24 days.');
delays.push(delay);
waitMs += delay;
}
return {
amounts: amounts, delays: delays, total: total, waitMs: waitMs,
amount: amount, schedule: schedule
};
}
function getAvailableBalance() {
var element = document.querySelector('[data-testid="header-token-balance"]');
if (!element) return null;
var raw = element.textContent.trim().replace(/,/g, '');
return /^\d+$/.test(raw) ? Number(raw) : null;
}
function renderBalance() {
var balance = getAvailableBalance();
$(HTML_IDS['BALANCE']).text(balance === null ? 'Unavailable' : balance + ' tokens');
var after = $(HTML_IDS['AFTER']);
if (balance === null || !previewPlan) {
after.text('—').attr('data-insufficient', 'false');
}
else {
var remainingCost = previewPlan.total;
if (activePlan === previewPlan &&
(runState === 'sending' || runState === 'waiting' || runState === 'complete')) {
remainingCost = activePlan.amounts.slice(tipsSent).reduce(function (sum, n) { return sum + n; }, 0);
}
after.text(balance < remainingCost ?
'Short by ' + (remainingCost - balance) + ' tokens' :
(balance - remainingCost) + ' tokens')
.attr('data-insufficient', balance < remainingCost ? 'true' : 'false');
}
updateStartAvailability();
}
function refreshPlanPreview() {
try {
previewPlan = buildPlan();
$(HTML_IDS['TOTAL']).text(previewPlan.total + ' tokens');
$(HTML_IDS['ETA']).text(formatDuration(previewPlan.waitMs / 1000));
$('#autoTip_range').text('');
var schedule = previewPlan.schedule;
var later = schedule.later.min === schedule.later.max ?
formatDuration(schedule.later.min) :
formatDuration(schedule.later.min) + '–' + formatDuration(schedule.later.max);
$('#autoTip_delay_preview').text(schedule.firstGap === null ?
'Wait before later tips: ' + later + '.' :
'First gap: ' + formatDuration(schedule.firstGap) + '; later gaps: ' + later + '.');
showAllSamples = false;
renderPlanSamples();
$('#autoTip_reroll').toggle(planHasRandomness(previewPlan));
renderBalance();
updatePlanConfirmations();
return true;
}
catch (ex) {
previewPlan = null;
$(HTML_IDS['TOTAL']).text('—');
$(HTML_IDS['ETA']).text('—');
$('#autoTip_range').text(ex.message || String(ex));
$('#autoTip_delay_preview').text('Enter valid settings to preview the plan.');
$('#autoTip_plan_preview').empty();
$('#autoTip_show_all_samples').prop('hidden', true);
$('#autoTip_reroll').hide();
renderBalance();
updatePlanConfirmations();
return false;
}
}
function renderPlanSamples() {
if (!previewPlan) return;
var count = previewPlan.amounts.length;
var shown = showAllSamples ? count : Math.min(count, 20);
var chips = [];
for (var i = 0; i < shown; i++) {
var amount = previewPlan.amounts[i];
var hasWait = i < previewPlan.delays.length;
var wait = hasWait ? formatDuration(previewPlan.delays[i] / 1000) : '';
var label = 'Tip ' + (i + 1) + ': ' + amount + ' tokens' +
(hasWait ? '; then wait ' + wait + ' before tip ' + (i + 2) : '');
chips.push('<span class="autoTip_sample_pill" role="group" aria-label="' + label +
'"><span aria-hidden="true">#' + (i + 1) + '</span>' +
'<svg class="autoTip_token_icon" viewBox="0 0 22 20" aria-hidden="true" focusable="false">' +
'<use href="#autoTip_token_shape"></use></svg>' +
'<span aria-hidden="true">' + amount + '</span>' +
(hasWait ? '<span class="autoTip_sample_wait" aria-hidden="true">◷ ' + wait +
'</span>' : '') + '</span>');
}
$('#autoTip_plan_preview').html(chips.join(''));
$('#autoTip_show_all_samples').prop('hidden', showAllSamples || count <= shown)
.text('Show all ' + count + ' sampled tips');
}
function planHasRandomness(plan) {
if (!plan) return false;
if (plan.amount.min < plan.amount.max) return true;
var laterGaps = plan.delays.length - (plan.schedule.firstGap === null ? 0 : 1);
return laterGaps > 0 && plan.schedule.later.min < plan.schedule.later.max;
}
function updateStartAvailability() {
var running = runState === 'sending' || runState === 'waiting';
var reason = '';
if (!running) {
if (!currentRoomName() || !defaultSendTipButton ||
!defaultSendTipButton.length || !defaultSendTipButton[0].isConnected) {
reason = 'Open a room and wait for its tip button to load.';
}
else if (!currentRoomType()) {
reason = 'Room show type is unavailable. Wait for the room to finish loading.';
}
else if (!currentViewerName()) {
reason = 'Signed-in username is unavailable. Wait for your account to load.';
}
else if (!previewPlan) reason = $('#autoTip_range').text() || 'Enter valid settings to create a plan.';
else {
var balance = getAvailableBalance();
if (balance === null) {
reason = 'Token balance unavailable. Wait for it to load or refresh.';
}
else if (previewPlan.total > balance) {
reason = 'Planned total exceeds the available balance.';
}
else if (previewPlan.total >= 100 && !$('#autoTip_confirm_tokens').is(':checked')) {
reason = 'Confirm the planned token spending to start.';
}
else if (previewPlan.waitMs >= LONG_WAIT_CONFIRM_MS &&
!$('#autoTip_confirm_time').is(':checked')) {
reason = 'Confirm the long planned wait to start.';
}
}
}
$(HTML_IDS['START']).prop('disabled', running || !!reason);
$('#autoTip_start_hint').text(reason);
}
function updatePlanConfirmations() {
var needsTokens = previewPlan !== null && previewPlan.total >= 100;
$('#autoTip_confirm_tokens').prop('checked', false).prop('disabled', !needsTokens);
$('#autoTip_confirm_tokens_label').text(needsTokens ?
'Confirm spending ' + previewPlan.total + ' tokens' :
'100+ token plans require confirmation');
var needsTime = previewPlan !== null && previewPlan.waitMs >= LONG_WAIT_CONFIRM_MS;
$('#autoTip_confirm_time').prop('checked', false).prop('disabled', !needsTime);
$('#autoTip_confirm_time_label').text(needsTime ?
'Confirm planned wait of ' + formatDuration(previewPlan.waitMs / 1000) :
'10+ minute plans require confirmation');
updateStartAvailability();
}
function startTipping() {
var roomName = currentRoomName();
var roomType = currentRoomType();
var viewerName = currentViewerName();
if (!roomName || !defaultSendTipButton || !defaultSendTipButton.length ||
!defaultSendTipButton[0].isConnected) {
setTipperStatus('Open a room and wait for its tip button to load.', 'error');
return false;
}
if (!roomType) {
setTipperStatus('Room show type is unavailable. Wait for the room to finish loading.', 'error');
return false;
}
if (!viewerName) {
setTipperStatus('Signed-in username is unavailable. Wait for your account to load.', 'error');
return false;
}
if (!previewPlan && !refreshPlanPreview()) {
setTipperStatus($('#autoTip_range').text(), 'error');
return false;
}
if (previewPlan.total >= 100 && !$('#autoTip_confirm_tokens').is(':checked')) {
setTipperStatus('Confirm the planned spending before starting.', 'error');
return false;
}
if (previewPlan.waitMs >= LONG_WAIT_CONFIRM_MS && !$('#autoTip_confirm_time').is(':checked')) {
setTipperStatus('Confirm the long planned wait before starting.', 'error');
return false;
}
var balance = getAvailableBalance();
if (balance === null) {
setTipperStatus('Token balance unavailable. Wait for it to load or refresh.', 'error');
return false;
}
if (previewPlan.total > balance) {
renderBalance();
setTipperStatus('The planned tips cost more than the available token balance.', 'error');
return false;
}
saveCurrentSettings();
runRoomName = roomName;
runRoomPath = location.pathname;
runRoomType = roomType;
runViewerName = viewerName;
activePlan = previewPlan;
inlineProgressDismissed = false;
$('#autoTip_progress_heading').text('Progress');
totalTips = activePlan.amounts.length;
tipsLeft = totalTips;
tipsSent = 0;
tokensSent = 0;
tippingRun++;
runState = 'sending';
requestInFlight = false;
nextTipAt = null;
$(HTML_IDS['START']).prop('disabled', true);
$('#autoTip_start_hint').text('');
setTipperStatus('Starting…', 'sending');
$('#autoTip_confirm_tokens, #autoTip_confirm_time').prop('disabled', true);
$('#autoTip_reroll').prop('disabled', true);
$(HTML_IDS['STOP']).prop('disabled', false);
$(CLASS_INPUT).prop('disabled', true);
updateTipperButton();
updateTipperProgress();
saveRunProgress();
sendTip();
return true;
}
function setTipperStatus(message, state) {
$(HTML_IDS['STATUS']).text(message).attr('data-state', state || 'idle');
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
var hours = Math.floor(seconds / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
var remainder = Math.round((seconds % 60) * 10) / 10;
if (remainder === 60) { remainder = 0; minutes++; }
if (minutes === 60) { minutes = 0; hours++; }
var parts = [];
if (hours) parts.push(hours + 'h');
if (minutes) parts.push(minutes + 'm');
if (remainder || !parts.length) parts.push(remainder + 's');
return parts.join(' ');
}
function futureWaitMs(startIndex) {
if (!activePlan) return 0;
return activePlan.delays.slice(startIndex).reduce(function (sum, delay) { return sum + delay; }, 0);
}
function updateTipperProgress() {
$(HTML_IDS['PROGRESS']).text(tipsSent + ' / ' + totalTips);
$(HTML_IDS['SENT']).text(tokensSent + ' tokens');
$('#autoTip_all_tips').text(allTimeTips);
$('#autoTip_all_tokens').text(allTimeTokens);
if (totalTips > 0) {
$('#autoTip_progress_section').prop('hidden', false).attr('data-state', runState);
var activeRun = runState === 'sending' || runState === 'waiting';
$('#autoTip_inline_progress').css('display', inlineProgressDismissed && !activeRun ?
'none' : 'inline-flex').attr('data-state', runState)
.attr('title', tipsSent + ' of ' + totalTips + ' tips; ' + tokensSent + ' tokens sent');
$('#autoTip_inline_count').text(tipsSent + '/' + totalTips + ' · ' + tokensSent + 't');
if (activePlan && tipsSent < totalTips && activeRun) {
var nextNumber = tipsSent + 1;
var nextAmount = activePlan.amounts[tipsSent];
var tokenPath = document.getElementById('autoTip_token_shape').getAttribute('d');
$('#autoTip_inline_next').html('<span>Next</span>' +
'<span id="autoTip_inline_tip_pill" role="group" aria-label="Tip ' +
nextNumber + ': ' + nextAmount + ' tokens">' +
'<span aria-hidden="true">#' + nextNumber + '</span>' +
'<svg class="autoTip_token_icon" viewBox="0 0 22 20" aria-hidden="true" focusable="false">' +
'<path d="' + tokenPath + '" fill="currentColor"></path></svg>' +
'<span aria-hidden="true">' + nextAmount + '</span></span>');
}
else $('#autoTip_inline_next').text(runState === 'complete' ? 'Done' :
runState === 'error' ? 'Failed' : 'Stopped');
if (activePlan && activePlan.amounts.length <= 20) {
$('#autoTip_inline_segments').html(activePlan.amounts.map(function (amount, index) {
return '<span title="Tip ' + (index + 1) + ': ' + amount + ' tokens"></span>';
}).join(''));
}
else $('#autoTip_inline_segments').empty();
$('#autoTip_inline_fill').css('width', (100 * tipsSent / totalTips) + '%');
$('#autoTip_inline_progress .autoTip_inline_track')
.attr('aria-valuemax', totalTips).attr('aria-valuenow', tipsSent);
$('#autoTip_quick_stop').toggle(activeRun);
$('#autoTip_dismiss_progress').toggle(!activeRun);
}
updateCountdown();
}
function formatInlineCountdown(seconds) {
var total = Math.ceil(Math.max(0, seconds));
var hours = Math.floor(total / 3600);
var minutes = Math.floor((total % 3600) / 60);
var remainingSeconds = String(total % 60).padStart(2, '0');
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainingSeconds :
minutes + ':' + remainingSeconds;
}
function updateCountdown() {
var inlineTimer = $('#autoTip_inline_timer');
var inlineClock = $('#autoTip_inline_clock');
var inlineTimeValue = $('#autoTip_inline_time_value');
if (nextTipAt !== null) {
var seconds = Math.max(0, (nextTipAt - Date.now()) / 1000);
var remaining = formatDuration(seconds);
$(HTML_IDS['NEXT']).text(remaining);
$(HTML_IDS['REMAINING']).text('~' + formatDuration(
seconds + futureWaitMs(tipsSent) / 1000));
inlineTimer.css('display', 'inline-flex').attr('aria-label', 'Next tip in ' + remaining);
inlineClock.show();
inlineTimeValue.text(formatInlineCountdown(seconds));
}
else if (runState === 'sending') {
$(HTML_IDS['NEXT']).text('Sending…');
$(HTML_IDS['REMAINING']).text('~' + formatDuration(futureWaitMs(tipsSent) / 1000));
inlineTimer.css('display', 'inline-flex').attr('aria-label', 'Sending the next tip');
inlineClock.hide();
inlineTimeValue.text('Sending…');
}
else {
$(HTML_IDS['NEXT']).text('—');
$(HTML_IDS['REMAINING']).text('—');
inlineTimer.hide().removeAttr('aria-label');
inlineClock.hide();
inlineTimeValue.text('');
}
}
function parseDuration(value) {
var input = String(value).trim();
if (!input || input.includes('-')) throw new Error('Invalid duration');
return juration.parse(input);
}
function getRandomNumber(min, max) {
return Math.random() * (max - min) + min;
}
function chainQueueTips() {
var delay = activePlan.delays[tipsSent - 1];
nextTipAt = Date.now() + delay;
runState = 'waiting';
setTipperStatus('Waiting for tip ' + (tipsSent + 1) + ' of ' + totalTips, 'waiting');
updateTipperProgress();
clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdown, 200);
tipFunctionTimeout = setTimeout(function () {
tipFunctionTimeout = null;
clearInterval(countdownInterval);
countdownInterval = null;
nextTipAt = null;
sendTip();
}, delay);
}
function getCookieValue(name) {
var prefix = encodeURIComponent(name) + '=';
var cookie = document.cookie.split(';').map(function (part) { return part.trim(); })
.find(function (part) { return part.startsWith(prefix); });
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
}
function tipErrorText(value) {
if (typeof value === 'string') return value.trim().slice(0, 300);
if (value && typeof value.message === 'string') return value.message.trim().slice(0, 300);
if (value && typeof value.error === 'string') return value.error.trim().slice(0, 300);
return '';
}
function sendTip() {
checkRoomNavigation();
if (runState !== 'sending' && runState !== 'waiting') return;
var run = tippingRun;
// Use live page context for the sender, and the checked room DOM for the recipient.
// The initial dossier can disappear or name a previous room after SPA navigation.
var queryUrl = '/tipping/send_tip/' + encodeURIComponent(runRoomName) + '/';
var amount = activePlan.amounts[tipsSent];
var balance = getAvailableBalance();
if (balance !== null && balance < amount) {
stopTipping('Available balance is below the next planned tip.', 'error');
return;
}
var params = {
'csrfmiddlewaretoken': getCookieValue('csrftoken'),
'tip_amount': amount,
'tip_room_type': runRoomType,
'tip_type': 'public',
'from_username': runViewerName,
'source': 'theater',
'video_mode': 'split',
'message': ''
};
if (!params.csrfmiddlewaretoken) {
stopTipping('CSRF cookie is unavailable. The next tip was not sent.', 'error');
return;
}
var formData = new FormData();
for (const [k, v] of Object.entries(params)) formData.append(k, v);
checkRoomNavigation();
if (run !== tippingRun) return;
runState = 'sending';
requestInFlight = true;
setTipperStatus('Sending tip ' + (tipsSent + 1) + ' of ' + totalTips + '…', 'sending');
updateCountdown();
activeRequest = $.ajax({
url: queryUrl,
data: formData,
method: 'POST',
cache: false,
contentType: false,
processData: false,
success: function (response) {
if (run !== tippingRun) return;
checkRoomNavigation();
if (run !== tippingRun) return;
requestInFlight = false;
activeRequest = null;
if (response && response.error) {
stopTipping(tipErrorText(response.error) || 'The server rejected this tip.', 'error');
return;
}
tipsSent++;
tokensSent += amount;
addAllTimeTip(amount);
tipsLeft = totalTips - tipsSent;
updateTipperButton();
updateTipperProgress();
saveRunProgress();
renderBalance();
if (tipsLeft === 0) stopTipping('Complete. All tips were sent.', 'complete');
else chainQueueTips();
},
error: function (xhr, textStatus, errorThrown) {
if (run !== tippingRun) return;
checkRoomNavigation();
if (run !== tippingRun) return;
requestInFlight = false;
activeRequest = null;
var detail = tipErrorText(xhr && xhr.responseJSON &&
(xhr.responseJSON.error || xhr.responseJSON.message)) ||
tipErrorText(errorThrown) ||
(textStatus && textStatus !== 'error' ? textStatus : '');
var status = xhr && xhr.status ? ' (HTTP ' + xhr.status + ')' : '';
stopTipping('Tip request failed' + status + (detail ? ': ' + detail : '') +
'. No further tips were sent.', 'error');
}
});
}
function stopTipping(message, state) {
var pendingRequest = requestInFlight;
tippingRun++;
clearTimeout(tipFunctionTimeout);
clearInterval(countdownInterval);
tipFunctionTimeout = null;
countdownInterval = null;
nextTipAt = null;
requestInFlight = false;
var request = activeRequest;
activeRequest = null;
if (request && typeof request.abort === 'function') request.abort();
tipsLeft = 0;
runState = state || 'stopped';
updateTipperButton();
updateTipperProgress();
$(HTML_IDS['STOP']).prop('disabled', true);
$(HTML_IDS['START']).prop('disabled', false);
$('#autoTip_reroll').prop('disabled', false);
updatePlanConfirmations();
$(CLASS_INPUT).prop('disabled', false);
setTipperStatus(message || (pendingRequest ?
'Stopped. A tip already being sent may still complete.' : 'Stopped.'), runState);
saveRunProgress();
renderBalance();
if (runState === 'error') {
$(HTML_IDS['POPUP']).show();
positionTipperPopup();
}
}
function initializeButtonCallbacks() {
var popup = $(HTML_IDS['POPUP']);
var button = $(HTML_IDS['BUTTON_BG']);
function closePopup(returnFocus) {
popup.hide();
button.attr('aria-expanded', 'false');
if (returnFocus) button.trigger('focus');
}
button.on('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (popup.is(':visible')) closePopup(true);
else {
renderBalance();
popup.show();
button.attr('aria-expanded', 'true');
positionTipperPopup();
popup.trigger('focus');
}
});
button.on('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
button.trigger('click');
}
});
$(window).on('storage', function (ev) {
if (ev.originalEvent && ev.originalEvent.key === 'autoTip.totals.v1') loadAllTimeTotals();
});
$(window).on('resize scroll', function () {
if (popup.is(':visible')) positionTipperPopup();
});
popup.on('click', function (ev) {
ev.stopPropagation();
});
$(HTML_IDS['START']).on('click', startTipping);
$('#autoTip_confirm_tokens, #autoTip_confirm_time').on('change', updateStartAvailability);
$('#autoTip_reroll').on('click', refreshPlanPreview);
$('#autoTip_show_all_samples').on('click', function () {
showAllSamples = true;
renderPlanSamples();
});
$('#autoTip_timing_help_toggle').on('click', function () {
var help = $('#autoTip_timing_help');
var opening = help.prop('hidden');
help.prop('hidden', !opening);
$(this).attr('aria-expanded', String(opening)).text(opening ?
'Timing formats ▴' : 'Timing formats ▾');
});
$('#autoTip_quick_stop').on('click', function (ev) {
ev.stopPropagation();
stopTipping();
});
$('#autoTip_dismiss_progress').on('click', function (ev) {
ev.stopPropagation();
inlineProgressDismissed = true;
$('#autoTip_inline_progress').hide();
saveRunProgress();
});
$(HTML_IDS['CLOSE']).on('click', function () { closePopup(true); });
$('#autoTip_form').on('submit', function (ev) {
ev.preventDefault();
if (runState !== 'sending' && runState !== 'waiting') startTipping();
});
$(document).on('keydown', function (ev) {
if (ev.key === 'Escape' && popup.is(':visible')) closePopup(true);
});
$(HTML_IDS['STOP']).on('click', function () {
stopTipping();
});
$('body').on('click', function () {
if (popup.is(':visible')) closePopup(false);
});
$(CLASS_INPUT).on('input change', refreshPlanPreview);
}
function saveCurrentSettings() {
GM_setValue('amount', $(HTML_IDS['AMOUNT']).val());
GM_setValue('count', $(HTML_IDS['COUNT']).val());
GM_setValue('schedule', $(HTML_IDS['SCHEDULE']).val());
}
function loadPreviousSettings() {
$(HTML_IDS['AMOUNT']).val(GM_getValue('amount', '1'));
$(HTML_IDS['COUNT']).val(GM_getValue('count', '10'));
var schedule = GM_getValue('schedule', null);
if (schedule === null) {
// Preserve the delay distribution from older versions of this script.
var base = GM_getValue('interval', '1s');
var lower = GM_getValue('variance_lower', '');
var upper = GM_getValue('variance_upper', '');
try {
var min = parseDuration(base) - (lower ? parseDuration(lower) : 0);
var max = parseDuration(base) + (upper ? parseDuration(upper) : 0);
schedule = min === max ? String(min) + 's' : String(min) + 's-' + String(max) + 's';
}
catch (ex) {
schedule = '1s';
}
}
$(HTML_IDS['SCHEDULE']).val(schedule);
refreshPlanPreview();
}
function loadAllTimeTotals() {
try {
var saved = JSON.parse(localStorage.getItem('autoTip.totals.v1') || '{}');
if (Number.isSafeInteger(saved.tips) && saved.tips >= 0) allTimeTips = saved.tips;
if (Number.isSafeInteger(saved.tokens) && saved.tokens >= 0) allTimeTokens = saved.tokens;
}
catch (ex) { /* Totals remain available for this page only. */ }
updateTipperProgress();
}
function addAllTimeTip(amount) {
// Re-read before writing so another tab's recent tips are included.
loadAllTimeTotals();
allTimeTips++;
allTimeTokens += amount;
try {
localStorage.setItem('autoTip.totals.v1', JSON.stringify({
tips: allTimeTips, tokens: allTimeTokens
}));
}
catch (ex) { /* Continue the current run if storage is unavailable. */ }
}
function progressStorageKey(path) {
return 'autoTip.progress.' + (path || location.pathname);
}
function saveRunProgress() {
if (totalTips < 1) return;
try {
localStorage.setItem(progressStorageKey(runRoomPath), JSON.stringify({
tipsSent: tipsSent,
totalTips: totalTips,
tokensSent: tokensSent,
state: runState,
inlineDismissed: inlineProgressDismissed,
statusMessage: $(HTML_IDS['STATUS']).text().slice(0, 400),
updatedAt: Date.now()
}));
}
catch (ex) { /* Progress storage is optional. */ }
}
function restoreRunProgress() {
try {
var raw = localStorage.getItem(progressStorageKey());
if (!raw) return;
var saved = JSON.parse(raw);
if (!Number.isSafeInteger(saved.tipsSent) || !Number.isSafeInteger(saved.totalTips) ||
!Number.isSafeInteger(saved.tokensSent) || saved.tipsSent < 0 || saved.totalTips < 1 ||
saved.tipsSent > saved.totalTips || saved.tokensSent < 0) return;
tipsSent = saved.tipsSent;
totalTips = saved.totalTips;
tokensSent = saved.tokensSent;
inlineProgressDismissed = saved.inlineDismissed === true;
$('#autoTip_progress_heading').text('Previous run');
runState = saved.state === 'sending' || saved.state === 'waiting' ? 'stopped' : saved.state;
var previousStatus = runState === 'stopped' && saved.state !== 'stopped' ?
'Interrupted by page reload.' :
runState === 'error' ?
(typeof saved.statusMessage === 'string' && saved.statusMessage &&
saved.statusMessage !== 'Ended with an error.' ?
saved.statusMessage : 'Error details were not saved for this earlier run.') :
runState === 'complete' ? 'Completed.' : 'Stopped.';
setTipperStatus(previousStatus, runState);
updateTipperProgress();
}
catch (ex) { /* Ignore unavailable or malformed stored progress. */ }
}
// Script ends here
// Duration parser included below.
/*
* juration - a natural language duration parser
* https://github.com/domchristie/juration
*
* Copyright 2011, Dom Christie
* Licenced under the MIT licence
*
*/
function loadJuration() {
var UNITS = {
seconds: {
patterns: ['second', 'sec', 's'],
value: 1,
formats: {
'chrono': '',
'micro': 's',
'short': 'sec',
'long': 'second'
}
},
minutes: {
patterns: ['minute', 'min', 'm(?!s)'],
value: 60,
formats: {
'chrono': ':',
'micro': 'm',
'short': 'min',
'long': 'minute'
}
},
hours: {
patterns: ['hour', 'hr', 'h'],
value: 3600,
formats: {
'chrono': ':',
'micro': 'h',
'short': 'hr',
'long': 'hour'
}
},
days: {
patterns: ['day', 'dy', 'd'],
value: 86400,
formats: {
'chrono': ':',
'micro': 'd',
'short': 'day',
'long': 'day'
}
},
weeks: {
patterns: ['week', 'wk', 'w'],
value: 604800,
formats: {
'chrono': ':',
'micro': 'w',
'short': 'wk',
'long': 'week'
}
},
months: {
patterns: ['month', 'mon', 'mo', 'mth'],
value: 2628000,
formats: {
'chrono': ':',
'micro': 'm',
'short': 'mth',
'long': 'month'
}
},
years: {
patterns: ['year', 'yr', 'y'],
value: 31536000,
formats: {
'chrono': ':',
'micro': 'y',
'short': 'yr',
'long': 'year'
}
}
};
var stringify = function (seconds, options) {
if (!_isNumeric(seconds)) {
throw "juration.stringify(): Unable to stringify a non-numeric value";
}
if ((typeof options === 'object' && options.format !== undefined) && (options.format !== 'micro' && options.format !== 'short' && options.format !== 'long' && options.format !== 'chrono')) {
throw "juration.stringify(): format cannot be '" + options.format + "', and must be either 'micro', 'short', or 'long'";
}
var defaults = {
format: 'short',
units: undefined
};
var opts = _extend(defaults, options);
var units = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'], values = [];
var remaining = seconds;
var activeUnits = 0;
for (var i = 0, len = units.length;
i < len && (opts.units == undefined || activeUnits < opts.units);
i++) {
var unit = UNITS[units[i]];
values[i] = Math.floor(remaining / unit.value);
if (values[i] > 0 || activeUnits > 0)
activeUnits++;
if (opts.format === 'micro' || opts.format === 'chrono') {
values[i] += unit.formats[opts.format];
}
else {
values[i] += ' ' + _pluralize(values[i], unit.formats[opts.format]);
}
remaining = remaining % unit.value;
}
var output = '';
for (i = 0, len = values.length; i < len; i++) {
if (values[i].charAt(0) !== "0" && opts.format != 'chrono') {
output += values[i] + ' ';
}
else if (opts.format == 'chrono') {
output += _padLeft(values[i] + '', '0', i == values.length - 1 ? 2 : 3);
}
}
return output.replace(/\s+$/, '').replace(/^(00:)+/g, '').replace(/^0/, '');
};
var parse = function (string) {
// returns calculated values separated by spaces
for (var unit in UNITS) {
for (var i = 0, mLen = UNITS[unit].patterns.length; i < mLen; i++) {
var regex = new RegExp("((?:\\d+\\.\\d+)|\\d+)\\s?(" + UNITS[unit].patterns[i] + "s?(?=\\s|\\d|\\b))", 'gi');
string = string.replace(regex, function (str, p1, p2) {
return " " + (p1 * UNITS[unit].value).toString() + " ";
});
}
}
var sum = 0,
numbers = string
.replace(/(?!\.)\W+/g, ' ') // replaces non-word chars (excluding '.') with whitespace
.replace(/^\s+|\s+$|(?:and|plus|with)\s?/g, '') // trim L/R whitespace, replace known join words with ''
.split(' ');
for (var j = 0, nLen = numbers.length; j < nLen; j++) {
if (numbers[j] && isFinite(numbers[j])) {
sum += parseFloat(numbers[j]);
} else if (!numbers[j]) {
throw "juration.parse(): Unable to parse: a falsey value";
} else {
// throw an exception if it's not a valid word/unit
throw "juration.parse(): Unable to parse: " + numbers[j].replace(/^\d+/g, '');
}
}
return sum;
};
// _padLeft('5', '0', 2); // 05
var _padLeft = function (s, c, n) {
if (!s || !c || s.length >= n) {
return s;
}
var max = (n - s.length) / c.length;
for (var i = 0; i < max; i++) {
s = c + s;
}
return s;
};
var _pluralize = function (count, singular) {
return count == 1 ? singular : singular + "s";
};
var _isNumeric = function (n) {
return !isNaN(parseFloat(n)) && isFinite(n);
};
var _extend = function (obj, extObj) {
for (var i in extObj) {
if (extObj[i] !== undefined) {
obj[i] = extObj[i];
}
}
return obj;
};
var juration = {
parse: parse,
stringify: stringify,
humanize: stringify
};
return juration;
};
/*--- waitForKeyElements(): A utility function, for Greasemonkey scripts,
that detects and handles AJAXed content.
Usage example:
waitForKeyElements (
"div.comments"
, commentCallbackFunction
);
//--- Page-specific function to do what we want when the node is found.
function commentCallbackFunction (jNode) {
jNode.text ("This comment changed by waitForKeyElements().");
}
IMPORTANT: This function requires your script to have loaded jQuery.
*/
function waitForKeyElements(
selectorTxt, /* Required: The jQuery selector string that
specifies the desired element(s).
*/
actionFunction, /* Required: The code to run when elements are
found. It is passed a jNode to the matched
element.
*/
bWaitOnce, /* Optional: If false, will continue to scan for
new elements even after the first match is
found.
*/
iframeSelector /* Optional: If set, identifies the iframe to
search.
*/
) {
var targetNodes, btargetsFound;
if (typeof iframeSelector == "undefined")
targetNodes = $(selectorTxt);
else
targetNodes = $(iframeSelector).contents()
.find(selectorTxt);
if (targetNodes && targetNodes.length > 0) {
btargetsFound = true;
/*--- Found target node(s). Go through each and act if they
are new.
*/
targetNodes.each(function () {
var jThis = $(this);
var alreadyFound = jThis.data('alreadyFound') || false;
if (!alreadyFound) {
//--- Call the payload function.
var cancelFound = actionFunction(jThis);
if (cancelFound)
btargetsFound = false;
else
jThis.data('alreadyFound', true);
}
});
}
else {
btargetsFound = false;
}
//--- Get the timer-control variable for this selector.
var controlObj = waitForKeyElements.controlObj || {};
var controlKey = selectorTxt.replace(/[^\w]/g, "_");
var timeControl = controlObj[controlKey];
//--- Now set or clear the timer as appropriate.
if (btargetsFound && bWaitOnce && timeControl) {
//--- The only condition where we need to clear the timer.
clearInterval(timeControl);
delete controlObj[controlKey]
}
else {
//--- Set a timer, if needed.
if (!timeControl) {
timeControl = setInterval(function () {
waitForKeyElements(selectorTxt,
actionFunction,
bWaitOnce,
iframeSelector
);
},
300
);
controlObj[controlKey] = timeControl;
}
}
waitForKeyElements.controlObj = controlObj;
}