TwitchTranslate

Automatically translates messages in Twitch chat to other languages.

// ==UserScript==
// @name         TwitchTranslate
// @namespace    MrSelenix
// @version      1.0.3
// @description  Automatically translates messages in Twitch chat to other languages.
// @author       MrSelenix
// @match        https://www.twitch.tv/*
// @match        https://dashboard.twitch.tv/*
// @license      MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_info
// @grant        GM_xmlhttpRequest
// @connect      api-free.deepl.com
// @connect      api.deepl.com
// ==/UserScript==

(function() {
    'use strict';

    ///////////
    // About //
    ///////////

    function about() {
        const version = GM_info.script.version;
        const name = GM_info.script.name;
        alert(`${name}\n\nThis script introduces the ability to translate messages from your favourite twitch channels into a different language.\nSome customizable options have also been made available for you to improve your experience.\n\n-Author: MrSelenix\n-Version: ${version}`);
    }

    ////////////////////////
    // Utility            //
    ////////////////////////

    function levenshteinDistance(str1, str2) {
        const len1 = str1.length;
        const len2 = str2.length;
        const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
        for (let i = 0; i <= len1; i++) dp[i][0] = i;
        for (let j = 0; j <= len2; j++) dp[0][j] = j;
        for (let i = 1; i <= len1; i++) {
            for (let j = 1; j <= len2; j++) {
                const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
                dp[i][j] = Math.min(
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i - 1][j - 1] + cost
                );
            }
        }
        return dp[len1][len2];
    }

    function repeatLimiter(str) {
        return str.replace(/([^0-9]{1,10}?)(\1{3,})/gi, (match, p1) => p1.repeat(3)); // Replace all repeating characters or sequences (excluding numbers) if the sequence repeats at least 3 times. e.g. "HiHiHiHiHi" => "HiHiHi" || "HIIIIIIIII" => "HIII"
    }

    function adjustMenuPosition(menu, anchorButton) {
        const btnRect = anchorButton.getBoundingClientRect();
        const menuRect = menu.getBoundingClientRect();

        const top = window.scrollY + btnRect.top - menuRect.height;
        const left = window.scrollX + btnRect.right - menuRect.width;

        menu.style.top = `${top}px`;
        menu.style.left = `${left}px`;
        menu.style.visibility = 'visible';
    }

    ////////////////////////
    // Color Definitions  //
    ////////////////////////

    const darkMode = {
        menuText: '#fafafa',
        menuBackground: '#18181b',
        menuHover: '#27272a',
        icon: '#b3b3b3',
        iconHover: '#313133',
        selectText: '#c0e0ff',
        outline: '#101010',
        shadow: '#000000',
        dropBackground: '#23232b',
        dropHover: '#30303b',
    }

    const lightMode = {
        menuText: '#050505',
        menuBackground: '#f5f5fa',
        menuHover: '#e2e2e6',
        icon: '#575757',
        iconHover: '#e5e5e5',
        selectText: '#77bbff',
        outline: '#e5e5e5',
        shadow: '#bbbbbb',
        dropBackground: '#ebebf0',
        dropHover: '#e0e0e0',
    }

    function isLightMode() {
        return document.documentElement.classList.contains('tw-root--theme-light');
    }

    function applyTheme(mode) {
        for (const [key, value] of Object.entries(mode)) {
            document.documentElement.style.setProperty(`--${key}`, value);
        }
    }

    //////////////////
    // Initialize   //
    //////////////////

    function initialize() {
        applyTheme(isLightMode() ? lightMode : darkMode);

        const observer = new MutationObserver(() => {
            if (!document.getElementById('tm-custom-main-btn')) {
                tt_button();
            }
        });

        const theme = new MutationObserver(() => {
            applyTheme(isLightMode() ? lightMode : darkMode);
        });

        observer.observe(document.body, { childList: true, subtree: true });
        theme.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
    }

    initialize();


    //////////////////////////////
    // Create Settings Button   //
    //////////////////////////////

    async function tt_button() {
        async function wait() {
            for (let i = 0; i < 10; i++) {
                if (i === 9) {
                    return;
                } else {
                }
                await new Promise(resolve => setTimeout(resolve, 250));
            }
        }

        (async () => {
            await wait();

            if (document.getElementById('tm-custom-main-btn')) return;

            const chatButton = document.querySelector('[data-a-target="chat-send-button"]').parentNode.parentNode;

            mainButton = document.createElement('button');
            mainButton.id = 'tm-custom-main-btn';
            mainButton.title = 'Translate Settings';
            mainButton.style.background = 'transparent';
            mainButton.style.border = 'none';
            mainButton.style.borderRadius = '4px';
            mainButton.style.marginRight = '1px';
            mainButton.style.cursor = 'pointer';
            mainButton.style.display = 'flex';
            mainButton.style.paddingRight = '5px';
            mainButton.style.paddingLeft = '3px';
            mainButton.style.paddingBottom = '6px';

            mainButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="var(--icon, #b3b3b3ff)" class="bi bi-translate" viewBox="-4 -6 22 22"><path d="M4.545 6.714 4.11 8H3l1.862-5h1.284L8 8H6.833l-.435-1.286H4.545zm1.634-.736L5.5 3.956h-.049l-.679 2.022H6.18z"></path><path d="M0 2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v3h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-3H2a2 2 0 0 1-2-2V2zm2-1a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H2zm7.138 9.995c.193.301.402.583.63.846-.748.575-1.673 1.001-2.768 1.292.178.217.451.635.555.867 1.125-.359 2.08-.844 2.886-1.494.777.665 1.739 1.165 2.93 1.472.133-.254.414-.673.629-.89-1.125-.253-2.057-.694-2.82-1.284.681-.747 1.222-1.651 1.621-2.757H14V8h-3v1.047h.765c-.318.844-.74 1.546-1.272 2.13a6.066 6.066 0 0 1-.415-.492 1.988 1.988 0 0 1-.94.31z"></path></svg>`;
            mainButton.onmouseenter = () => mainButton.style.background = 'var(--iconHover, #53535f7a)';
            mainButton.onmouseleave = () => mainButton.style.background = 'transparent';
            mainButton.onclick = onMainButtonClick;
            mainButton.onmousedown = e => e.stopPropagation();

            if (chatButton) {
                chatButton.parentNode.insertBefore(mainButton, chatButton);
                console.log(`[TwitchTranslate] Version ${GM_info.script.version} Loaded Successfully`);
            } else {
                 console.error("Error appending");
            }
            messageExtraction();
            window.addEventListener('popstate', closeMenu);
        })();
    }

    ////////////////////////////
    // Create & Populate Menu //
    ////////////////////////////

    function createMenu(button) {
        const menu = document.createElement('div');
        const title = document.createElement('h3');
        const divider = document.createElement('hr');

        //Styling
        menu.className = 'tm-custom-menu';
        menu.style.position = 'absolute';
        menu.style.background = 'var(--menuBackground, #18181b)';
        menu.style.border = '1px solid var(--outline, #101010)';
        menu.style.borderRadius = '4px';
        menu.style.boxShadow = '0 4px 24px var(--shadow, #000000';
        menu.style.padding = '8px';
        menu.style.zIndex = 10000;
        menu.style.minWidth = '140px';
        menu.tabIndex = -1;

        title.textContent = 'Translate Settings';
        title.style.fontSize = '20px';
        title.style.fontFamily = 'Helvetica, monospace';
        title.style.fontWeight = 'bold';
        title.style.margin = '0 0 10px 0';
        title.style.textAlign = 'center';
        title.style.color = 'var(--menuText, #ffffff';

        divider.style.cssText = `
         width: 265px;
         height: 1px;
         border: none;
         margin: 1px 0;
         background-color: #666666;
         `;
        const divider2 = divider.cloneNode(true);

        /////////////////////
        // Menu Population //
        /////////////////////

        // Menu Title
        menu.appendChild(title);
        menu.appendChild(divider);

        // Enable/Disable Button
        const toggleBtn = createTranslationsToggleButton();
        toggleBtn.setEnabledState(GM_getValue('translationsEnabled', true));
        menu.appendChild(toggleBtn);

        // Languages Button
        const langBtn = createDropdownButton(languages, "targetLanguage", "en", "Target Language:");
        menu.appendChild(langBtn);

        // Server Selection Button
        const serverBtn = createDropdownButton(servers, "server", "GoogleLegacy", "Server:", (selectedServer) => {
            showApiKeyInput(selectedServer); // your function to update the menu UI

            // Alert if the selected server requires an API key but none is set
            if (selectedServer !== 'GoogleLegacy' && servers[selectedServer]) {
                const keyName = servers[selectedServer].gmKey;
                const apiKey = GM_getValue(keyName, "");
                const webLink = servers[selectedServer].link;
                const id = servers[selectedServer].id;
                if (!apiKey) {
                    alert(`You need to get an API Key to use the ${id} server. You can get one from "${webLink}"`);
                }
            }
        });
        menu.appendChild(serverBtn);

        // API Key input button
        let apiKeyInputContainer = null;
        function showApiKeyInput(selectedServer) {
            if (apiKeyInputContainer && apiKeyInputContainer.parentNode) {
                apiKeyInputContainer.parentNode.removeChild(apiKeyInputContainer);
            }
            if (selectedServer !== 'GoogleLegacy' && servers[selectedServer]) {
                apiKeyInputContainer = createApiKeyInput(selectedServer);
                menu.insertBefore(apiKeyInputContainer, serverBtn.nextSibling);
            }
            adjustMenuPosition(menu, mainButton);
        }

        showApiKeyInput(GM_getValue("server", "GoogleLegacy"));

        menu.appendChild(divider2);

        const advancedContainer = advancedOptionsContainer(menu, mainButton);
        // All Advance Sub-menu options here

        // Message history Button
        // CURRENTLY BROKEN!
        //const histBtn = createMsgHistoryButton();
        //histBtn.setEnabledState(GM_getValue('history', false));
        //advancedContainer.appendChild(histBtn);

        // Color picker button
        const textColor = colorPicker();
        advancedContainer.appendChild(textColor);

        // Toggle Emotes
        const emoteBtn = emoteToggle();
        emoteBtn.setEnabledState(GM_getValue('emotes', true));
        advancedContainer.appendChild(emoteBtn);

        // Toggle Append Style
        const spanType = appendType();
        spanType.setEnabledState(GM_getValue('spanType', "span"));
        advancedContainer.appendChild(spanType);

        // About Button
        advancedContainer.appendChild(createMenuButton('About', about, 'var(--selectText, #c0e0ff)'));

        //////////////////////
        //End of Population //
        //////////////////////

        menu.style.visibility = 'hidden';
        menu.style.left = '-9999px';
        menu.style.top = '-9999px';
        document.body.appendChild(menu);

        adjustMenuPosition(menu, button);

        return menu;
    }


    ///////////////////////////////
    // Main Button & Menu Logic  //
    ///////////////////////////////

    let menuOpen = false;
    let menuElement = null;
    let mainButton = null;

    function closeMenu() {
        if (menuElement) {
            if (typeof openDropdown === "function") {
                openDropdown();
                openDropdown = null;
            }
            menuElement.remove();
            menuElement = null;
            menuOpen = false;
        }
    }

    function onMainButtonClick(e) {
        e.stopPropagation();

        if (menuOpen) {
            closeMenu();
            return;
        }

        menuElement = createMenu(mainButton);
        menuOpen = true;

        setTimeout(() => {
            document.addEventListener('mousedown', outsideClickListener);
        }, 0);
    }

    function outsideClickListener(e) {
        if (
            menuElement &&
            !menuElement.contains(e.target) &&
            e.target !== mainButton
        ) {
            closeMenu();
            document.removeEventListener('mousedown', outsideClickListener);
        }
    }

    window.addEventListener("resize", function() {
        if (menuOpen && menuElement && mainButton) {
            adjustMenuPosition(menuElement, mainButton);
        }
    });

    ////////////////////////////
    // Menu Button Creator    //
    ////////////////////////////

    function createMenuButton(label, onClick, color) {
        const btn = document.createElement('button');
        btn.textContent = label;
        btn.className = 'tm-custom-menu-btn';
        btn.style.display = 'block';
        btn.style.width = '100%';
        btn.style.margin = '4px 0';
        btn.style.padding = '6px 12px';
        btn.style.background = 'var(--menuBackground, #18181b)';
        btn.style.color = color || 'var(--menuText, #ffffff)';
        btn.style.border = 'none';
        btn.style.borderRadius = '6px';
        btn.style.textAlign = 'left';
        btn.style.cursor = 'pointer';
        btn.style.fontWeight = 'bold';
        btn.onmousedown = e => e.stopPropagation();
        btn.onclick = onClick;
        btn.onmouseover = () => btn.style.background = 'var(--menuHover, #27272a';
        btn.onmouseout  = () => btn.style.background = 'var(--menuBackground, #18181b)';
        return btn;
    }

    /////////////////////////
    // API Key Entry Field //
    /////////////////////////

    function createApiKeyInput(serverId, onInput) {
        const meta = servers[serverId];
        if (!meta) return null;

        const container = document.createElement('div');
        container.style.display = 'flex';
        container.style.alignItems = 'center';
        container.style.margin = '8px 0';

        const label = document.createElement('span');
        label.textContent = meta.label;
        label.style.fontWeight = 'bold';
        label.style.marginLeft = '11px';
        label.style.marginRight = '4px';
        label.style.whiteSpace = 'nowrap';
        container.appendChild(label);

        const input = document.createElement('input');
        input.type = 'password';
        input.placeholder = meta.placeholder;
        input.value = GM_getValue(meta.gmKey, '');
        input.style.flex = '1';
        input.title = `Enter your ${meta.placeholder}`;
        input.style.padding = '2px 6px';
        input.style.border = '1px solid #aaa';
        input.style.borderRadius = '4px';
        input.style.marginRight = '-2px';
        input.style.fontSize = '14px';
        input.style.background = 'var(--menuBackground, #18181b)';
        input.style.color = 'var(--menuText, #fafafa)';

        input.addEventListener('input', () => {
            GM_setValue(meta.gmKey, input.value);
            if (onInput) onInput(input.value);
        });

        input.addEventListener('keydown', (e) => {
            if (e.key === "Escape" || e.key === "Enter") {
                input.blur();
                e.stopPropagation();
            }
        });

        container.appendChild(input);
        container._input = input;

        return container;
    }

    /////////////////////////////
    // Enabled/Disabled Toggle //
    /////////////////////////////

    function createTranslationsToggleButton(onStateChange) {
        function getToggleTranslationsLabel(enabled) {
            return `<span style="font-weight:bold">Translations:</span> <span style="font-weight:bold; color:${enabled ? "#1db755" : "#e02020"}">${enabled ? "Enabled" : "Disabled"}</span>`;
        }
        let enabled = GM_getValue('translationsEnabled', true);

        let btn = createMenuButton(getToggleTranslationsLabel(enabled), () => {
            enabled = !enabled;
            GM_setValue('translationsEnabled', enabled);
            btn.innerHTML = getToggleTranslationsLabel(enabled);
            if (onStateChange) onStateChange(enabled);
        });

        btn.setEnabledState = function(state) {
            enabled = state;
            btn.innerHTML = getToggleTranslationsLabel(enabled);
        };
        return btn;
    }

    /////////////////////////////
    // Message History Toggle  //
    /////////////////////////////

    function appendType(onStateChange) {
        function getAppendTypeLabel(type) {
            // type is either "span" or "div"
            let styleName = type === "span" ? "Inline" : "Newline";
            return `<span style="font-weight:bold">Append Style:</span> <span style="font-weight:bold; color:var(--selectText, #c0e0ff)">${styleName}</span>`;
        }
        let type = GM_getValue('spanType', "span");

        let btn = createMenuButton(getAppendTypeLabel(type), () => {
            type = (type === "span") ? "div" : "span";
            GM_setValue('spanType', type);
            btn.innerHTML = getAppendTypeLabel(type);
            if (onStateChange) onStateChange(type);
        });

        btn.setEnabledState = function(newType) {
            type = newType;
            btn.innerHTML = getAppendTypeLabel(type);
        };
        return btn;
    }

    ////////////////////
    // Emotes Toggle  //
    ////////////////////

    function emoteToggle(onStateChange) {
        function getemoteLabel(enabled) {
            return `<span style="font-weight:bold">Show Emotes:</span> <span style="font-weight:bold; color:${enabled ? "#1db755" : "#e02020"}">${enabled ? "Enabled" : "Disabled"}</span>`;
        }
        let enabled = GM_getValue('emotes', true);

        let btn = createMenuButton(getemoteLabel(enabled), () => {
            enabled = !enabled;
            GM_setValue('emotes', enabled);
            btn.innerHTML = getemoteLabel(enabled);
            if (onStateChange) onStateChange(enabled);
        });

        btn.setEnabledState = function(state) {
            enabled = state;
            btn.innerHTML = getemoteLabel(enabled);
        };
        return btn;
    }

    /////////////////////////////
    // Message History Toggle  //
    /////////////////////////////

    function createMsgHistoryButton(onStateChange) {
        function getMsgHistoryLabel(enabled) {
            return `<span style="font-weight:bold">Historical Translations:</span> <span style="font-weight:bold; color:${enabled ? "#1db755" : "#e02020"}">${enabled ? "Enabled" : "Disabled"}</span>`;
        }
        let enabled = GM_getValue('history', false);

        let btn = createMenuButton("", () => {
            enabled = !enabled;
            GM_setValue('history', enabled);
            labelSpan.innerHTML = getMsgHistoryLabel(enabled);
            if (onStateChange) onStateChange(enabled);
        });

        const labelSpan = document.createElement("span");
        labelSpan.innerHTML = getMsgHistoryLabel(enabled);

        const helpSpan = document.createElement("span");
        helpSpan.textContent = "?";
        helpSpan.title = "Enabled = Translate Old Messages When Connecting\nDisabled = Translate New Messages Only\nRecommend = Disabled";
        helpSpan.style.color = "#808080";
        helpSpan.style.float = "right";
        helpSpan.style.fontSize = "16px";

        btn.appendChild(labelSpan);
        btn.appendChild(helpSpan);

        btn.setEnabledState = function(state) {
            enabled = state;
            labelSpan.innerHTML = getMsgHistoryLabel(enabled);
        };

        return btn;
    }

    //////////////////
    // Chat Colours //
    //////////////////

    function colorPicker(defaultColor = "#9147FF") {
        function isValidHex(hex) {
            return /^#[0-9A-Fa-f]{6}$/.test(hex);
        }

        let color = GM_getValue('textColor', defaultColor).toUpperCase() ;
        if (!isValidHex(color)) color = defaultColor;
        let lastValidColor = color;

        const resetBtn = document.createElement("button");
        resetBtn.type = "button";
        resetBtn.textContent = "⟲";
        resetBtn.title = "Reset to default color";
        resetBtn.style.border = "none";
        resetBtn.style.float = "right";
        resetBtn.style.background = "transparent";
        resetBtn.style.cursor = "pointer";
        resetBtn.style.fontSize = "16px";
        resetBtn.style.verticalAlign = "middle";
        resetBtn.style.color = "#bbb";
        resetBtn.style.padding = "0 2px";
        resetBtn.onmouseenter = () => resetBtn.style.color = defaultColor;
        resetBtn.onmouseleave = () => resetBtn.style.color = "#bbb";

        resetBtn.addEventListener("click", function(e) {
            e.preventDefault();
            e.stopPropagation();
            setColor(defaultColor);
        });

        const input = document.createElement("input");
        input.type = "text";
        input.maxLength = 7;
        input.value = color;
        input.style.color = color;
        input.style.width = "90px";
        input.style.float = "right";
        input.style.textAlign = "center";
        input.style.border = "1px solid #ccc";
        input.style.borderRadius = "4px";
        input.style.padding = "2px 6px";
        input.style.fontSize = "14px";
        input.style.fontWeight = 'bold';
        input.style.background = 'var(--menuBackground, #18181b)';
        input.style.marginLeft = "10px";
        input.placeholder = defaultColor;
        input.style.boxSizing = "border-box";

        const colorInput = document.createElement("input");
        colorInput.type = "color";
        colorInput.value = color;
        colorInput.style.display = "none";

        function colorPickerLabel() {
            return 'Text Color:';
        }

        const btn = createMenuButton(colorPickerLabel(), () => {

            if (document.activeElement !== input) {
                document.body.appendChild(colorInput);
                const rect = btn.getBoundingClientRect();
                colorInput.style.position = "absolute";
                colorInput.style.left = rect.left + "px";
                colorInput.style.top = (rect.top - 4) + "px";
                colorInput.style.zIndex = 10000;
                colorInput.style.opacity = '0';
                colorInput.style.display = "block";
                colorInput.focus();
                colorInput.click();
            }
        });
        btn.appendChild(input);
        btn.appendChild(resetBtn);

        function setColor(newColor) {
            if (!isValidHex(newColor)) return;
            color = newColor.toUpperCase() ;
            lastValidColor = newColor;
            input.value = newColor;
            input.style.color = newColor;
            colorInput.value = newColor;
            GM_setValue('textColor', newColor);
        }

        input.addEventListener("input", (e) => {
            let val = e.target.value.toUpperCase() ;
            if (isValidHex(val)) setColor(val);
        });

        function restoreIfInvalid() {
            if (!isValidHex(input.value)) {
                input.value = lastValidColor.toUpperCase() ;
                input.style.color = lastValidColor;
            }
        }

        input.addEventListener("keydown", (e) => {
            if (e.key === "Enter" || e.key === "Escape") {
                restoreIfInvalid();
            }
        });
        input.addEventListener("blur", restoreIfInvalid);

        input.addEventListener("click", (e) => {
            e.stopPropagation();
        });

        input.addEventListener("dblclick", (e) => {
            colorInput.focus();
            colorInput.click();
        });

        colorInput.addEventListener("input", (e) => {
            setColor(e.target.value.toUpperCase() );
            colorInput.blur();
        });

        btn.setColor = setColor;

        return btn;
    }

    ////////////////////////////////
    // Advanced options container //
    ////////////////////////////////

    function advancedOptionsContainer(menu, mainButton, insertBefore) {
        const advancedContainer = document.createElement('div');
        advancedContainer.style.display = 'none';
        menu.appendChild(advancedContainer);

        let advancedVisible = false;
        const advancedToggleBtn = createMenuButton('Show Advanced Options', () => {
            advancedVisible = !advancedVisible;
            advancedContainer.style.display = advancedVisible ? '' : 'none';
            advancedToggleBtn.textContent = advancedVisible ? 'Hide Advanced Options' : 'Show Advanced Options';
            adjustMenuPosition(menu, mainButton);
        });
        advancedToggleBtn.style.color = '#808080';

        // Change which line is active based on personal preference.

        menu.appendChild(advancedToggleBtn);                           //Place Button at Bottom of menu
        //menu.insertBefore(advancedToggleBtn, advancedContainer);     //Place Button at Top of menu

        return advancedContainer
    }

    ///////////////////////////
    // Dropdown Menu Builder //
    ///////////////////////////

    let openDropdown = null;
    function createDropdownButton(optionsMap, storageKey, defaultValue, labelPrefix, onChange) {
        let currentValue = GM_getValue(storageKey, defaultValue);

        function getLabel(id) {
            const value = optionsMap[id];
            return typeof value === 'string' ? value : (value && typeof value === 'object' && value.id ? value.id : id);
        }

        const btn = createMenuButton(
            `<span style="font-weight:bold">${labelPrefix}</span> <span style="font-weight:bold; color:var(--selectText, #c0e0ff)">${getLabel(currentValue)}</span> <span style="float:right;color:#888">&#9662;</span>`,
            function(e) {
                e.stopPropagation();
                toggleDropdown();
            }
        );

        function updateButtonLabel() {
            btn.innerHTML = `<span style="font-weight:bold">${labelPrefix}</span> <span style="font-weight:bold; color:var(--selectText, #c0e0ff)">${getLabel(currentValue)}</span> <span style="float:right;color:#888">&#9662;</span>`;
        }

        let dropdown = null;
        function toggleDropdown() {
            if (dropdown && dropdown.parentNode) {
                closeDropdown();
                return;
            }
            showDropdown();
        }
        function showDropdown() {
            if (openDropdown && openDropdown !== closeDropdown) {
                openDropdown();
            }
            openDropdown = closeDropdown;

            dropdown = document.createElement('div');
            dropdown.style.position = 'absolute';
            dropdown.style.background = 'var(--dropBackground, #23232b';
            dropdown.style.border = '1px solid var(--outline, #444444';
            dropdown.style.borderRadius = '6px';
            dropdown.style.boxShadow = '0 4px 8px var(--shadow, #00000040';
            dropdown.style.minWidth = btn.offsetWidth + 'px';
            dropdown.style.zIndex = 10001;
            dropdown.style.fontWeight = 'bold';

            if (Object.keys(optionsMap).length > 10) {
                dropdown.style.maxHeight = "400px";
                dropdown.style.overflowY = "auto";
            }

            Object.entries(optionsMap).forEach(([id, value]) => {
                const option = document.createElement('div');
                option.textContent = typeof value === 'string' ? value : (value && typeof value === 'object' && value.id ? value.id : id);
                option.style.borderRadius = '6px';
                option.style.padding = '7px 10px';
                option.style.cursor = 'pointer';
                option.style.color = id === currentValue ? '#1db755' : 'var(--menuText, #fafafa)';
                option.onmouseover = () => option.style.background = 'var(--dropHover, #333333)';
                option.onmouseout  = () => option.style.background = 'transparent';
                option.onmousedown = function(ev) {
                    ev.stopPropagation();
                    currentValue = id;
                    GM_setValue(storageKey, currentValue);
                    updateButtonLabel();
                    if (onChange) onChange(currentValue);
                    closeDropdown();
                };
                dropdown.appendChild(option);
            });

            dropdown.style.display = 'block';
            document.body.appendChild(dropdown);
            const btnRect = btn.getBoundingClientRect();
            const dropdownHeight = dropdown.offsetHeight;
            dropdown.style.left = btnRect.left + 'px';
            dropdown.style.top = (btnRect.top + window.scrollY - dropdownHeight) + 'px';

            function closeDropdownOnMouseDown(ev) {
                if (dropdown && !dropdown.contains(ev.target) && ev.target !== btn) {
                    closeDropdown();
                    document.removeEventListener('mousedown', closeDropdownOnMouseDown);
                }
            }
            setTimeout(() => document.addEventListener('mousedown', closeDropdownOnMouseDown), 0);

            dropdown._outsideHandler = closeDropdownOnMouseDown;
        }
        function closeDropdown() {
            if (dropdown && dropdown.parentNode) {
                document.removeEventListener('mousedown', dropdown._outsideHandler);
                dropdown.remove();
                dropdown = null;
            }
            if (openDropdown === closeDropdown) {
                openDropdown = null;
            }
        }
        btn.closeDropdown = closeDropdown;

        btn.setValue = function(value) {
            currentValue = value;
            GM_setValue(storageKey, currentValue);
            updateButtonLabel();
        };
        btn.getValue = function() {
            return currentValue;
        };

        btn.setValue(GM_getValue(storageKey, defaultValue));

        return btn;
    }

    //////////////////////////////
    // Message processing       //
    //////////////////////////////

    function messageExtraction() {
        const originals = new Map();
        const chatContainer = document.querySelector('[data-test-selector="chat-scrollable-area__message-container"], .scrollable-container.seventv-chat-scroller');
        if (!chatContainer) return;

        // Extract all text from each message and use placeholders for emotes and @mentions
        function extractText(chatLine) {
            const parts = [];
            const mentions = [];
            const emotes = [];
            let mentionIndex = 0;
            let emoteIndex = 0;
            let allowEmotes = GM_getValue('emotes', true);

            chatLine.childNodes.forEach(node => {
                if (node.nodeType === Node.TEXT_NODE) {
                    if (node.textContent.trim()) parts.push(node.textContent.trim());
                } else if (node.nodeType === Node.ELEMENT_NODE) {
                    if (node.matches('.mention-fragment, .mention-token')) {
                        mentions.push(node.textContent.trim());
                        parts.push(`53-13_1${mentionIndex}`);//Store mentions as a variable to reconstruct later, avoids translating @usernames.
                        mentionIndex++;
                    }
                    else if (node.matches('.text-fragment, [data-a-target="chat-message-text"], .text-token')) {
                        node.childNodes.forEach(child => {
                            if (child.nodeType === Node.TEXT_NODE) {
                                if (child.textContent.trim()) parts.push(child.textContent.trim());
                            } else if (child.nodeType === Node.ELEMENT_NODE) {
                                if (child.matches('.mention-fragment, .mention-token')) {
                                    mentions.push(child.textContent.trim());
                                    parts.push(`53-13_1${mentionIndex}`); //Store mentions as a variable to reconstruct later, avoids translating @usernames.
                                    mentionIndex++;
                                } else {
                                    const emoteImg = child.querySelector('img[alt]');
                                    if (emoteImg && allowEmotes) {
                                        emotes.push(emoteImg.srcset);
                                        parts.push(`53-13_2${emoteIndex}`);//Store Emotes as a variable to reconstruct later, allows emote images later.
                                        emoteIndex++;
                                    }
                                }
                            }
                        });
                    }
                    else if (
                        node.matches('.chat-line__message--emote-button, [data-test-selector="emote-button"], .seventv-chat-emote')
                    ) {
                        const img = node.querySelector('img[alt]');
                        if (img && allowEmotes) {
                            emotes.push(img.srcset);
                            parts.push(`53-13_2${emoteIndex}`);//Store Emotes as a variable to reconstruct later, allows emote images later.
                            emoteIndex++;
                        }
                    }
                }
            });

            //Storing Mentions and Emotes this way keeps their positions within the chat message, useful for reconstruction later, while preventing mentions or emotes from being translated alongside the main message.
            //The use of numbers, dashes and underscores is the most reliable way to pass through translations without being modified, as some languages have different ways of writing . () [] and character strings etc.
            //While there may be better ways to go about this, this has worked 99.99% of the time during testing.

            let extractedMessage = parts.join(' ').replace(/\s+/g, ' ').trim();
            extractedMessage = repeatLimiter(extractedMessage);
            return { extractedMessage, mentions, emotes };
        }

        async function detectLanguage(text, targetLang) {
            let regex = /\s*5_3-1_[34]-(\d+)\s*/g;
            text = text.replace(regex, ' ').replace(/\s+/g, ' ').trim();
            const response = await fetch(
                `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`
            );
            const data = await response.json();
            return data[2]; // returns the detected language. "en", "fr", "de" etc...
        }

        // Translate with GoogleLegacy
        async function translate_Google(text, targetLang) {
            const response = await fetch(
                `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`
            );
            const data = await response.json();
            return data[0].map(item => item[0]).join('');
        }

        // Translate with DeepL
        function translate_DeepL(text, targetLang) {
            const apiKey = GM_getValue('DeepLApiKey', '');
            if (!apiKey) {
                console.warn("[TwitchTranslate] DeepL API key missing.");
                return Promise.resolve(null);
            }
            const dlTargetLang = targetLang.toUpperCase();
            const params = new URLSearchParams({
                auth_key: apiKey,
                text: text,
                target_lang: dlTargetLang
            }).toString();

            // Try Pro endpoint first, then Free if error
            const endpoints = [
                "https://api.deepl.com/v2/translate",
                "https://api-free.deepl.com/v2/translate"
            ];
            let triedEndpoints = 0;

            return new Promise((resolve, reject) => {
                function tryEndpoint() {
                    if (triedEndpoints >= endpoints.length) { // If both endpoints fail
                        const msg = "[TwitchTranslate] DeepL response error: 403 - Forbidden.\nInvalid or expired API key. If problem persists, try a different server";
                        console.error(msg);
                        resolve({
                            translation: "DeepL API key is invalid or does not have permission.",
                            detectedLang: "Error"
                        });
                        return;
                    }
                    const URL = endpoints[triedEndpoints];
                    GM_xmlhttpRequest({
                        method: "POST",
                        url: URL,
                        headers: {
                            "Content-Type": "application/x-www-form-urlencoded"
                        },
                        data: params,
                        onload: function(response) {
                            try {
                                // If 456 error (Quota exceeded)
                                if (response.status === 456) {
                                    console.warn("[TwitchTranslate] Quota exceeded. The character limit for this billing period has been reached");
                                    resolve({
                                        translation: "DeepL Quota exceeded for this billing period.",
                                        detectedLang: "Error"
                                    });
                                    return
                                }
                                // If 4xx error (invalid auth, wrong endpoint for key, etc.), try next
                                if (response.status >= 400 && response.status < 500) {
                                    triedEndpoints++;
                                    tryEndpoint();
                                    return;
                                }
                                if (response.status !== 200) {
                                    let msg = `[TwitchTranslate] DeepL response error: ${response.status}`;
                                    try {
                                        const err = JSON.parse(response.responseText);
                                        if (err && err.message) msg += ` - ${err.message}`;
                                    } catch {}
                                    console.error(msg);
                                    resolve(null);
                                    return;
                                }
                                const data = JSON.parse(response.responseText);
                                if (
                                    !data.translations ||
                                    !data.translations.length ||
                                    !data.translations[0].text
                                ) {
                                    console.error("[TwitchTranslate] DeepL: No translation in response", data);
                                    resolve(null);
                                    return;
                                }
                                const detectedLang = (data.translations[0].detected_source_language || '').toLowerCase();
                                // Don't post translation if already in target language
                                if (detectedLang === dlTargetLang.toLowerCase()) {
                                    console.warn("[TwitchTranslate] DeepL Skipped the translation because the language was detected to be the same as the target");
                                    resolve(null);
                                    return;
                                }
                                resolve({
                                    translation: data.translations[0].text,
                                    detectedLang: detectedLang
                                });
                            } catch (e) {
                                console.error("[TwitchTranslate] DeepL parse error:", e, response.responseText);
                                resolve(null);
                            }
                        },
                        onerror: function(err) {
                            triedEndpoints++;
                            tryEndpoint();
                        }
                    });
                }
                tryEndpoint();
            });
        }

        // Replace placeholders in translation with mentions/emotes
        function reconstructMessage(translated, mentions, emotes = []) {
            let result = translated;
            if (mentions) {
                mentions.forEach((mention, idx) => {
                    result = result.replace(new RegExp(`53-13_1${idx}`, 'g'), mention);
                });
            }
            if (emotes) {
                emotes.forEach((srcset, idx) => {
                    result = result.replace(
                        new RegExp(`53-13_2${idx}`, 'g'),
                        `<img srcset="${srcset}" style="height:1.8em;vertical-align:-0.66em;" alt="emote">`
                    );
                });
            }
            return result;
        }

        // Main translation handler for each message
        async function handleTranslation(chatLine, extractedMessage, mentions, emotes) {
            const translationsEnabled = GM_getValue('translationsEnabled', true);
            if (!translationsEnabled) return;

            const targetLang = GM_getValue('targetLanguage', 'en');
            const server = GM_getValue('server', 'GoogleLegacy');

            let detectedLang = await detectLanguage(extractedMessage, targetLang);

            if (detectedLang === targetLang) return; // Detect the language of each message using google to know if we should try to translate the full message with the chosen server. This saves us wasting characters with pointless translations on servers with character Limits.
            if (!(detectedLang in languages)) return; //Limit languages to whats in the language map (for GoogleLegacy)

            //server selection
            let translated;
            //Google
            if (server === 'GoogleLegacy') {
                translated = await translate_Google(extractedMessage, targetLang);
            }
            //DeepL
            else if (server === 'DeepL') {
                const dlResult = await translate_DeepL(extractedMessage, targetLang);
                if (!dlResult) return;
                translated = dlResult.translation;
                detectedLang = dlResult.detectedLang;
                //if (!(detectedLang in languages)) return; //Limit languages to whats in the language map (For DeepL)
            }

            else {
                // Add other services here
                return;
            }

            //Levenshtein filtering
            const extractedLower = extractedMessage.toLowerCase();
            const transLower = translated.toLowerCase();
            const dist = 5; // Higher numbers mean more differences between original and translation must be present before appending a message to the chat

            if (levenshteinDistance(extractedLower, transLower) < dist ) { return };

            // Reconstruct message with mentions/emotes
            const finalMessage = reconstructMessage(translated, mentions, emotes);

            // Append translation after original
            const langName = languages[detectedLang] || detectedLang;
            const output = `(Translated from ${langName}: ${finalMessage})`;
            if (!chatLine.querySelector('.twitchtranslate-reconstructed')) {
                let color = GM_getValue('textColor', "#9147ff");
                let spanType = GM_getValue('spanType', "span");
                const newSpan = document.createElement(spanType);
                newSpan.innerHTML = ' ' + output;
                newSpan.className = 'twitchtranslate-reconstructed';
                newSpan.style.fontWeight = 'bold';
                newSpan.style.color = color;
                newSpan.style.marginLeft = '4px';
                chatLine.appendChild(newSpan);
            }
        }

        /////////////////////////////////
        // Watching for new Chat Lines //
        /////////////////////////////////

        //let hasSeenWelcome = GM_getValue('history', false);
        let hasSeenWelcome = true;

        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(newNode => {
                    if (newNode.nodeType !== Node.ELEMENT_NODE) return;

                    if (hasSeenWelcome === false && newNode.querySelector('div.live-message-separator-line__hr')) { // New line check for twitch with or without BTTV
                        hasSeenWelcome = true;
                    }

                    if (hasSeenWelcome === false && document.querySelector('.seventv-message')) { // New line check for 7tv
                        hasSeenWelcome = true;
                    }

                    if(hasSeenWelcome) {
                        const chatLines = newNode.matches?.('[data-a-target="chat-line-message-body"], .seventv-chat-message-body') ? [newNode] : newNode.querySelectorAll?.('[data-a-target="chat-line-message-body"], .seventv-chat-message-body') || [];
                        chatLines.forEach(chatLine => {
                            if (!originals.has(chatLine)) {
                                const { extractedMessage, mentions, emotes } = extractText(chatLine);
                                handleTranslation(chatLine, extractedMessage, mentions, emotes);
                                originals.set(chatLine, extractedMessage);
                            }
                        });
                    }
                });
            });
        });

        observer.observe(chatContainer, { childList: true, subtree: true });
        console.log("[TwitchTranslate] Chat Observer initialized");
    }

    ////////////////////
    // Storage Maps   //
    ////////////////////

    const servers = {
        GoogleLegacy: {
            id: "Google (Legacy)",
            label: "API Key",
            placeholder: null,
            gmKey: null,
            link: null
        },
        DeepL: {
            id: "DeepL",
            label: "API Key",
            placeholder: "DeepL API Key",
            gmKey: "DeepLApiKey",
            link: "https://www.deepl.com/en/pro#developer"
        },
    };

    const languages = { //List of supported languages. If you know the language codes, you can add new entries to the list. Some language codes may not be supported by specific providers. Others may be supported but not present in the list.
        en: "English",
        af: "Afrikaans",
        sq: "Albanian",
        ar: "Arabic",
        hy: "Armenian",
        az: "Azerbaijani",
        be: "Belarusian",
        bn: "Bengali",
        bs: "Bosnian",
        bg: "Bulgarian",
        ca: "Catalan",
        "zh-cn": "Chinese (Simplified)",
        "zh-tw": "Chinese (Traditional)",
        hr: "Croatian",
        cs: "Czech",
        da: "Danish",
        nl: "Dutch",
        et: "Estonian",
        tl: "Filipino",
        fi: "Finnish",
        fr: "French",
        ka: "Georgian",
        de: "German",
        el: "Greek",
        ht: "Haitian Creole",
        haw: "Hawaiian",
        iw: "Hebrew",
        hi: "Hindi",
        hu: "Hungarian",
        is: "Icelandic",
        id: "Indonesian",
        ga: "Irish",
        it: "Italian",
        ja: "Japanese",
        jw: "Javanese",
        ko: "Korean",
        la: "Latin",
        lb: "Luxembourgish",
        lv: "Latvian",
        lt: "Lithuanian",
        mk: "Macedonian",
        mt: "Maltese",
        mn: "Mongolian",
        ne: "Nepali",
        no: "Norwegian",
        fa: "Persian",
        pl: "Polish",
        pt: "Portuguese",
        pa: "Punjabi",
        ro: "Romanian",
        ru: "Russian",
        sm: "Samoan",
        sr: "Serbian",
        sk: "Slovak",
        sl: "Slovenian",
        es: "Spanish",
        sv: "Swedish",
        th: "Thai",
        tr: "Turkish",
        uk: "Ukrainian",
        ur: "Urdu",
        uz: "Uzbek",
        vi: "Vietnamese",
        cy: "Welsh",
        yi: "Yiddish",
        zu: "Zulu"
    };
})();