Greasy Fork镜像 is available in English.

Twitch Chat Translator

Automatically translates messages in Twitch chat to other languages.

// ==UserScript==
// @name         Twitch Chat Translator
// @namespace    MrSelenix
// @version      0.5.2
// @description  Automatically translates messages in Twitch chat to other languages.
// @author       MrSelenix
// @match        https://www.twitch.tv/*
// @license      GPL-3.0-or-later
// @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, expButton, expButton_hover;
    let savedWords = GM_getValue('customWords', []);
    let blockEmotes = GM_getValue('blockEmotes', true);
    let emoteRegex = new RegExp('\\n.*', 'gs');
    let repeatedCharacterLimit = GM_getValue('characterLimit', 4);
    let levDist_UserDefined = GM_getValue('differenceSensitivity', 5);
    let percent_UserDefined = GM_getValue('languagePercent', 0.75);
    let experimentalFeatures = GM_getValue('experimentalFeatures', false);

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

// Lightmode Colours
const lightMode_Text = '#000000ff';
const lightMode_Icon = '#595959ff';
const lightMode_Menu = '#d4d4d9ff';
const lightMode_expButton = '#acacaeff';
const lightMode_Hover = '#d2d2d2ba';
const lightMode_smallText = '#727272';
const lightMode_expHover = '#8c8c8eff';

// 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;
    expButton = lightMode_expButton;
    hoverColor = lightMode_Hover;
    smallText = lightMode_smallText;
    expButton_hover = lightMode_expHover;
  } else {
    textColor = darkMode_Text;
    iconColor = darkMode_Icon;
    menuColor = darkMode_Menu;
    expButton = darkMode_expButton;
    hoverColor = darkMode_Hover;
    smallText = darkMode_smallText;
    expButton_hover = darkMode_expHover;
  }
  // Apply the colors to your UI elements if necessary
  console.log('Theme updated:', { textColor, iconColor, menuColor, hoverColor, smallText, expButton, expButton_hover });
}

// 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)}`;

    const detectLanguage = (word) => {
      return fetch(`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${destinationLanguage}&dt=t&q=${encodeURIComponent(word)}`)
        .then(response => response.json())
        .then(data => data[2]); // Returns detected language
    };

    let matchPercentage = 0; // Default value when experimentalFeatures is off

    function continueTranslation() {
      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
            const sanitizedText = text.trim().toLowerCase(); // Sanitize both texts for comparison
            const sanitizedTranslation = translation.trim().toLowerCase();
            let regex = new RegExp(`(\\w)\\1{${repeatedCharacterLimit-1},}`, "gi");
            let translationFull = translation.replace(regex, (match) => match.slice(0, repeatedCharacterLimit)); //Restrict repeated characters to a maximimum of the set limit after translating (Limit=4, "Woooooooooo" => "Woooo")

            // Only resolve if the translation is different from the original text
            if (text === translationFull || levenshteinDistance(sanitizedText, sanitizedTranslation) <= levDist_UserDefined || matchPercentage >= percent_UserDefined) {
              resolve(null);
                //console.log(`${matchPercentage} of ${percent_UserDefined} || ${levenshteinDistance(sanitizedText, sanitizedTranslation)} of ${levDist_UserDefined} || Nulled on Ln137`);
                return;
            } else if (experimentalFeatures) {
              // Check if translationFull matches the destination language when experimentalFeatures is on
              detectLanguage(translationFull)
                .then(finalLang => {
                  if (finalLang === destinationLanguage) {
                    resolve({ translation: translationFull, detectedLanguage });
                      //console.log(`${matchPercentage} of ${percent_UserDefined} || ${levenshteinDistance(sanitizedText, sanitizedTranslation)} of ${levDist_UserDefined} || Translated on experimental`);
                      return;
                  } else {
                    resolve(null); // Translation didn’t match the target language
                      console.log(`Language of translated text was not detected as ${detectedLanguage}`);
                  }
                })
                .catch(error => {
                  console.error('Language detection failed for translationFull:', error);
                  resolve({ translation: translationFull, detectedLanguage }); // Fallback to original behavior
                });
            } else {
              // If experimentalFeatures is off, resolve directly without extra check
              resolve({ translation: translationFull, detectedLanguage });
               // console.log(`${matchPercentage} of ${percent_UserDefined} || ${levenshteinDistance(sanitizedText, sanitizedTranslation)} of ${levDist_UserDefined} || Translated without experimental`);
            }
          } else {
            reject('Translation error: Invalid response format');
          }
        })
        .catch(error => reject(error));
    }

    // Only calculate matchPercentage if experimentalFeatures is enabled
    if (experimentalFeatures) {
      const words = text.split(/\s+/).filter(word => word.length > 1);
      Promise.all(words.map(word => detectLanguage(word)))
        .then(languages => {
          const matchingLanguages = languages.filter(lang => lang === destinationLanguage).length;
          matchPercentage = words.length > 0 ? matchingLanguages / words.length : 0;
          continueTranslation();
        })
        .catch(error => reject(error));
    } else {
      continueTranslation();
    }
  });
}

