Library Twitch Command Buttons

Contains frequently used helper functions and utilities that I use across multiple scripts

当前为 2025-08-26 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/scripts/547394/1649820/Library%20Twitch%20Command%20Buttons.js

async function main() {
    await init_gm_config();
    await wait_for_gm_config();

    if(GM_config.get("script_enabled")) {
        const custom_css = GM_config.get("custom_css_styles")?.trim();
        if (custom_css)
            GM_addStyle(custom_css);

        if(GM_config.get("hide_powerups"))
            hide_powerups();

        if(GM_config.get("collect_point_bonus"))
            collect_point_bonus();

        wait_for_element(".chat-input").then(async () => {
            if(GM_config.get("irc"))
                if(GM_config.get("auth_username") != "" && GM_config.get("auth_oauth") != "")
                    connect_to_twitch();
                else
                    Swal.fire({
                        title: "Missing IRC Credentials!",
                        text: "IRC is selected, but your username or OAuth token is missing or invalid. Please update your settings or disable \"Use IRC\".",
                        icon: "error",
                        theme: "dark",
                        backdrop: false
                    });

            if(GM_config.get("notifications"))
                observe_chat_for_username_mentions();

            wait_for_element(".community-points-summary").then(async () => {
                if(GM_config.get("voucher_buttons"))
                    generate_voucher_buttons();
            });

            if(GM_config.get("show_streamelements_points"))
                show_streamelements_points();

            insert_command_buttons(generate_button_groups());
        });
    }
}

// ========================
// Username
// ========================

let username = get_username_from_cookie();

function get_username_from_cookie() {
    // Get all cookies
    const cookies = document.cookie.split(";");
    // Search for the "name" cookie
    for (const cookie of cookies) {
        const [cookie_name, cookie_value] = cookie.trim().split("=");
        if (cookie_name === "name")
            return decodeURIComponent(cookie_value); // URL-decode the value
    }
    return null; // "name" cookie not found
}

// ========================
// StreamElements Functions & API
// ========================

let se_channel = null; // Variable to store the channel data
let se_user_data = null; // Variable to store user-specific data

let se_user_data_fetch_interval = 1; // In minutes
let se_points_add_delay = 5; // In seconds
let element_se_current = null;
let element_se_change = null;

async function show_streamelements_points() {
    // Wait for the channel data to be fetched
    await streamelements_fetch_channel_data(streamelements_store);
    // Check if the channel data was successfully fetched
    if (!se_channel)
        return console.error("Channel data could not be fetched.");

    if(username)
    {
        wait_for_element(".k-streamelements_points").then(async () => {
            // Initialize the innerHTML with two spans
            document.querySelector(".k-streamelements_points").innerHTML = `
            StreamElements: <span id="k-se_current_span"></span><span id="k-se_change_span"></span>
        `;

            // Point to the <span> elements
            element_se_current = document.querySelector("#k-se_current_span");
            element_se_change = document.querySelector("#k-se_change_span");

            await update_se_points(true); // Initial update of points
            setInterval(update_se_points, se_user_data_fetch_interval * 60 * 1000);
        });
    }
}

async function update_se_points(initialization = false) {
    // Get current points from the data attribute
    const data_points = element_se_current.getAttribute("data-points");
    const current = data_points !== null ? parseInt(data_points) : null;

    const user_data = await streamelements_fetch_user(se_channel._id, username);
    const new_points = user_data?.points ?? null;

    if (new_points === null) {
        element_se_current.textContent = "N/A";
        element_se_current.setAttribute("data-points", null);
        element_se_change.textContent = ""; // Clear the change span
        return;
    }

    element_se_current.setAttribute("data-points", new_points);
    const diff = new_points - current;

    if (diff !== 0 && !initialization) {
        // Add class to the change span based on whether the difference is positive or negative
        if (diff > 0)
            element_se_change.classList.add("k-points_added");
        else
            element_se_change.classList.add("k-points_subtracted");

        // Show the old points in the current span and the difference in the change span
        element_se_current.textContent = current;
        element_se_change.textContent = ` ${diff >= 0 ? "+" : ""}${diff}`;
        await sleep_s(10);

        // Use the generic animation method
        element_se_change.textContent = "";
        await animate_number_counter(element_se_current, current, new_points);

        // Remove the class after the animation
        element_se_change.classList.remove("k-points_added", "k-points_subtracted");
    } else
        element_se_current.textContent = new_points;
}

