Skip to content

Instantly share code, notes, and snippets.

@Fourmisain
Forked from kebien6020/live-tranlation.user.js
Last active May 9, 2021 18:23
Show Gist options
  • Save Fourmisain/bd5f0dfc00cb54b89a97badd9b4a250d to your computer and use it in GitHub Desktop.
Save Fourmisain/bd5f0dfc00cb54b89a97badd9b4a250d to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Streaming live translate
// @namespace youtube.com
// @version 0.9.9
// @author Fourmisain, u/BakuhatsuK, u/konokalahola
// @description Get streaming translation comments easily. Based on extension made by u/BakuhatsuK, based on extension made by u/konokalahola
// @match https://*.youtube.com/watch*
// @run-at document-start
// @noframes
// @updateURL https://gist.github.com/Fourmisain/bd5f0dfc00cb54b89a97badd9b4a250d/raw/6870a15f6b687dcc3b7a23abb5ca9bf69219032f/live-translation.user.js
// ==/UserScript==
// uses heroicons 'translate' and 'users' from tailwindlabs, licensed under MIT
// sourced from https://heroicons-viewer.nmyvsn.net/ with size 6
// changelog:
// v0.9.9
// - fix broken watch page detection due to removed 'is-watch-page' attribute
// v0.9.8
// - fix chat detection when changing between Top and Live chat replay
// - add DENY_REGEX, defaulting to filter 'noises'
// - add more general user matching via USER_MATCHLIST and VERIFIED_USER_MATCHLIST
// USER_MATCHLIST defaults to tanigox aka Yagoo (though there is an impostor with the same name)
// VERIFIED_USER_MATCHLIST only matches users when they are verified, defaults to Discord and Merryweather Comics
// - add user deny list
// - add titles to svg buttons
// v0.9.7
// - fix chat observer sometimes being registered twice due to await race condition in onChatFrameChange()
// fix the same potential issue with onPageChange() as well, just to be sure
// v0.9.6
// - fix buttons being positioned wrong when YouTube layout changes (when window size changes)
// - fix resetContainer() not removing button div
// - slight refactor:
// rename setupContainer -> setupUI
// rename resetContainer -> teardownUI
// rename updateContainer -> putMessage
// add updateUI() to simplify initializing/updating UI
// v0.9.5
// - fix "fix buttons disappearing behind new video recommendations layout" (YouTube's element ids are never unique...)
// v0.9.4
// - replace #chatframe observer with iframe 'load' event, fixes script crash when hiding chat replay
// v0.9.3
// - fix buttons disappearing behind new video recommendations layout
// v0.9.2
// - fix error on matching [en] in collaboration mode
// v0.9.1
// - option to always show owner
// v0.9 forked by Fourmisain
// - fix: script stops working because #chatframe url changes (which changes #item-offset)
// - match 'en:', 'eng:' too
// - option to match generic 'any:', useful for collaborations
// - option to always show moderators
// - option to always show Yagoo (tanigox)
// - add 'collaboration' button which switches the generic 'any:' matching
// - copy the actual comment instead of only the text (e.g. moderators will have blue names and emoji will appear)
// author colors won't work for super chats but they will show the donation amount
// - fix auto-scrolling not always working
// - filter chat mutations via tagName instead of classList (filter out 'engagement messages', slow mode notices and placeholders)
// - make updateContainer async to ensure it will add early messages
// - slightly adjusted margins
// - adjust log levels, always log errors
// - add license notice of used icons
// v0.8 last version of u/BakuhatsuK
(function() {
'use strict';
// by default match '[en]', '[eng]', 'en:', 'eng:'
const MSG_REGEX = /\[eng?]|^\s*eng?\s?:/i;
// in collaboration mode, match '[en]', '[eng]' and generic 'any:'
const SPEAKER_MSG_REGEX = /\[eng?]|^\s*([a-z]+)\s?:\s*([a-z]+)\s*/i;
// in collaboration mode, never match 'es:', 'esp:', 'ES:', 'ESP:'
const SPEAKER_DENYLIST = new Set(['es', 'esp']);
// never match 'noises' and certain user names
const DENY_REGEX = /\s+noises/i;
const USER_DENYLIST = new Set([]);
const MATCH_SUPERCHATS = true;
const ALWAYS_MATCH_MODERATORS = true;
const ALWAYS_MATCH_OWNER = true;
// match by username (which is non-uniqe) or additionally check if verified
const ALWAYS_MATCH_USERLIST = true;
const USER_MATCHLIST = new Set(['tanigox']);
const VERIFIED_USER_MATCHLIST = new Set(['Discord', 'Merryweather Comics']);
let showTranslation = false;
let matchSpeaker = false; // collaboration mode
const DEBUG = false;
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
const whenAvailable = async (selector, wnd = window) => {
while (true) {
const elem = wnd.document.querySelector(selector);
if (!elem) {
if (DEBUG) console.debug('whenAvailable: Could not yet find selector', selector);
await sleep(500);
} else {
if (DEBUG) console.debug('whenAvailable: Found selector', selector, elem);
return elem;
}
}
};
const insertAfter = (ref, node) => ref.parentNode.insertBefore(node, ref.nextSibling);
const divToSpan = (HTML) => {
let html = HTML;
const openExp = new RegExp(/<div[^>]*>/g);
let openMatch;
while ((openMatch = openExp.exec(html)) !== null)
html = html.substring(0, openMatch.index + 1) + 'span' + html.substring(openMatch.index + 4);
return html.replace(/<\/div>/g, '</span>');
};
let oldHref = document.location.href;
async function main() {
if (DEBUG) console.debug('main: Running on', window.location.href);
// Observe location change
const body = document.querySelector('body');
const appObserver = new MutationObserver(() => {
if (oldHref !== document.location.href) {
oldHref = document.location.href;
onPageChange();
}
});
appObserver.observe(body, { childList: true, subtree: true });
// Trigger initial setup as if page had just changed
await onPageChange();
}
let chatObserver;
let chatFrame;
let topLiveObserver;
let onPageChangeMutex = false;
async function onPageChange() {
try {
if (onPageChangeMutex) return; // Ignore multiple calls
onPageChangeMutex = true;
if (DEBUG) console.debug('onPageChange: Init');
if (chatObserver) {
if (DEBUG) console.debug('onPageChange: Disconnecting chat observer');
chatObserver.disconnect();
chatObserver = null;
}
if (chatFrame) {
if (DEBUG) console.debug('onPageChange: Removing chat frame \'load\' event');
chatFrame.removeEventListener('load', onChatFrameChange);
chatFrame = null;
}
if (window.location.pathname === '/watch') {
if (DEBUG) console.debug('onPageChange: Setting up UI elements');
teardownUI();
await setupUI();
if (DEBUG) console.debug('onPageChange: Adding chat frame \'load\' event');
chatFrame = await whenAvailable('#chatframe');
chatFrame.addEventListener('load', onChatFrameChange); // #chatframe url can change
// Call directly when we missed the first 'load' event
try {
if (chatFrame.contentDocument.readyState === 'complete' || chatFrame.contentDocument.readyState === 'loaded') {
await onChatFrameChange();
}
} catch (error) {
// May possibly be a DOMException
}
} else {
if (DEBUG) console.debug('onPageChange: Removing UI elements');
teardownUI();
}
} finally {
onPageChangeMutex = false;
}
}
let onChatFrameChangeMutex = false;
async function onChatFrameChange() {
try {
if (onChatFrameChangeMutex) return; // Ignore multiple calls
onChatFrameChangeMutex = true;
if (DEBUG) console.debug('onChatFrameChange: Init');
if (chatObserver) {
if (DEBUG) console.debug('onChatFrameChange: Disconnecting chat observer');
chatObserver.disconnect();
chatObserver = null;
}
if (topLiveObserver) {
if (DEBUG) console.debug('onChatFrameChange: Disconnecting Top/Live observer');
topLiveObserver.disconnect();
topLiveObserver = null;
}
try {
// This throws when iframe points to about:blank (chat replay was hidden)
const itemOffset = await whenAvailable('#item-offset', chatFrame.contentWindow);
if (DEBUG) console.debug('onChatFrameChange: Setting up chat observer');
chatObserver = new MutationObserver(onChatChange);
chatObserver.observe(itemOffset, { childList: true, subtree: true });
// Top/Live replay buttons change #item-list's child (which will change #item-offset)
if (DEBUG) console.debug('onChatFrameChange: Setting up Top/Live observer');
const chat = await whenAvailable('#item-list', chatFrame.contentWindow);
topLiveObserver = new MutationObserver(onChatFrameChange);
topLiveObserver.observe(chat, { childList: true });
} catch (error) {
if (DEBUG) console.debug('onChatFrameChange: can\'t access chat iframe', error);
}
// YouTube repositions #chat when resizing the window, so we need to reposition buttons as well
// Doing it here is a bit late, MutationObservers on #primary-inner and #secondary-inner might be better
if (DEBUG) console.debug('onChatFrameChange: Repositioning buttons');
const chat = await whenAvailable('#chat');
const button_container = document.getElementById('button_container');
button_container.remove();
insertAfter(chat, button_container);
} finally {
onChatFrameChangeMutex = false;
}
}
function onChatChange(mutations) {
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
const chatElems = [...mutation.addedNodes]
.filter((node) => node.nodeType === Node.ELEMENT_NODE)
.filter((elem) => elem.tagName === 'YT-LIVE-CHAT-TEXT-MESSAGE-RENDERER'
|| elem.tagName === 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER');
if (chatElems.length === 0) continue;
if (DEBUG) console.debug('onChatChange: New messages', chatElems.length);
for (const chatElem of chatElems) {
const isSuperChat = (chatElem.tagName === 'YT-LIVE-CHAT-PAID-MESSAGE-RENDERER');
onMessage(chatElem, isSuperChat);
}
}
}
function onMessage(chatElem, isSuperChat) {
if (isSuperChat && !MATCH_SUPERCHATS) return;
const msgElem = chatElem.querySelector('#message');
if (!msgElem) {
console.error('onMessage: Could not find message within chatElem', chatElem);
return;
}
const authorElem = chatElem.querySelector('#author-name');
if (!authorElem) {
console.error('onMessage: Could not find author within chatElem', chatElem);
return;
}
const purchElem = chatElem.querySelector('#purchase-amount');
if (isSuperChat && !purchElem) {
console.error('onMessage: Could not find purchase amount within super chat chatElem', chatElem);
return;
}
const badgeElem = chatElem.querySelector('yt-live-chat-author-badge-renderer'); // doesn't always exist
const msgText = msgElem.textContent;
const authorText = authorElem.textContent;
const isVerified = (badgeElem && badgeElem.type === 'verified');
const isATextMatch = (matchSpeaker ? SPEAKER_MSG_REGEX : MSG_REGEX).exec(msgText);
const isModeratorMatch = ALWAYS_MATCH_MODERATORS && authorElem.classList.contains('moderator');
const isOwnerMatch = ALWAYS_MATCH_OWNER && authorElem.classList.contains('owner');
const isAUserMatch = ALWAYS_MATCH_USERLIST &&
(USER_MATCHLIST.has(authorText) || (isVerified && VERIFIED_USER_MATCHLIST.has(authorText)));
// only continue when we have at least one match
if (!isATextMatch && !isAUserMatch && !isModeratorMatch && !isOwnerMatch)
return;
if (DENY_REGEX.test(msgText))
return;
if (USER_DENYLIST.has(authorText))
return;
if (matchSpeaker && isATextMatch && isATextMatch[1] && isATextMatch[2]) {
// part before and after ":"
const speaker = isATextMatch[1].toLowerCase();
const speakerText = isATextMatch[2];
if (SPEAKER_DENYLIST.has(speaker)) return;
if (speakerText.length <= 1) return; // try to filter out smileys like :D and :v
if (DEBUG) console.debug('onMessage: Matched speaker', speaker);
// TODO more closely match speaker, e.g. `speaker in SPEAKERS`
}
if (DEBUG) console.debug('onMessage: Matched message', msgText);
// for some inexplicable reason, copying this yt-live-chat-author-chip element's HTML
// will delete the author text???
// reproducible anywhere on YouTube, even in the inspector, outside of youtube copying works
// const authorWithBadgesElem = authorElem.parentElement;
// can't use authorElem.outerHTML for super chats because of the missing renderer
const author = (isSuperChat ? authorText : authorElem.outerHTML);
const msg = (isSuperChat ? divToSpan(msgElem.outerHTML) : msgElem.outerHTML);
const purch = (isSuperChat ? purchElem.innerText : null);
putMessage(author, msg, purch);
}
async function putMessage(author, message, purchaseAmount) {
const container = await whenAvailable('#translate_container');
const position = container.scrollHeight - container.offsetHeight - 8;
const shouldScroll = (container.scrollTop >= position);
container.insertAdjacentHTML('beforeend', `
<div style="margin-top: 7px;">
${author}${(purchaseAmount ? ` (${purchaseAmount}): ` : '')}&nbsp;&nbsp;${message}
</div>
`);
if (shouldScroll) container.scrollTo(0, container.scrollHeight);
}
function teardownUI() {
const buttonContainer = document.getElementById('button_container');
if (buttonContainer) buttonContainer.remove();
const translationContainer = document.getElementById('translation_container');
if (translationContainer) translationContainer.remove();
}
async function setupUI() {
const chat = await whenAvailable('#chat');
chat.style.marginBottom = '7px';
// setup buttons
const buttonContainer = document.createElement('DIV');
buttonContainer.id = 'button_container';
buttonContainer.style.display = 'flex';
buttonContainer.style.marginBottom = '7px';
buttonContainer.innerHTML = `
<svg id="translate_live_button" viewBox="0 0 20 20" width="20" height="20" class="adjustments w-6 h-6" style="vertical-align: middle; margin-left: 7px; cursor: pointer;">
<path fill-rule="evenodd" d="M7 2a1 1 0 011 1v1h3a1 1 0 110 2H9.578a18.87 18.87 0 01-1.724 4.78c.29.354.596.696.914 1.026a1 1 0 11-1.44 1.389c-.188-.196-.373-.396-.554-.6a19.098 19.098 0 01-3.107 3.567 1 1 0 01-1.334-1.49 17.087 17.087 0 003.13-3.733 18.992 18.992 0 01-1.487-2.494 1 1 0 111.79-.89c.234.47.489.928.764 1.372.417-.934.752-1.913.997-2.927H3a1 1 0 110-2h3V3a1 1 0 011-1zm6 6a1 1 0 01.894.553l2.991 5.982a.869.869 0 01.02.037l.99 1.98a1 1 0 11-1.79.895L15.383 16h-4.764l-.724 1.447a1 1 0 11-1.788-.894l.99-1.98.019-.038 2.99-5.982A1 1 0 0113 8zm-1.382 6h2.764L13 11.236 11.618 14z" clip-rule="evenodd"></path>
<title>Show translation container</title>
</svg>
<svg id="collaboration_button" viewBox="0 0 20 20" width="20" height="20" class="w-6 h-6" style="vertical-align: middle; margin-left: 4px; cursor: pointer;">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path>
<title>Enable collaboration mode</title>
</svg>
`;
// add buttons
insertAfter(chat, buttonContainer);
// add button events
const translationButton = document.getElementById('translate_live_button');
translationButton.style.display = 'block';
translationButton.onclick = function(e) {
e.preventDefault();
showTranslation = !showTranslation;
updateUI();
};
const collaborationButton = document.getElementById('collaboration_button');
collaborationButton.style.display = 'block';
collaborationButton.onclick = function(e) {
e.preventDefault();
matchSpeaker = !matchSpeaker;
updateUI();
};
// add translation container
const infoContents = document.getElementById('info-contents');
infoContents.insertAdjacentHTML('afterend', '<div id="translate_container" style="display: none; font-size: 13px; width: 100%; height: 127px; background-color: white; overflow: hidden; overflow-y: scroll; margin-bottom: 0; margin-top: 0; padding-left: 7px; padding-right: 10px;"></div>');
// initialize UI state
updateUI();
if (DEBUG) console.debug('setupUI: End setup');
}
function updateUI() {
const translationButton = document.getElementById('translate_live_button');
translationButton.style.fill = (showTranslation ? '#c00' : 'gray');
const translationContainer = document.getElementById('translate_container');
translationContainer.style.display = (showTranslation ? 'block' : 'none');
// hide video title, view count, like buttons etc when translation container is shown
const infoContents = document.getElementById('info-contents');
infoContents.style.display = (showTranslation ? 'none' : 'block');
const collaborationButton = document.getElementById('collaboration_button');
collaborationButton.style.fill = (matchSpeaker ? '#c00' : 'gray');
}
if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
main();
} else {
document.addEventListener('DOMContentLoaded', main);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment