Twitch Chat Translator

Automatically translates messages in Twitch chat to other languages.

Versión del día 14/01/2025. Echa un vistazo a la versión más reciente.

// ==UserScript==
// @name         Twitch Chat Translator
// @namespace    MrSelenix
// @version      0.4.1
// @description  Automatically translates messages in Twitch chat to other languages.
// @author       MrSelenix
// @match        https://www.twitch.tv/*
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function () {
  'use strict';

  // Store the original messages in a map (key: message element, value: original text)
  const originalMessages = new Map();

    let translationTextColor = GM_getValue('translationTextColor', '#808080');
    let textColor, iconColor, menuColor, hoverColor, smallText;
    let savedWords = GM_getValue('customWords', []);
    let blockEmotes = GM_getValue('blockEmotes', false);
    let emoteRegex = new RegExp('\\n.*', 'gs');
    let repeatedCharacterLimit = 4;

// Darkmode Colours
const darkMode_Text = '#ffffffff';
const darkMode_Icon = '#d3d3d3ff';
const darkMode_Menu = '#252530ff';
const darkMode_Hover = '#53535f7a';
const darkMode_smallText = '#b0b0b0ff';

// Lightmode Colours
const lightMode_Text = '#000000ff';
const lightMode_Icon = '#595959ff';
const lightMode_Menu = '#d4d4d9ff';
const lightMode_Hover = '#d2d2d2ba';
const lightMode_smallText = '#727272';

// Function to check if the current theme is light
function isLightTheme() {
  return document.documentElement.classList.contains('tw-root--theme-light');
}

// Function to set colors based on the current theme
function setColors() {
  if (isLightTheme()) {
    textColor = lightMode_Text;
    iconColor = lightMode_Icon;
    menuColor = lightMode_Menu;
    hoverColor = lightMode_Hover;
    smallText = lightMode_smallText;
  } else {
    textColor = darkMode_Text;
    iconColor = darkMode_Icon;
    menuColor = darkMode_Menu;
    hoverColor = darkMode_Hover;
    smallText = darkMode_smallText;
  }
  // Apply the colors to your UI elements if necessary
  console.log('Theme updated:', { textColor, iconColor, menuColor, hoverColor, smallText });
}

// Initial setup of colors
setColors();

function refreshButton() {
    const existingButton = document.getElementById('toggle-settings');

    if (existingButton) {
    console.log("toggle-settings located... Refreshing");
     existingButton.remove();
     setTimeout(addButton, 150);
    }
    else {
        console.log("toggle-settings not located");
        addButton();
    }
}

// Function to observe changes in the <html> class attribute
function observeThemeChanges() {
  const targetNode = document.documentElement; // Ensure this is the <html> element
  const config = { attributes: true, attributeFilter: ['class'] }; // Monitor 'class' changes

  const callback = function(mutationsList) {
    for (let mutation of mutationsList) {
      if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
        console.log('Detected class change on <html>: ', mutation); // Debugging log
        setColors(); // Call setColors whenever the class changes
        refreshButton();
      }
    }
  };
  const themeObserver = new MutationObserver(callback);

  // Start observing the <html> element for class attribute changes
  themeObserver.observe(targetNode, config);

  console.log('themeObserver has been set up and is observing the <html> element.');
}

// Start observing theme changes
observeThemeChanges();