async function streamelements_fetch_channel_data(twitch_channel) {
    try {
        const response = await fetch(`https://api.streamelements.com/kappa/v2/channels/${twitch_channel}`);
        if (!response.ok)
            throw new Error(`API request failed with status ${response.status}`);

        const data = await response.json();
        se_channel = data; // Store the entire JSON response
    } catch (error) {
        console.error("Error fetching StreamElements channel data:", error);
        se_channel = null; // Set to null in case of an error
    }
}

async function streamelements_fetch_user(channel_id, username) {
    try {
        const response = await fetch(`https://api.streamelements.com/kappa/v2/points/${channel_id}/${username}`);
        if (!response.ok)
            throw new Error(`API request failed with status ${response.status}`);

        const data = await response.json();
        return data || null; // Return the points or 0 if not found
    } catch (error) {
        console.error("Error fetching StreamElements points:", error);
        return null; // Return null in case of an error
    }
}

// ========================
// Twitch React Chat by Cyb3rgamer
// ========================

let current_chat;

function send_message_with_event(message) {
    // Update current_chat only if it's undefined or missing the onSendMessage prop
    if (!current_chat || !current_chat.props?.onSendMessage)
        current_chat = get_current_chat();

    // Send the message if current_chat and onSendMessage are available
    if (current_chat?.props?.onSendMessage)
        current_chat.props.onSendMessage(message);
    else
        console.error("Current chat is not available or missing onSendMessage prop.");
}

function get_current_chat() {
    try {
        const chat_node = document.querySelector(`section[data-test-selector="chat-room-component-layout"]`);
        if (!chat_node) return null;

        // Find the React instance of the chat container
        const react_instance = get_react_instance(chat_node);
        if (!react_instance) return null;

        // Search the React parent nodes for the chat component
        const chat_component = search_react_parents(react_instance, (node) => {
            return node.stateNode && node.stateNode.props && node.stateNode.props.onSendMessage;
        });

        return chat_component ? chat_component.stateNode : null;
    } catch (error) {
        console.error("Error accessing the chat:", error);
        return null;
    }
}

function get_react_instance(element) {
    for (const key in element)
        if (key.startsWith("__reactInternalInstance$") || key.startsWith("__reactFiber$"))
            return element[key];
    return null;
}

function search_react_parents(node, predicate, max_depth = 15, depth = 0) {
    if (!node || depth > max_depth) return null;

    try {
        if (predicate(node))
            return node;
    } catch (error) {
        console.error("Error while searching React parents:", error);
    }

    return search_react_parents(node.return, predicate, max_depth, depth + 1);
}

// ========================
// Twitch IRC Connection
// ========================

const twitch_host = "irc-ws.chat.twitch.tv";
const twitch_port = 443;
let socket;
let timer;
let reconnect_interval = 5000;

let reconnect_attempts = 0; // Counter for reconnection attempts
const max_reconnect_attempts = 3; // Maximum number of reconnection attempts

function connect_to_twitch() {
    socket = new WebSocket(`wss://${twitch_host}:${twitch_port}`);

    socket.onopen = () => {
        console.log("Twitch connection started.");

        // Authenticate and join the channel
        socket.send(`PASS ${GM_config.get("auth_oauth").includes("oauth:") ? GM_config.get("auth_oauth") : "oauth:" + GM_config.get("auth_oauth")}`);
        socket.send(`NICK ${GM_config.get("auth_username")}`);
        socket.send(`JOIN #${twitch_channel}`);

        // Start ping timer to prevent disconnect
        timer = setInterval(() => {
            socket.send("PING :tmi.twitch.tv");
        }, 5 * 60 * 1000); // Send a ping every 5 minutes
    };

    socket.onmessage = (event) => {
        const message = event.data;

        // Check for authentication failure
        if (message.includes("Login authentication failed")) {
            console.error("Twitch authentication failed. Please check your username and OAuth token.");
            reconnect_attempts++; // Increment the reconnection attempt counter
            console.log(`Authentication failed. Attempt ${reconnect_attempts} of ${max_reconnect_attempts}`);
            // Close the connection and try to reconnect
            socket.close();
            return;
        }
        else if (message.includes("Welcome, GLHF!")) { // Check for successful connection
            console.log("Twitch authentication successful.");
            reconnect_attempts = 0; // Reset the counter on successful authentication
        }
    };

    socket.onclose = () => {
        console.log("Twitch connection closed.");
        // Stop the ping timer
        clearInterval(timer);
        // Check if max reconnection attempts have been reached
        if (reconnect_attempts < max_reconnect_attempts) {
            reconnect_attempts++; // Increment the reconnection attempt counter
            console.log(`Reconnecting... Attempt ${reconnect_attempts} of ${max_reconnect_attempts}`);

            // Try to reconnect after a delay
            setTimeout(() => connect_to_twitch(), reconnect_interval);
        } else
            // Show error message if max reconnection attempts are reached
            Swal.fire({
                title: "Cannot connect to IRC!",
                text: "Unable to connect to Twitch with the provided credentials. Please check your username and OAuth token, or disable \"Use IRC\".",
                icon: "error",
                theme: "dark",
                backdrop: false
            });
    };

    socket.onerror = (error) => {
        console.error("Twitch connection error:", error);
    };
}

