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