☰

Chaturbate Auto Tip

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴、Greasemonkey 油猴子 或 Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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