function send_message_with_irc(message) {
    if (socket.readyState === WebSocket.OPEN)
        socket.send(`PRIVMSG #${twitch_channel} :${message}`);
    else
        console.error("WebSocket is not open. Current state:", socket.readyState);
}

// ========================
// UI and Button Handling
// ========================

function insert_command_buttons(buttongroups) {
    let html = `
        <div id="k-main-container" class="k-main-container">
            <div id="k-streamelements_points" class="k-streamelements_points"></div>
            <div id="k-panel-buttons">
                <div id="k-make-draggable-button" title="Detach from chat">👆</div>
                <div id="k-grab-handle" class="k-hidden">🖐️</div>
                <div id="k-pin-button" class="k-hidden" title="Reattach to chat">📌</div>
                <div id="k-cart-button" title="Open store">🛒</div>
                <div id="k-open-settings" title="Userscript settings">⚙️</div>
            </div>
            <div id="k-actions" class="k-buttongroups">${buttongroups}</div>
        </div>
    `;
    document.querySelector(".chat-input").insertAdjacentHTML("beforebegin", html);

    // Add event listeners for buttons
    document.querySelector("#k-targets #k-closebutton")?.addEventListener("click", () => switch_panel(null), false);
    document.querySelectorAll(".k-buttongroup .k-actionbutton")?.forEach(el => el.addEventListener("click", generate_command, false));
    document.querySelectorAll(".k-buttongroup .k-targetbutton")?.forEach(el => el.addEventListener("click", switch_panel, false));
    document.querySelectorAll(".k-selection-label")?.forEach(el => el.addEventListener("click", show_btn_menu, false));

    // Draggable buttons
    document.querySelector("#k-make-draggable-button")?.addEventListener("mousedown", () => make_draggable());
    document.querySelector("#k-pin-button")?.addEventListener("click", () => disable_draggable());
    document.querySelector("#k-cart-button")?.addEventListener("click", () => open_store());
    document.querySelector("#k-open-settings")?.addEventListener("click", () => GM_config.open());
}

function open_store() {
    const store_name = streamelements_store?.trim() || twitch_channel;
    const url = `https://streamelements.com/${store_name}/store`;
    window.open(url, "_blank");
}

function switch_panel(event) {
    document.querySelector("#k-actions").classList.toggle("k-hidden");
    document.querySelector("#k-targets").classList.toggle("k-hidden");

    if (event) {
        const target_count = parseInt(event.target.getAttribute("data-targets"));
        const action = event.target.getAttribute("cmd");
        const target_buttons_container = document.getElementById("k-targetbuttons");

        // Set the data-action attribute for the targets panel
        document.querySelector("#k-targets").setAttribute("data-action", action);

        // Check if the number of existing buttons matches the target count
        const existing_buttons = target_buttons_container.querySelectorAll(".k-actionbutton");
        if (existing_buttons.length !== target_count) {
            // Clear existing buttons if the count doesn't match
            existing_buttons.forEach(button => button.remove());

            // Generate new buttons
            let target_buttons_html = "";
            for (let i = 1; i <= target_count; i++)
                target_buttons_html += btngrp_button(i, i);

            // Insert new buttons before the close button
            target_buttons_container.insertAdjacentHTML("afterbegin", target_buttons_html);

            // Add event listeners to the new buttons
            target_buttons_container.querySelectorAll(".k-actionbutton").forEach(el => {
                el.addEventListener("click", generate_command, false);
            });
        }

        // Adjust CSS for grid layout
        target_buttons_container.classList.remove("k-grid-1", "k-grid-2", "k-grid-3", "k-grid-4", "k-grid-5", "k-grid-6", "k-grid-7", "k-grid-8");

        // Calculate the number of buttons per row
        let buttons_per_row;
        if (target_count <= 6)
            buttons_per_row = target_count; // 1-6 Buttons: All in one row
        else if (target_count === 8)
            buttons_per_row = 4; // 8 Buttons: 4 per row
        else
            buttons_per_row = 6; // 7+ Buttons: 6 per row (except 8)

        target_buttons_container.classList.add(`k-grid-${buttons_per_row}`);
    }
}

