- 
      
- 
        Save Fourmisain/bd5f0dfc00cb54b89a97badd9b4a250d to your computer and use it in GitHub Desktop. 
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | // ==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}): ` : '')}  ${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