// 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 close handlers at the function level
  let closeMenuOnOutsideClick;
  let closeExperimentalMenuOnOutsideClick;

  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: 390px;
        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);

      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';
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = GM_getValue('translationsEnabled', true);
      checkbox.style.cursor = 'pointer';
      checkbox.addEventListener('change', function() {
        GM_setValue('translationsEnabled', this.checked);
      });
      checkboxContainer.appendChild(checkboxLabel);
      checkboxContainer.appendChild(checkbox);
      settingsMenu.appendChild(checkboxContainer);

      // 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';
      const emoteCheckbox = document.createElement('input');
      emoteCheckbox.type = 'checkbox';
      emoteCheckbox.checked = GM_getValue('blockEmotes', true);
      emoteCheckbox.style.cursor = 'pointer';
      emoteCheckbox.addEventListener('change', function() {
        GM_setValue('blockEmotes', this.checked);
      });
      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';
      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;
      `;
      Object.entries(languageMap).forEach(([code, name]) => {
        const option = document.createElement('option');
        option.value = code;
        option.textContent = name;
        option.style.cursor = 'pointer';
        languageSelect.appendChild(option);
      });
      languageSelect.value = GM_getValue('selectedLanguage', 'en');
      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';
      const textColorInput = document.createElement('input');
      textColorInput.type = 'text';
      textColorInput.placeholder = 'HEX color';
      textColorInput.style.cssText = `
        width: 80px;
        color: black;
        background-color: #ffffff;
        border: 1px solid #55556e;
        border-radius: 4px;
        padding: 5px;
      `;
      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');
      const initialColor = GM_getValue('translationTextColor', '#808080');
      textColorInput.value = initialColor;
      colorPicker.value = initialColor;

      function updateColor(color) {
        textColorInput.value = color;
        translationTextColor = color;
        GM_setValue('translationTextColor', color);
      }

      textColorInput.addEventListener('input', function() {
        let hexColor = this.value.replace('#', '');
        if (/^[0-9A-F]{6}$/i.test(hexColor)) {
          const fullHex = '#' + hexColor;
          this.style.borderColor = '#55556e';
          updateColor(fullHex);
          colorPicker.value = fullHex;
        } else {
          this.style.borderColor = 'red';
        }
      });
      textColorInput.addEventListener('keydown', function(event) {
        if (event.key === 'Enter') this.blur();
      });
      colorPicker.addEventListener('change', function() {
        updateColor(this.value);
      });

      const inputWithPreview = document.createElement('div');
      inputWithPreview.style.cssText = `
        display: flex;
        align-items: center;
      `;
      inputWithPreview.appendChild(colorPicker);
      inputWithPreview.appendChild(textColorInput);
      textColorContainer.appendChild(textColorLabel);
      textColorContainer.appendChild(inputWithPreview);
      settingsMenu.appendChild(textColorContainer);

      // Add text entry field for blocked words
      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;">Blacklisted 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';
      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;
      `;
      const savedWords = GM_getValue('customWords', []);
      textEntryInput.value = savedWords.join(', ');
      textEntryInput.addEventListener('input', function() {
        const wordsArray = this.value.split(',').map(word => word.trim()).filter(word => word.length > 0);
        GM_setValue('customWords', wordsArray);
      });
      textEntryInput.addEventListener('keydown', function(event) {
        if (event.keyCode === 32) {
          event.stopPropagation();
          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;
          event.preventDefault();
        }
        if (event.key === 'Enter') this.blur();
      });
      textEntryContainer.appendChild(textEntryLabel);
      textEntryContainer.appendChild(textEntryInput);
      settingsMenu.appendChild(textEntryContainer);

      // Add "Experimental Settings" button
      const experimentalButtonContainer = document.createElement('div');
      experimentalButtonContainer.style.cssText = `
        display: flex;
        justify-content: center;
        margin-top: 10px;
      `;
      const experimentalButton = document.createElement('button');
      experimentalButton.textContent = 'Experimental Settings';
      experimentalButton.title = 'Experimental Settings';
      experimentalButton.style.cssText = `
        background-color: ${expButton};
        color: ${textColor};
        border: none;
        border-radius: 4px;
        padding: 5px 10px;
        cursor: pointer;
        transition: background-color 0.3s ease;
      `;
      experimentalButton.onmouseover = function() { this.style.backgroundColor = `${expButton_hover}`; };
      experimentalButton.onmouseout = function() { this.style.backgroundColor = `${expButton}`; };

      experimentalButton.addEventListener('click', () => {
        let experimentalMenu = document.getElementById('experimental-settings-menu');

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

          const expTitle = document.createElement('h2');
          expTitle.textContent = "Experimental Settings";
          expTitle.style.cssText = `
            margin: 0 0 10px 0;
            font-size: 20px;
            text-align: center;
            color: ${textColor};
          `;
          experimentalMenu.appendChild(expTitle);

          const expDivider = document.createElement('hr');
          expDivider.style.cssText = `
            width: 100%;
            height: 2px;
            background-color: #55556e;
            border: none;
            margin: 5px 0;
          `;
          experimentalMenu.appendChild(expDivider);

          // Checkbox for Enable Experimental Translations
          const expCheckboxContainer = document.createElement('div');
          expCheckboxContainer.style.cssText = `
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin: 10px 0;
          `;
          const expCheckboxLabel = document.createElement('label');
            expCheckboxLabel.title = 'Enables additional checks to prevent some inaccurate translations';
          expCheckboxLabel.innerHTML = `
            <span style="font-size: 16px; font-family: 'Helvetica', sans-serif;">Experimental Translations</span><br>
            <span style="font-size: 12px; font-family: 'Helvetica', sans-serif; color: ${smallText};">Enables extra steps to try mitigating incorrect translations</span>
          `;
          expCheckboxLabel.style.marginRight = '10px';
          const expCheckbox = document.createElement('input');
          expCheckbox.type = 'checkbox';
          expCheckbox.checked = GM_getValue('experimentalFeatures', false); // Default false
          expCheckbox.style.cursor = 'pointer';
          expCheckbox.addEventListener('change', function() {
            GM_setValue('experimentalFeatures', this.checked);
          });
          expCheckboxContainer.appendChild(expCheckboxLabel);
          expCheckboxContainer.appendChild(expCheckbox);
          experimentalMenu.appendChild(expCheckboxContainer);

          // Text box for Character Limits
          const charLimitContainer = document.createElement('div');
          charLimitContainer.style.cssText = `
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin: 10px 0;
          `;
          const charLimitLabel = document.createElement('label');
          charLimitLabel.innerHTML = `
            <span style="font-size: 16px; font-family: 'Helvetica', sans-serif;">Repeated Characters Limit</span><br>
            <span style="font-size: 12px; font-family: 'Helvetica', sans-serif; color: ${smallText};">Limit: 4 ➤ "Wooooooooo" becomes "Woooo" || (0 = Disable) (Default = 4)</span>
          `;
          charLimitLabel.style.marginRight = '10px';
          const charLimitInput = document.createElement('input');
          charLimitInput.type = 'text';
          charLimitInput.placeholder = '4';
          charLimitInput.style.cssText = `
            width: 50px;
            color: black;
            background-color: #ffffff;
            border: 1px solid #55556e;
            border-radius: 4px;
            padding: 5px;
            text-align: center;
          `;
          charLimitInput.value = GM_getValue('characterLimit', 4); // Default 4
          charLimitInput.addEventListener('input', function() {
            const value = this.value.replace(/[^0-9]/g, ''); // Only allow positive integers
            this.value = value;
            if (value === '' || parseInt(value) < 0) this.value = '0';
            GM_setValue('characterLimit', parseInt(this.value) || 0);
          });
          charLimitInput.addEventListener('keydown', function(event) {
            if (event.key === 'Enter') this.blur();
          });
          charLimitContainer.appendChild(charLimitLabel);
          charLimitContainer.appendChild(charLimitInput);
          experimentalMenu.appendChild(charLimitContainer);

          // Text box for Difference Sensitivity
          const diffSensitivityContainer = document.createElement('div');
          diffSensitivityContainer.style.cssText = `
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin: 10px 0;
          `;
          const diffSensitivityLabel = document.createElement('label');
          diffSensitivityLabel.innerHTML = `
            <span style="font-size: 14px; font-family: 'Helvetica', sans-serif;">Minimum Levenshtein Distance</span><br>
            <span style="font-size: 11px; font-family: 'Helvetica', sans-serif; color: ${smallText};">Higher numbers means more changes required. Higher = Less translations<br>(Default = 5)</span>
          `;
          diffSensitivityLabel.style.marginRight = '10px';
          const diffSensitivityInput = document.createElement('input');
          diffSensitivityInput.type = 'text';
          diffSensitivityInput.placeholder = '5';
          diffSensitivityInput.style.cssText = `
            width: 50px;
            color: black;
            background-color: #ffffff;
            border: 1px solid #55556e;
            border-radius: 4px;
            padding: 5px;
            text-align: center;
          `;
          diffSensitivityInput.value = GM_getValue('differenceSensitivity', 5); // Default 5
          diffSensitivityInput.addEventListener('input', function() {
            const value = this.value.replace(/[^0-9]/g, ''); // Only allow positive integers
            this.value = value;
            if (value === '' || parseInt(value) < 0) this.value = '0';
            GM_setValue('differenceSensitivity', parseInt(this.value) || 0);
          });
          diffSensitivityInput.addEventListener('keydown', function(event) {
            if (event.key === 'Enter') this.blur();
          });
          diffSensitivityContainer.appendChild(diffSensitivityLabel);
          diffSensitivityContainer.appendChild(diffSensitivityInput);
          experimentalMenu.appendChild(diffSensitivityContainer);

          // Slider for Language Percent
          const langPercentContainer = document.createElement('div');
          langPercentContainer.style.cssText = `
            display: flex;
            flex-direction: column;
            margin: 10px 0;
          `;
          const langPercentLabel = document.createElement('label');
          langPercentLabel.innerHTML = `
            <span style="font-size: 16px; font-family: 'Helvetica', sans-serif;">Language Detection</span><br>
            <span style="font-size: 11px; font-family: 'Helvetica', sans-serif; color: ${smallText};">REQUIRES EXPERIMENTAL TRANSLATIONS<br>If more than % of the original message is in the Target Language, skip translation. Lower = Less translations. (Default 75%)</span>
          `;
          langPercentLabel.style.marginBottom = '5px';
          const langPercentSlider = document.createElement('input');
          langPercentSlider.type = 'range';
          langPercentSlider.min = '0';
          langPercentSlider.max = '1';
          langPercentSlider.step = '0.01'; // Granularity of 0.01 (1%)
          langPercentSlider.value = GM_getValue('languagePercent', 0.75); // Default 0.5 (50%)
          langPercentSlider.style.cssText = `
            width: 100%;
            cursor: pointer;
          `;
          const langPercentValue = document.createElement('span');
          langPercentValue.textContent = `${(langPercentSlider.value * 100).toFixed(0)}%`;
          langPercentValue.style.cssText = `
            font-size: 12px;
            color: ${smallText};
            text-align: center;
            margin-top: 5px;
          `;
          langPercentSlider.addEventListener('input', function() {
            GM_setValue('languagePercent', parseFloat(this.value));
            langPercentValue.textContent = `${(this.value * 100).toFixed(0)}%`;
          });
          langPercentContainer.appendChild(langPercentLabel);
          langPercentContainer.appendChild(langPercentSlider);
          langPercentContainer.appendChild(langPercentValue);
          experimentalMenu.appendChild(langPercentContainer);

          experimentalButton.appendChild(experimentalMenu);

          experimentalMenu.addEventListener('click', function(e) {
            e.stopPropagation();
          });

          closeExperimentalMenuOnOutsideClick = function(event) {
            if (!experimentalButton.contains(event.target) && !experimentalMenu.contains(event.target)) {
              experimentalMenu.style.display = 'none';
              document.removeEventListener('click', closeExperimentalMenuOnOutsideClick);
            }
          };
        }

        experimentalMenu.style.display = experimentalMenu.style.display === 'none' ? 'block' : 'none';
        if (experimentalMenu.style.display === 'block') {
          document.addEventListener('click', closeExperimentalMenuOnOutsideClick);
          settingsMenu.style.display = 'block';
        } else {
          document.removeEventListener('click', closeExperimentalMenuOnOutsideClick);
        }
      });

      experimentalButtonContainer.appendChild(experimentalButton);
      settingsMenu.appendChild(experimentalButtonContainer);

      newButton.appendChild(settingsMenu);

      closeMenuOnOutsideClick = function(event) {
        if (!newButton.contains(event.target) && !settingsMenu.contains(event.target)) {
          settingsMenu.style.display = 'none';
          document.removeEventListener('click', closeMenuOnOutsideClick);
          const experimentalMenu = document.getElementById('experimental-settings-menu');
          if (experimentalMenu && experimentalMenu.style.display === 'block') {
            experimentalMenu.style.display = 'none';
            document.removeEventListener('click', closeExperimentalMenuOnOutsideClick);
          }
        }
      };

      settingsMenu.addEventListener('click', function(e) { e.stopPropagation(); });
    }

    settingsMenu.style.display = settingsMenu.style.display === 'none' ? 'block' : 'none';
    if (settingsMenu.style.display === 'block') {
      document.addEventListener('click', closeMenuOnOutsideClick);
    } else {
      document.removeEventListener('click', closeMenuOnOutsideClick);
      const experimentalMenu = document.getElementById('experimental-settings-menu');
      if (experimentalMenu && experimentalMenu.style.display === 'block') {
        experimentalMenu.style.display = 'none';
        document.removeEventListener('click', closeExperimentalMenuOnOutsideClick);
      }
    }
  });

  settingsButton.appendChild(newButton);

  const twitchButtonContainer = document.querySelector('.Layout-sc-1xcs6mc-0.kEPLoI');
  if (twitchButtonContainer) {
    const chatButton = twitchButtonContainer.querySelector('.Layout-sc-1xcs6mc-0.jOVwMQ');
    if (chatButton) {
      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 updateSettings() {
        savedWords = GM_getValue('customWords', []);
        blockEmotes = GM_getValue('blockEmotes', true);
        repeatedCharacterLimit = GM_getValue('characterLimit', 4);
        levDist_UserDefined = GM_getValue('differenceSensitivity', 5);
        percent_UserDefined = GM_getValue('languagePercent', 0.75);
        experimentalFeatures = GM_getValue('experimentalFeatures', false);
    }

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 => {
                        updateSettings();
                        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);
                    updateSettings();
                    //remove savedWords from final message before translations
                    savedWords.forEach(str => {
                        if (str.trim() !== '') {
                            let regex = new RegExp(`\\b${str}\\b`, 'gi');
                            halfMessage = halfMessage.replace(regex, '');
                        }
                    });

                  // Check if translations are enabled before translating new messages
                  if (GM_getValue('translationsEnabled', true)) {
                      updateSettings();
                      let fullMessage = halfMessage.replace(/(\w)\1{3,}/gi, (match, p1) => p1.repeat(`${repeatedCharacterLimit}`)); //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 元