function generate_command(event) {
    let cmd = "";
    if(event.target.parentNode.parentNode.getAttribute("data-action")) {
        cmd = event.target.parentNode.parentNode.getAttribute("data-action"); // Add action attack or devine in case its from the switched panel
        // Remove the data and go back to main panel
        event.target.parentNode.parentNode.setAttribute("data-action", "");
        switch_panel(null);
    }
    cmd += event.target.getAttribute("cmd");

    // Check if the button has random min and max attributes and append a random number if they exist
    if (event.target.hasAttribute("data-random-min") && event.target.hasAttribute("data-random-max"))
        cmd += `${random_number(parseInt(event.target.getAttribute("data-random-min")), parseInt(event.target.getAttribute("data-random-max")))}`;

    let suffix = "!";
    cmd = (GM_config.get("prevent_shadowban") ? `${suffix}${randomize_case(cmd)}` : `${suffix}${cmd}`).trim();

    if(cmd.trim() !== "" && cmd !== null)
        if(GM_config.get("irc"))
            send_message_with_irc(cmd);
        else
            send_message_with_event(cmd);
    else
        Swal.fire({
            icon: "error",
            title: "Error",
            text: "Please contact script creator, this button doesn't seem to work correctly!",
            theme: "dark",
            backdrop: false
        });
}

function insert_voucher_buttons(html) {
    wait_for_element(".chat-input__buttons-container").then(async () => {
        html = `<div class="k-store-buttongroups"><div class="k-buttongroup">${html}</div></div>`;
        document.querySelector(".chat-input")?.insertAdjacentHTML("afterend", html);

        let buttons = document.querySelectorAll(".k-get_voucher_button");
        buttons.forEach(button => {
            button.addEventListener("click", async event => {
                let voucher = event.target.getAttribute("voucher");
                let repeats = event.target.getAttribute("data-repeats");

                if (repeats !== null && repeats > 1)
                    await bulk_purchase_product(voucher, parseInt(repeats));
                else
                    await purchase_voucher(event);
            }, false);
        });
    });
}

function generate_voucher_button(voucher, text, options = {}) {
    const { classes = "", repeats = null } = options

    let base_class = "k-actionbutton k-get_voucher_button"
    let combined_classes = (base_class + ` ${classes ?? ""}`).trim()
    let attributes = `voucher="${voucher}" class="${combined_classes}"`

    if (repeats !== null) attributes += ` data-repeats="${repeats}"`

    return `<button ${attributes}>${text}</button>`
}

function btngrp_label(label) {
    return `<label class="k-buttongroup-label">${label}</label>`;
}

function lblgrp_label(btn_menu, name, classes="") {
    return `<label class="k-selection-label ${classes}" data-btn-menu="${btn_menu}">${name}</label>`;
}

function btngrp_button(cmd, text, options = {}) {
    const { classes = "", targets = null, random_min = null, random_max = null } = options;

    let base_class = targets !== null ? "k-targetbutton" : "k-actionbutton";
    let combined_classes = (base_class + ` ${classes ?? ""}`).trim();
    let attributes = `cmd="${cmd}" class="${combined_classes}"`;
    if (targets !== null) attributes += ` data-targets="${targets}"`;
    if (random_min !== null && random_max !== null) attributes += ` data-random-min="${random_min}" data-random-max="${random_max}"`;
    return `<button ${attributes}>${text}</button>`;
}

function show_btn_menu(event) {
    let btn_menus = document.querySelectorAll(".k-btn-menu");
    let label_group = event.target.closest(".k-labelgroup");
    let close_button = label_group.querySelector(`label[data-btn-menu="close"]`);

    btn_menus.forEach(el => {
        el.getAttribute("data-btn-menu") === event.target.getAttribute("data-btn-menu") ? el.classList.remove("k-hidden") : el.classList.add("k-hidden");
    });

    let all_hidden = Array.from(btn_menus).every(el => el.classList.contains("k-hidden"));
    all_hidden ? close_button.classList.add("k-hidden") : close_button.classList.remove("k-hidden");
}

// ========================
// Draggable Container
// ========================

