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