// Function to translate text using the Google Translate API
function translateText(text, destinationLanguage) {
  return new Promise((resolve, reject) => {
    const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${destinationLanguage}&dt=t&q=${encodeURIComponent(text)}`;
    fetch(url)
      .then(response => response.json())
      .then(data => {
        if (data && data[0] && data[0][0]) {
          const translation = data[0].map(item => item[0]).join(''); // Collect all translation parts
          const detectedLanguage = data[2]; // Detected language is at index 2

          // Sanitize both texts for comparison
          const sanitizedText = text.trim().toLowerCase();
          const sanitizedTranslation = translation.trim().toLowerCase();
          const translationFull = translation.replace(/(\w)\1{3,}/gi, (match, p1) => p1.repeat(`${repeatedCharacterLimit}`)); //Restrict repeated characters to a maximimum of the set limit after translating (Limit=4, "Woooooooooo" => "Woooo")
           // console.log(savedWords);
           //console.log(`Text=${text},Translation=${translationFull}`);
           // console.log(savedWords.includes(text));
          // Only resolve if the translation is different from the original text
            if (text = translationFull || levenshteinDistance(`${sanitizedText}`, `${sanitizedTranslation}`) <= 3) {
                resolve(null);
            } else {
                resolve({ translationFull, detectedLanguage });
            }
        } else {
          reject('Translation error: Invalid response format');
        }
      })
      .catch(error => reject(error));
  });
}

// Function to translate a message
function translateMessage(messageElement, fullMessage) {
  // Get the selected language from GM_getValue
  const targetLanguage = GM_getValue('selectedLanguage', 'en');

  return translateText(fullMessage, targetLanguage)
    .then(result => {
      if (result === null) {
        return; // No translation needed
      }

      const { translation, detectedLanguage } = result;

      // Check if the detected language is supported in our languageMap
      if (detectedLanguage in languageMap && detectedLanguage !== targetLanguage) {
        // Convert detected language code to full language name
        const fullLanguageName = languageMap[detectedLanguage];

        const translationHTML = `<span style="color: ${translationTextColor}; font-weight: bold;"> (Translated from "${fullLanguageName}": ${translation})</span>`;

        const messageBody = messageElement.querySelector('[data-a-target="chat-line-message-body"]');
       // console.log(`Message Body: ${messageBody}`);

        if (messageBody) {
          let lastTextFragment = messageBody.querySelector('span.text-fragment:last-child');
          if (!lastTextFragment) {
            // If no span.text-fragment exists at the end, create one
            lastTextFragment = document.createElement('span');
            lastTextFragment.className = 'text-fragment';
            lastTextFragment.textContent = ''; // Empty content
            messageBody.appendChild(lastTextFragment);
          }

          // Append the translation
          lastTextFragment.insertAdjacentHTML('beforeend', translationHTML);
         // console.log(`Translation appended to: ${lastTextFragment}`);
        }
      }
    })
    .catch(error => {
      console.error("Error translating message:", error);
    });
}

function addButton() {

  // Check if the button has already been added
  if (document.getElementById('toggle-settings')) return;

  // Create a container div with a class similar to Twitch's buttons
  const settingsButton = document.createElement('div');
  settingsButton.className = 'Layout-sc-1xcs6mc-0 WmSnIX';

  // Create the button
  const newButton = document.createElement('button');
  newButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="${iconColor}" 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>`;
  newButton.classList.add('btn', 'btn-primary');
  newButton.id = 'toggle-settings';
  newButton.title = "Translation Settings";
  newButton.style.cssText = `
    margin-right: 5px;
    background-color: transparent;
    border-style: none;
    padding-left: 3px;
    padding-right: 5px;
    border-radius: 4px;
    transition: background-color 0.3s ease, border-radius 0.3s ease;
  `;

  // Define hover effect
  newButton.onmouseover = function() {
      this.style.backgroundColor = `${hoverColor}`;
  };
  newButton.onmouseout = function() {
      this.style.backgroundColor = 'transparent';
  };


  // Declare closeMenuOnOutsideClick at the function level
  let closeMenuOnOutsideClick;

  newButton.addEventListener('click', () => {
    let settingsMenu = document.getElementById('settings-menu');

    if (!settingsMenu) {
      settingsMenu = document.createElement('div');
      settingsMenu.id = 'settings-menu';
      settingsMenu.style.cssText = `
        position: absolute;
        width: 280px;
        height: 350px;
        background-color: ${menuColor};
        display: none;
        z-index: 1000;
        bottom: 40px;
        right: 70px;
        border-radius: 6px;
        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
        padding: 10px;
        color: ${textColor};
        font-size: 14px;
        cursor: default;
      `;

      // Add the title to the settings menu
      const title = document.createElement('h2');
      title.textContent = "Translation Settings";
      title.style.cssText = `
        margin: 0 0 10px 0;
        font-size: 20px;
        text-align: center;
        color: ${textColor};
      `;
      settingsMenu.appendChild(title);

        // Add a colored line for contrast
        const divider = document.createElement('hr');
        divider.style.cssText = `
        width: 100%;
        height: 2px;
        background-color: #55556e;
        border: none;
        margin: 5px 0;
`;
        settingsMenu.appendChild(divider);

      // Add checkbox for enabling translations
      const checkboxContainer = document.createElement('div');
      checkboxContainer.style.cssText = `
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin: 5px 0;
      `;

        const checkboxLabel = document.createElement('label');
        checkboxLabel.innerHTML = `
        <span style="font-size: 18px; font-family: 'Helvetica', sans-serif;">Enable Translations</span><br>
        <span style="font-size: 12px; font-family: 'Helvetica', sans-serif; color: ${smallText};">(New Messages Only)</span>
        `;
        checkboxLabel.style.marginRight = '10px'; // Space between label and checkbox

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        // Retrieve the saved state from GM_getValue or default to true
        checkbox.checked = GM_getValue('translationsEnabled', true);
        checkbox.style.cursor = 'pointer';

        // Save the state when it changes
        checkbox.addEventListener('change', function() {
            GM_setValue('translationsEnabled', this.checked);
            let translationsEnabled = GM_getValue('translationsEnabled');
            //console.log(`TranslationsEnabled=${translationsEnabled}`);
            //console.log("---");
        });

        //3rd Party Emote Blocker
              const emoteCheckboxContainer = document.createElement('div');
      emoteCheckboxContainer.style.cssText = `
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin: 5px 0;
      `;

        const emoteCheckboxLabel = document.createElement('label');
        emoteCheckboxLabel.innerHTML = `
        <span style="font-size: 14px; font-family: 'Helvetica', sans-serif;">Hide 3rd-party Emotes from translations</span><br>
        `;
        emoteCheckboxLabel.style.marginRight = '10px'; // Space between label and checkbox

        const emoteCheckbox = document.createElement('input');
        emoteCheckbox.type = 'checkbox';
        // Retrieve the saved state from GM_getValue or default to false
        emoteCheckbox.checked = GM_getValue('blockEmotes', false);
        emoteCheckbox.style.cursor = 'pointer';

        // Save the state when it changes
        emoteCheckbox.addEventListener('change', function() {
            GM_setValue('blockEmotes', this.checked);
            let blockEmotes = GM_getValue('blockEmotes');
            console.log(`blockEmotes=${blockEmotes}`);
            console.log("---");
        });

        checkboxContainer.appendChild(checkboxLabel);
        checkboxContainer.appendChild(checkbox);
        settingsMenu.appendChild(checkboxContainer);
        emoteCheckboxContainer.appendChild(emoteCheckboxLabel);
        emoteCheckboxContainer.appendChild(emoteCheckbox);
        settingsMenu.appendChild(emoteCheckboxContainer);

              // Add "Translate to..." label and dropdown
      const translationTargetContainer = document.createElement('div');
      translationTargetContainer.style.cssText = `
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin: 10px 0;
      `;

      const translationTargetLabel = document.createElement('label');
      translationTargetLabel.textContent = "Translate to...";
      translationTargetLabel.style.marginRight = '10px'; // Space between label and dropdown

        const languageSelect = document.createElement('select');
        languageSelect.style.cssText = `
        width: 150px;
        cursor: pointer;
        background-color: #ffffff;
        color: black;
        border: 1px solid #55556e;
        padding: 2px;
        border-radius: 3px;
        `;

      // Populate dropdown with languages
      Object.entries(languageMap).forEach(([code, name]) => {
        const option = document.createElement('option');
        option.value = code;
        option.textContent = name;
        option.style.cursor = 'pointer';
        languageSelect.appendChild(option);
      });

      // Set the selected language based on saved value or default to English
      languageSelect.value = GM_getValue('selectedLanguage', 'en');

      // Save the selected language when it changes
      languageSelect.addEventListener('change', function() {
        GM_setValue('selectedLanguage', this.value);
      });

      translationTargetContainer.appendChild(translationTargetLabel);
      translationTargetContainer.appendChild(languageSelect);
      settingsMenu.appendChild(translationTargetContainer);

// Add "Text Colour" label and input
const textColorContainer = document.createElement('div');
textColorContainer.style.cssText = `
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin: 10px 0;
`;

const textColorLabel = document.createElement('label');
textColorLabel.innerHTML = `
  <span style="font-size: 14px; font-family: 'Helvetica', sans-serif;">Text Color</span><br>
  <span style="font-size: 11px; font-family: 'Helvetica', sans-serif; color: ${smallText};">Default #808080</span>
`;
textColorLabel.style.marginRight = '10px'; // Space between label and input

const textColorInput = document.createElement('input');
textColorInput.type = 'text';
textColorInput.placeholder = 'HEX color';
textColorInput.style.cssText = `
  width: 80px; // Adjust width as needed
  color: black;
  background-color: #ffffff;
  border: 1px solid #55556e;
  border-radius: 4px;
  padding: 5px;
`;

// Create the color picker input, now directly in place of colorPreview
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.style.cssText = `
  width: 22px;
  height: 20px;
  margin-right: 5px;
  cursor: pointer;
`;
colorPicker.value = GM_getValue('translationTextColor', '#808080');

// Set initial value from saved state or default to #808080 (gray)
const initialColor = GM_getValue('translationTextColor', '#808080');
textColorInput.value = initialColor;
colorPicker.value = initialColor; // Set color picker to match text input

// Function to update color when changed
function updateColor(color) {
  textColorInput.value = color;
  translationTextColor = color;
  GM_setValue('translationTextColor', color);
}

// Event listener for the text input
textColorInput.addEventListener('input', function() {
  let hexColor = this.value;
  hexColor = hexColor.replace('#', '');
  if (/^[0-9A-F]{6}$/i.test(hexColor)) {
    const fullHex = '#' + hexColor;
    this.style.borderColor = '#55556e';
    updateColor(fullHex);
    colorPicker.value = fullHex; // Sync with color picker
  } else {
    this.style.borderColor = 'red';
  }
});
        // Event listener for "Enter" key to lose focus on the text box
textColorInput.addEventListener('keydown', function(event) {
  if (event.key === 'Enter') {
    this.blur(); // Remove focus from the text input
  }
});

// Event listener for color picker change
colorPicker.addEventListener('change', function() {
  updateColor(this.value);
});

// Container for both input and color picker
const inputWithPreview = document.createElement('div');
inputWithPreview.style.cssText = `
  display: flex;
  align-items: center;
`;

        // Add new container for text entry field and label
const textEntryContainer = document.createElement('div');
textEntryContainer.style.cssText = `
  display: flex;
  flex-direction: column;
  margin: 10px 0;
`;

const textEntryLabel = document.createElement('label');
textEntryLabel.innerHTML = `
  <span style="font-size: 14px; font-family: 'Helvetica', sans-serif;">Blocked Words/Phrases</span><br>
  <span style="font-size: 12px; font-family: 'Helvetica', sans-serif; color: ${smallText};">Remove words/emotes from messages before translating. Not case-Sensitive</span>
`;
textEntryLabel.style.marginBottom = '5px'; // Space between label and input

// Create the text entry input field
const textEntryInput = document.createElement('input');
textEntryInput.type = 'text';
textEntryInput.placeholder = 'OMEGALUL, Sadge, NOPERS';
textEntryInput.style.cssText = `
  width: 100%;
  color: black;
  background-color: #ffffff;
  border: 1px solid #55556e;
  border-radius: 4px;
  padding: 5px;
`;

// Retrieve and set the saved value for this text field (if any), ensure it's an array
const savedWords = GM_getValue('customWords', []);
textEntryInput.value = savedWords.join(', '); // Convert the array to a string if there are saved words

// Event listener to update the saved array in GM_setValue whenever the contents change
textEntryInput.addEventListener('input', function() {
  // Split the input into an array by commas and remove any extra spaces
  const wordsArray = this.value.split(',').map(word => word.trim()).filter(word => word.length > 0);

  // Save the array in GM_setValue
  GM_setValue('customWords', wordsArray);
});

        textEntryInput.addEventListener('keydown', function(event) {
        if (event.keyCode === 32) {
          // Prevent the event from bubbling up but still allow the space to be typed
          event.stopPropagation();

          // We need to manually insert the space since stopPropagation might interfere
          const start = this.selectionStart;
          const end = this.selectionEnd;
          this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
          this.selectionStart = this.selectionEnd = start + 1;

          // Prevent default only to stop any potential navigation or other default behavior
          event.preventDefault();
        }
      });
        textEntryInput.addEventListener('keydown', function(event) {
  if (event.key === 'Enter') {
    this.blur(); // Remove focus from the text input
  }
});

        textEntryContainer.appendChild(textEntryLabel);
        textEntryContainer.appendChild(textEntryInput);
        inputWithPreview.appendChild(colorPicker); // Color picker now in place of the preview
        inputWithPreview.appendChild(textColorInput);

        textColorContainer.appendChild(textColorLabel);
        textColorContainer.appendChild(inputWithPreview);

        settingsMenu.appendChild(textColorContainer);
        settingsMenu.appendChild(textEntryContainer);


      newButton.appendChild(settingsMenu);

      // Define closeMenuOnOutsideClick here with access to settingsMenu
      closeMenuOnOutsideClick = function(event) {
        if (!newButton.contains(event.target) && !settingsMenu.contains(event.target)) {
          settingsMenu.style.display = 'none';
          document.removeEventListener('click', closeMenuOnOutsideClick);
        }
      };

      settingsMenu.addEventListener('click', function(e) {
        e.stopPropagation(); // Prevent click events from reaching document
      });
    }

    // Toggle the display of the settings menu
    settingsMenu.style.display = settingsMenu.style.display === 'none' ? 'block' : 'none';

    // Manage the event listener based on visibility
    if (settingsMenu.style.display === 'block') {
      document.addEventListener('click', closeMenuOnOutsideClick);
    } else {
      document.removeEventListener('click', closeMenuOnOutsideClick);
    }
  });

  // Append the button to the new container
  settingsButton.appendChild(newButton);

  // Find the specific container div
  const twitchButtonContainer = document.querySelector('.Layout-sc-1xcs6mc-0.kEPLoI'); //Find general button container below chat
  if (twitchButtonContainer) { //If it exists...
    const chatButton = twitchButtonContainer.querySelector('.Layout-sc-1xcs6mc-0.jOVwMQ'); //Find sendChat button container
    if (chatButton) { //If it exists...
      twitchButtonContainer.insertBefore(settingsButton, chatButton);
        console.log("Located the chat button. Appending Translation Settings before it");
    } else {
      twitchButtonContainer.appendChild(settingsButton);
        console.log("Did not locate chat button, appended Translation Settings to main button container instead");
    }
  }
}
  // Variable to store the MutationObserver reference
  let observer = null;
  let modViewObserver = null;

    function updateWords() {
        savedWords = GM_getValue('customWords', []);
    }

    function updateBlockedEmotes() {
        blockEmotes = GM_getValue('blockEmotes');
    }

function startTranslation() {
  const chatContainer = document.querySelector('[data-test-selector="chat-scrollable-area__message-container"]');
  if (chatContainer) {
    // Translate the initial set of chat messages, but now it checks if translations are enabled

    observer = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(newNode => {
          if (newNode.nodeType === Node.ELEMENT_NODE) {
            const newMessageElement = newNode.querySelector('span.text-fragment');

            if (newMessageElement) {
              // Collect all text fragments for the message
              const messageContainer = newNode.closest('[data-a-target="chat-line-message-body"]') || newNode;

              if (messageContainer && !originalMessages.has(messageContainer)) {
                const textFragments = Array.from(messageContainer.querySelectorAll('span.text-fragment'));
                if (textFragments.length > 0) {
                    const sanitizedArray = textFragments.map(fragment => {
                        updateBlockedEmotes();
                        if (blockEmotes){
                            let sanitizedText = fragment.textContent.replace(/[a-zA-Z]+\n.*/gs, '').trim(); // Removes all emote data from the end of the text-fragment
                            return sanitizedText;
                        } else {
                            let sanitizedText = fragment.textContent.replace(/\n.*/gs, '').trim(); // Removes all emote data from the end of the text-fragment
                            return sanitizedText;
                        }
                    });

                    let halfMessage = sanitizedArray.join(' ');

                  // Store the original message text
                  originalMessages.set(messageContainer, halfMessage);
                    updateWords();
                    //remove savedWords from final message before translations
                    savedWords.forEach(str => {
                        if (str.trim() !== '') {
                            let regex = new RegExp(str, 'gi');
                            halfMessage = halfMessage.replace(regex, '');
                        }
                    });

                  // Check if translations are enabled before translating new messages
                  if (GM_getValue('translationsEnabled', true)) {
                      updateWords();
                      let fullMessage = halfMessage.replace(/(\w)\1{3,}/gi, (match, p1) => p1.repeat(4)); //Restrict repeated characters to a maximimum of the set limit  before translating (Limit=4, "Woooooooooo" => "Woooo")
                      //console.log(`Full Message: ${fullMessage}`);
                    translateMessage(messageContainer, fullMessage);
                  }
                }
              }
            }
          }
        });
      });
    });

    const observerConfig = { childList: true, subtree: true };
    observer.observe(chatContainer, observerConfig);
  } else {
    setTimeout(startTranslation, 500); // Retry if chat container isn't found yet
  }
}