function make_draggable() {
    const container = document.querySelector("#k-main-container");
    const make_draggable_button = document.querySelector("#k-make-draggable-button");
    const grab_handle = document.querySelector("#k-grab-handle");
    const pin_button = document.querySelector("#k-pin-button");

    if (container && make_draggable_button && grab_handle && pin_button) {
        // Add the "draggable" class
        container.classList.add("k-draggable");

        // Hide the make-draggable button and show the k-grab-handle and pin button
        make_draggable_button.classList.add("k-hidden");
        grab_handle.classList.remove("k-hidden");
        pin_button.classList.remove("k-hidden");

        // Save the initial position of the container relative to the viewport
        const initial_rect = container.getBoundingClientRect();
        const left = initial_rect.left;
        const bottom = initial_rect.bottom;

        // Move the container to the body (to ensure it's above other elements)
        document.body.appendChild(container);

        // Set the initial position using transform
        container.style.transform = `translate(${left}px, ${window.innerHeight - bottom}px)`;

        // Enable dragging only when the k-grab-handle is clicked
        interact(grab_handle).draggable({
            listeners: {
                move(event) {
                    const target = container;
                    const rect = target.getBoundingClientRect();
                    const window_width = window.innerWidth;
                    const window_height = window.innerHeight;

                    // Calculate new position based on mouse movement
                    let x = rect.left + event.dx;
                    let y = rect.bottom - container.offsetHeight + event.dy;

                    // Round x and y to prevent jitter caused by subpixel values
                    x = Math.round(x);
                    y = Math.round(y);

                    // Constrain the position to keep the container within the window bounds
                    x = Math.max(0, Math.min(x, window_width - rect.width)); // Left and right edges
                    y = Math.max(50, Math.min(y, window_height - container.offsetHeight)); // Top and bottom edges

                    // Update the container's position using transform
                    target.style.transform = `translate(${x}px, ${y}px)`;
                }
            }
        });
    }
}

function disable_draggable() {
    const container = document.querySelector("#k-main-container");
    const make_draggable_button = document.querySelector("#k-make-draggable-button");
    const grab_handle = document.querySelector("#k-grab-handle");
    const pin_button = document.querySelector("#k-pin-button");

    if (container && make_draggable_button && grab_handle && pin_button) {
        // Remove the "draggable" class
        container.classList.remove("k-draggable");

        // Disable dragging
        interact(grab_handle).draggable(false);

        // Reset the container to its original position
        container.style.transform = "translate(0px, 0px)";
        container.setAttribute("data-x", 0);
        container.setAttribute("data-y", 0);

        // Move the container back to the chat panel
        document.querySelector(".chat-input")?.insertAdjacentElement("beforebegin", container);

        // Show the make-draggable button and hide the k-grab-handle and pin button
        make_draggable_button.classList.remove("k-hidden");
        grab_handle.classList.add("k-hidden");
        pin_button.classList.add("k-hidden");
    }
}

// ========================
// Notifications
// ========================

function observe_chat_for_username_mentions() {
    wait_for_element(".chat-scrollable-area__message-container").then(async () => {
        const chat_container = document.querySelector(".chat-scrollable-area__message-container");
        await sleep_s(5);

        if(username && username != "") {
            // Create a MutationObserver to watch for new messages
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    mutation.addedNodes.forEach((node) => {
                        // Check if the added node is a chat message
                        let msg = node.querySelector(`span[data-a-target="chat-line-message-body"]`)?.innerText?.trim();
                        if (msg && msg.toLowerCase().includes(username.toLowerCase())) {
                            let author = node.querySelector(`.chat-author__display-name`)?.textContent;
                            GM_notification({
                                title: `Channel: ${twitch_channel} - ${author} mentioned you!`,
                                text: `${msg}`,
                                timeout: 15000,
                                silent: false
                            });
                        }
                    });
                });
            });

            // Start observing the chat container for new child nodes
            observer.observe(chat_container, {
                childList: true, // Watch for added or removed child nodes
                subtree: true, // Watch all descendants of the container
            });
        }
    });
}

// ========================
// Collect Point Bonus
// ========================

async function collect_point_bonus() {
    await wait_for_element(".claimable-bonus__icon").then(async () => {
        document.querySelector(".claimable-bonus__icon")?.click();
        console.log("BONUS CLICKED");
        await sleep_m(10);
        collect_point_bonus();
    });
}

// ========================
// Twitch Store Observer
// ========================

async function twitch_store_observer() {
    // Helper function that detects if the item page is opened
    const selector = "#channel-points-reward-center-body > .reward-center-body > div:not(.rewards-list)";
    const container = await wait_for_element(selector);

    if (container.querySelector(".reward-icon__image")) {
        if(GM_config.get("clickable_links_in_description"))
            clickable_links_in_description(container);

        if (GM_config.get("bulk_purchase_panel"))
            insert_twitch_store_amount_panel(container);
    }

    // Wait till panel disappears
    await wait_for_element_to_disappear(selector);
    twitch_store_observer();
}

// ========================
// Clickable links in description
// ========================

