☰

Chaturbate Auto Tip

Preplan timed tips and token amounts with balance checks, progress, and a live countdown

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         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;
}