function levenshteinDistance(str1, str2) {
  const len1 = str1.length;
  const len2 = str2.length;

  // Create a 2D array (matrix) with dimensions (len1+1) x (len2+1)
  const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));

  // Initialize the matrix
  for (let i = 0; i <= len1; i++) {
    dp[i][0] = i; // Distance of any first string to an empty second string
  }
  for (let j = 0; j <= len2; j++) {
    dp[0][j] = j; // Distance of any second string to an empty first string
  }

  // Compute the Levenshtein distance
  for (let i = 1; i <= len1; i++) {
    for (let j = 1; j <= len2; j++) {
      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; // No cost if characters are the same
      dp[i][j] = Math.min(
        dp[i - 1][j] + 1, // Deletion
        dp[i][j - 1] + 1, // Insertion
        dp[i - 1][j - 1] + cost // Substitution
      );
    }
  }

  // The value in the bottom-right corner is the Levenshtein distance
  return dp[len1][len2];
}

  function setupModViewObserver() {
    modViewObserver = new MutationObserver(() => {
      const chatContainer = document.querySelector('[data-test-selector="chat-scrollable-area__message-container"]');
      if (chatContainer && !observer) {
        startTranslation();
      }
      // Also add the button when Mod View changes
      setTimeout(addButton, 150);
    });

    modViewObserver.observe(document.body, { childList: true, subtree: true });
  }

  window.addEventListener('load', () => {
    setupModViewObserver();
    startTranslation();
    setTimeout(addButton, 150); // Add button on initial load
  });
})();

  //Language Map for all supported languages
  const languageMap = {
  "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",
  "en": "English",
  "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"
  // This list does not include all languages supported by GoogleAPIs and might include languages that are no longer supported.
};
长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元