function clickable_links_in_description(container) {
    const desc = container.querySelector("p"); // Get the first <p> element

    if (desc?.querySelector("a"))
        return; // Skip if there are already <a> elements

    const url_regex = /https?:\/\/[^\s]+/g; // Simple URL detection
    const original_text = desc.textContent;

    if (!url_regex.test(original_text))
        return; // Skip if there are no URLs

    // Replace URLs with clickable <a> tags
    const html_with_links = original_text.replace(url_regex, url => {
        return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
    });

    desc.innerHTML = html_with_links;
}

// ========================
// Bulk Twitch Store Purchase UI
// ========================

function insert_twitch_store_amount_panel(container) {
    if(!document.querySelector(".k-twitch-store-amount-panel")) {
        // console.log("insert_twitch_store_amount_panel: Inserting panel.");

        // HTML as a complete string → Amount panel + button for bulk purchase
        let html = `
            <div class="k-twitch-store-amount-panel">
                <button type="button" id="k-twitch-store-amount-decrease">-</button>
                <input type="number" id="k-twitch-store-amount-value" value="1" min="1" max="100">
                <button type="button" id="k-twitch-store-amount-increase">+</button>
                <button type="button" id="k-twitch-store-bulk-purchase" class="k-twitch-store-cart-button" title="Start bulk purchase">🛒</button>
            </div>
        `;

        // Insert HTML after 2nd child
        if (container.children.length >= 2)
            container.children[1].insertAdjacentHTML("afterend", html);
        else
            return;

        // Event Listeners
        const input = document.getElementById("k-twitch-store-amount-value");
        const btn_decrease = document.getElementById("k-twitch-store-amount-decrease");
        const btn_increase = document.getElementById("k-twitch-store-amount-increase");
        const bulk_button = document.getElementById("k-twitch-store-bulk-purchase");

        btn_decrease.addEventListener("click", () => on_decrease_click(input));
        btn_increase.addEventListener("click", () => on_increase_click(input));
        input.addEventListener("input", () => on_input_change(input));

        // Bulk Purchase Button → Event
        bulk_button.addEventListener("click", async () => {
            let amount = parseInt(input.value);
            if (isNaN(amount) || amount < 1) amount = 1;
            else if (amount > 100) amount = 100;

            const product = document.querySelector("#channel-points-reward-center-header > div > p").innerHTML;
            bulk_purchase_product(product, amount);
        });
    }
}

function on_decrease_click(input) {
    let value = parse_int_safe(input.value, 1);
    value = Math.max(1, value - 1);
    input.value = value;
}

function on_increase_click(input) {
    let value = parse_int_safe(input.value, 1);
    value = Math.min(100, value + 1);
    input.value = value;
}

function on_input_change(input) {
    let value = parseInt(input.value);
    if (isNaN(value) || value < 1)
        value = 1;
    else if (value > 100)
        value = 100;
    input.value = value;
}

function parse_int_safe(str, fallback) {
    const value = parseInt(str);
    return isNaN(value) ? fallback : value;
}

// ========================
// Purchase Functions
// ========================

async function purchase_voucher(trigger) {
    let voucher = trigger.target.attributes.voucher.value;
    let storebutton = document.querySelector(".community-points-summary button");
    storebutton.click();
    wait_for_element(".rewards-list").then(async () => { // Wait till rewards list is showing
        let rewards = document.querySelector(".rewards-list");
        let reward = rewards.querySelector(`img[alt="${voucher}"]`);
        if(reward) { // Open the voucher buy menu
            reward.click();
            wait_for_element(".reward-center-body button.ScCoreButton-sc-ocjdkq-0").then(async () => { // Wait till voucher item is showing
                let reward_redeem_button = document.querySelector(".reward-center-body button.ScCoreButton-sc-ocjdkq-0");
                if(reward_redeem_button.disabled == false)
                    reward_redeem_button.click();
                else {
                    storebutton.click();
                    Swal.fire({
                        icon: "error",
                        title: "Error",
                        text: "Reward not available, maybe you reached maximum amount of claims for this stream or you don't have enough channel points!",
                        theme: "dark",
                        backdrop: false
                    });


                }
            });
        }
        else
            Swal.fire({
                icon: "error",
                title: "Error",
                text: "Reward not found maybe they are disabled at the moment, if not than please contact script creator via Discord!",
                theme: "dark",
                backdrop: false
            });
    });
}

async function bulk_purchase_product(product, amount) {
    let storebutton = document.querySelector(".community-points-summary button");
    let success_count = 0;

    Swal.fire({
        title: `Purchasing "${product}"...`,
        html: `<progress value="0" max="${amount}"></progress><br>0/${amount}`,
        icon: "info",
        theme: "dark",
        backdrop: false,
        showConfirmButton: false,
        allowOutsideClick: false,
        willOpen: () => {
            Swal.showLoading();
        }
    });

    for (let i = 0; i < amount; i++) {
        storebutton.click();

        try {
            await wait_for_element(".rewards-list");
            let rewards = document.querySelector(".rewards-list");
            let reward = rewards.querySelector(`img[alt="${product}"]`);

            if (reward) {
                reward.click();
                await wait_for_element(".reward-center-body button.ScCoreButton-sc-ocjdkq-0");

                let reward_redeem_button = document.querySelector(".reward-center-body button.ScCoreButton-sc-ocjdkq-0");

                if (reward_redeem_button.disabled == false) {
                    reward_redeem_button.click();
                    success_count++;
                } else {
                    Swal.fire({
                        icon: "error",
                        title: "Error",
                        text: `Reward not available anymore! Process stopped after ${success_count} successful purchases.`,
                        theme: "dark",
                        backdrop: false
                    });
                    storebutton.click();
                    return;
                }
            } else {
                Swal.fire({
                    icon: "error",
                    title: "Error",
                    text: `Reward "${product}" not found! Process stopped.`,
                    theme: "dark",
                    backdrop: false
                });
                storebutton.click();
                return;
            }
        } catch (err) {
            console.error(err);
            Swal.fire({
                icon: "error",
                title: "Error",
                text: `Unexpected error occurred. Process stopped after ${success_count} successful purchases.`,
                theme: "dark",
                backdrop: false
            });
            storebutton.click();
            return;
        }

        // Update Progress Bar
        Swal.update({
            html: `<progress value="${i + 1}" max="${amount}"></progress><br>${i + 1}/${amount}`
        });

        await wait_for_element_to_disappear(".reward-center-body button.ScCoreButton-sc-ocjdkq-0");
    }

    Swal.fire({
        title: "Done!",
        text: `${success_count}/${amount} "${product}" purchased successfully.`,
        icon: "success",
        theme: "dark",
        backdrop: false
    });
}

// ========================
// Restart Timer by Zosky
// ========================

async function zoskys_restart_timer(mst) {
    let max_stream_time = mst;
    let update_interval = 2; // Initial interval: 2 second

    let time_element = null;
    let timer_element = null;

    create_timer_element();
    await start_timer();

    // Function to calculate the remaining time (only hours and minutes)
    function calculate_time_left(seconds, max_stream_time) {
        let time_left = max_stream_time - seconds;
        let h = Math.floor(time_left / 3600);
        let m = Math.floor((time_left % 3600) / 60);

        // Format the time (only hours and minutes)
        return {
            hours: h < 10 ? `0${h}` : h,
            minutes: m < 10 ? `0${m}` : m,
        };
    }

    // Function to create the timer element if it doesn't exist
    function create_timer_element() {
        if (!timer_element) {
            wait_for_element("section > div").then(async () => {
                const infobox = document.querySelector(".channel-info-content");
                const timer_html = `
                <div id="time_left" style="width: 100%; text-align: center; font-size: x-large; background: purple;">
                    Waiting for stream time...
                </div>
                `;
                infobox.insertAdjacentHTML("afterbegin", timer_html);
                timer_element = document.getElementById("time_left"); // Update reference
            });
        }
    }

    // Function to update the timer in the DOM
    async function update_timer() {
        try {
            // Try to find the .live-time element if not already found
            if (!time_element) {
                time_element = document.querySelector(".live-time");
                if (!time_element) return false; // Element not found
            }

            // Extract the time from the nested <span> or <p> tag
            const time_text = time_element.querySelector("span, p").textContent.trim();
            // Extract the time parts (HH:MM:SS)
            const time_parts = time_text.split(":").map(Number);

            // Handle only HH:MM:SS format
            if (time_parts.length === 3 && !time_parts.some(isNaN)) {
                const [hours, minutes, seconds] = time_parts;
                const total_seconds = hours * 3600 + minutes * 60 + seconds;

                // Calculate and update the timer (only hours and minutes)
                const { hours: h, minutes: m } = calculate_time_left(total_seconds, max_stream_time);

                // Update the timer element
                timer_element.innerHTML = `Approx ${h}:${m} till stream restart for vouchers`;
                return true; // Element found and updated
            } else {
                return false; // Invalid format
            }
        } catch (error) {
            console.error("Error updating timer:", error);
            return false; // Error occurred
        }
    }

    // Start the timer with dynamic intervals
    async function start_timer() {
        await wait_for_element(".live-time").then(async () => {
            time_element = document.querySelector(".live-time");
            update_interval = 50; // Switch to 50-second interval
        });

        while (true) {
            // Try to update the timer
            const element_found = await update_timer();

            // If update_timer returns false, break the loop
            if (!element_found) break;

            // Wait for the specified interval
            await sleep_s(update_interval);
        }
        console.log("Timer stopped due to an error or invalid format.");
    }
}

// ========================
// CSS Styles
// ========================

function hide_powerups() {
    GM_addStyle(`
        .rewards-list > div:first-of-type,
        .rewards-list [class*="bitsRewardListItem"] {
            display: none !important;
        }

        .rewards-list > div {
            padding-top: 0 !important;
        }
    `);
}

GM_addStyle(`
#configuration {
    padding: 20px !important;
    max-height: 600px !important;
    max-width: 500px !important;
    background: inherit !important;
}

#configuration * {
    background: inherit;
    color: inherit;
}

#configuration .section_header {
    margin-bottom: 10px !important;
}

#configuration input {
    margin-right: 10px;
}

#configuration input[type="text"] {
    display: block;
}

#configuration textarea {
    width: 100%;
    min-height: 70px;
    resize: vertical;
}

#configuration_resetLink {
    color: var(--color-text-base) !important;
}

.k-actionbutton,
.k-targetbutton,
#configuration_saveBtn,
#configuration_closeBtn {
    padding: 10px;
    background-color: var(--color-background-button-primary-default);
    color: var(--color-text-button-primary);
    display: inline-flex;
    position: relative;
    align-items: center;
    justify-content: center;
    vertical-align: middle;
    overflow: hidden;
    text-decoration: none;
    text-decoration-color: currentcolor;
    white-space: nowrap;
    user-select: none;
    font-weight: var(--font-weight-semibold);
    font-size: 13px;
    height: var(--button-size-default);
    border-radius: var(--input-border-radius-default);
}

.k-main-container {
    min-width: 300px;
    position: relative;
    background: inherit;
    border-top: 2px solid var(--color-background-button-primary-default);
}

.k-main-container.k-draggable {
    border: 2px solid var(--color-background-button-primary-default);
    position: absolute;
    z-index: 100;
 }

.k-store-buttongroups {
    padding: 0px 15px 15px;
}

.k-buttongroups {
    padding: 25px 15px 15px;
}

.k-buttongroup {
    display: flex;
    flex-wrap: wrap;
    gap: 5px;
}

.k-buttongroup-label {
    font-size: 13px;
}

.k-labelgroup {
    margin-top: 5px;
    font-size: 17px;
    gap: 25px;
    display: flex;
}

.k-hidden {
    display: none;
}

#k-streamelements_points {
    font-size: 12px;
    position: absolute;
    left: 5px;
    top: 5px;
}

#k-streamelements_points .k-points_added {
    color: green;
}

#k-streamelements_points .k-points_subtracted {
    color: red;
}

#k-panel-buttons {
    position: absolute;
    top: 5px;
    right: 5px;
    user-select: none;
    font-size: 16px;
    display: grid;
    gap: 5px;
    grid-auto-flow: column;
}

#k-pin-button,
#k-make-draggable-button,
#k-cart-button,
#k-open-settings {
    cursor: pointer;
}

#k-grab-handle {
    cursor: grab;
}

.k-grid-1 { display: grid; grid-template-columns: repeat(1, min-content); }
.k-grid-2 { display: grid; grid-template-columns: repeat(2, min-content); }
.k-grid-3 { display: grid; grid-template-columns: repeat(3, min-content); }
.k-grid-4 { display: grid; grid-template-columns: repeat(4, min-content); }
.k-grid-5 { display: grid; grid-template-columns: repeat(5, min-content); }
.k-grid-6 { display: grid; grid-template-columns: repeat(6, min-content); }
.k-grid-7 { display: grid; grid-template-columns: repeat(7, min-content); }
.k-grid-8 { display: grid; grid-template-columns: repeat(8, min-content); }

.k-twitch-store-amount-panel {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    border: 2px solid var(--color-twitch-purple);
    border-radius: 8px;
    margin-top: 10px;
    font-weight: bold;
    background: rgba(0,0,0,0.1);
}

.k-twitch-store-amount-panel button {
    padding: 6px 10px;
    font-size: 16px;
    cursor: pointer;
}

.k-twitch-store-amount-panel input[type="number"] {
    width: 30px;
    text-align: center;
    font-size: 16px;
    background: none;
    border: none;
    color: inherit;
    -webkit-appearance: none;
    -moz-appearance: textfield;
}

.k-twitch-store-amount-panel input[type="number"]:focus-visible {
    outline: none;
}

.k-twitch-store-cart-button {
    border-left: 2px solid var(--color-twitch-purple);
}
`);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址