Last active
October 30, 2025 15:49
-
-
Save Janinnho/ba888e4f71ee9e73b7662dd7580fb250 to your computer and use it in GitHub Desktop.
multistream-emotes User Skript
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 MultiStreamViewer - 7TV/BTTV/FFZ Emotes | |
| // @namespace MultiStreamViewer | |
| // @version 1.0.0 | |
| // @description Adds 7TV, BTTV and FFZ emotes to Twitch chat embeds on MultiStreamViewer | |
| // @author Janinnho | |
| // @match https://www.twitch.tv/embed/*/chat* | |
| // @match https://twitch.tv/embed/*/chat* | |
| // @match http://localhost:8787/* | |
| // @match https://multistreamviewer.jannik.software/* | |
| // @grant GM.xmlHttpRequest | |
| // @grant GM_xmlhttpRequest | |
| // @connect 7tv.io | |
| // @connect api.betterttv.net | |
| // @connect cdn.betterttv.net | |
| // @connect api.frankerfacez.com | |
| // @connect cdn.7tv.app | |
| // @connect api.ivr.fi | |
| // @run-at document-end | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // Check if we're in the chat iframe | |
| const isChatEmbed = window.location.href.includes('twitch.tv/embed') && window.location.href.includes('/chat'); | |
| // Set flag that userscript is loaded (for parent page detection) | |
| if (!isChatEmbed && (window.location.href.includes('localhost:8787') || window.location.href.includes('multistreamviewer.jannik.software'))) { | |
| window.MultiStreamEmotesLoaded = true; | |
| console.log('[MultiStreamEmotes] Userscript detected on main page'); | |
| } | |
| if (!isChatEmbed) { | |
| console.log('[MultiStreamEmotes] Not in chat embed, skipping'); | |
| return; | |
| } | |
| console.log('[MultiStreamEmotes] Initializing in Twitch chat embed'); | |
| class EmoteInjector { | |
| constructor() { | |
| this.emotes = { | |
| '7tv': new Map(), | |
| 'bttv': new Map(), | |
| 'ffz': new Map() | |
| }; | |
| this.globalEmotes = { | |
| '7tv': new Map(), | |
| 'bttv': new Map(), | |
| 'ffz': new Map() | |
| }; | |
| this.channel = this.extractChannelFromUrl(); | |
| this.observer = null; | |
| this.injectedMessages = new WeakSet(); | |
| this.isLoading = true; | |
| } | |
| extractChannelFromUrl() { | |
| const match = window.location.pathname.match(/\/embed\/([^\/]+)\/chat/); | |
| return match ? match[1].toLowerCase() : null; | |
| } | |
| async init() { | |
| if (!this.channel) { | |
| console.warn('[MultiStreamEmotes] Could not extract channel name'); | |
| return; | |
| } | |
| console.log(`[MultiStreamEmotes] Loading emotes for #${this.channel}`); | |
| try { | |
| await this.loadAllEmotes(); | |
| console.log(`[MultiStreamEmotes] Loaded emotes:`, { | |
| '7tv_global': this.globalEmotes['7tv'].size, | |
| '7tv_channel': this.emotes['7tv'].size, | |
| 'bttv_global': this.globalEmotes.bttv.size, | |
| 'bttv_channel': this.emotes.bttv.size, | |
| 'ffz_global': this.globalEmotes.ffz.size, | |
| 'ffz_channel': this.emotes.ffz.size | |
| }); | |
| this.isLoading = false; | |
| this.startObserving(); | |
| this.injectExistingMessages(); | |
| } catch (err) { | |
| console.error('[MultiStreamEmotes] Failed to initialize:', err); | |
| } | |
| } | |
| async loadAllEmotes() { | |
| await Promise.all([ | |
| this.load7TVEmotes(), | |
| this.loadBTTVEmotes(), | |
| this.loadFFZEmotes() | |
| ]); | |
| } | |
| async load7TVEmotes() { | |
| try { | |
| // Global emotes | |
| const globalRes = await this.fetch('https://7tv.io/v3/emote-sets/global'); | |
| const globalData = await globalRes.json(); | |
| if (globalData.emotes) { | |
| for (const emote of globalData.emotes) { | |
| const url = `https://cdn.7tv.app/emote/${emote.id}/1x.webp`; | |
| this.globalEmotes['7tv'].set(emote.name, { url, id: emote.id, service: '7tv' }); | |
| } | |
| } | |
| // Channel emotes | |
| const userId = await this.getTwitchUserId(); | |
| const channelRes = await this.fetch(`https://7tv.io/v3/users/twitch/${userId}`); | |
| if (channelRes.ok) { | |
| const channelData = await channelRes.json(); | |
| if (channelData.emote_set?.emotes) { | |
| for (const emote of channelData.emote_set.emotes) { | |
| const url = `https://cdn.7tv.app/emote/${emote.id}/1x.webp`; | |
| this.emotes['7tv'].set(emote.name, { url, id: emote.id, service: '7tv' }); | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[MultiStreamEmotes] Failed to load 7TV emotes:', err); | |
| } | |
| } | |
| async loadBTTVEmotes() { | |
| try { | |
| // Global emotes | |
| const globalRes = await this.fetch('https://api.betterttv.net/3/cached/emotes/global'); | |
| const globalData = await globalRes.json(); | |
| for (const emote of globalData) { | |
| const url = `https://cdn.betterttv.net/emote/${emote.id}/1x`; | |
| this.globalEmotes.bttv.set(emote.code, { url, id: emote.id, service: 'bttv' }); | |
| } | |
| // Channel emotes | |
| const userId = await this.getTwitchUserId(); | |
| const channelRes = await this.fetch(`https://api.betterttv.net/3/cached/users/twitch/${userId}`); | |
| if (channelRes.ok) { | |
| const channelData = await channelRes.json(); | |
| if (channelData.channelEmotes) { | |
| for (const emote of channelData.channelEmotes) { | |
| const url = `https://cdn.betterttv.net/emote/${emote.id}/1x`; | |
| this.emotes.bttv.set(emote.code, { url, id: emote.id, service: 'bttv' }); | |
| } | |
| } | |
| if (channelData.sharedEmotes) { | |
| for (const emote of channelData.sharedEmotes) { | |
| const url = `https://cdn.betterttv.net/emote/${emote.id}/1x`; | |
| this.emotes.bttv.set(emote.code, { url, id: emote.id, service: 'bttv' }); | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[MultiStreamEmotes] Failed to load BTTV emotes:', err); | |
| } | |
| } | |
| async loadFFZEmotes() { | |
| try { | |
| // Global emotes | |
| const globalRes = await this.fetch('https://api.frankerfacez.com/v1/set/global'); | |
| const globalData = await globalRes.json(); | |
| if (globalData.sets) { | |
| for (const setId in globalData.sets) { | |
| const set = globalData.sets[setId]; | |
| if (set.emoticons) { | |
| for (const emote of set.emoticons) { | |
| const url = `https:${emote.urls['1'] || emote.urls['2'] || emote.urls['4']}`; | |
| this.globalEmotes.ffz.set(emote.name, { url, id: emote.id, service: 'ffz' }); | |
| } | |
| } | |
| } | |
| } | |
| // Channel emotes | |
| const channelRes = await this.fetch(`https://api.frankerfacez.com/v1/room/${this.channel}`); | |
| if (channelRes.ok) { | |
| const channelData = await channelRes.json(); | |
| if (channelData.sets) { | |
| for (const setId in channelData.sets) { | |
| const set = channelData.sets[setId]; | |
| if (set.emoticons) { | |
| for (const emote of set.emoticons) { | |
| const url = `https:${emote.urls['1'] || emote.urls['2'] || emote.urls['4']}`; | |
| this.emotes.ffz.set(emote.name, { url, id: emote.id, service: 'ffz' }); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[MultiStreamEmotes] Failed to load FFZ emotes:', err); | |
| } | |
| } | |
| async getTwitchUserId() { | |
| if (this._userId) return this._userId; | |
| try { | |
| const res = await this.fetch(`https://api.ivr.fi/v2/twitch/user?login=${this.channel}`); | |
| const data = await res.json(); | |
| this._userId = data[0]?.id || this.channel; | |
| return this._userId; | |
| } catch (err) { | |
| console.warn('[MultiStreamEmotes] Failed to get Twitch user ID:', err); | |
| return this.channel; | |
| } | |
| } | |
| fetch(url) { | |
| return new Promise((resolve, reject) => { | |
| const gmFetch = typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : | |
| (typeof GM !== 'undefined' && GM.xmlHttpRequest) || null; | |
| if (gmFetch) { | |
| gmFetch({ | |
| method: 'GET', | |
| url: url, | |
| onload: (response) => { | |
| resolve({ | |
| ok: response.status >= 200 && response.status < 300, | |
| status: response.status, | |
| json: () => Promise.resolve(JSON.parse(response.responseText)) | |
| }); | |
| }, | |
| onerror: reject | |
| }); | |
| } else { | |
| // Fallback to regular fetch | |
| fetch(url).then(resolve).catch(reject); | |
| } | |
| }); | |
| } | |
| startObserving() { | |
| // Find chat message container | |
| const chatContainer = this.findChatContainer(); | |
| if (!chatContainer) { | |
| console.warn('[MultiStreamEmotes] Chat container not found, retrying...'); | |
| setTimeout(() => this.startObserving(), 1000); | |
| return; | |
| } | |
| console.log('[MultiStreamEmotes] Found chat container, starting observer'); | |
| this.observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| this.injectEmotesInElement(node); | |
| } | |
| } | |
| } | |
| }); | |
| this.observer.observe(chatContainer, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| findChatContainer() { | |
| // Twitch chat selectors (may need adjustment based on Twitch's structure) | |
| const selectors = [ | |
| '.chat-scrollable-area__message-container', | |
| '[data-test-selector="chat-scrollable-area__message-container"]', | |
| '.scrollable-area', | |
| '[class*="scrollable"]', | |
| '[class*="chat-list"]' | |
| ]; | |
| for (const selector of selectors) { | |
| const element = document.querySelector(selector); | |
| if (element) return element; | |
| } | |
| return null; | |
| } | |
| injectExistingMessages() { | |
| const chatContainer = this.findChatContainer(); | |
| if (chatContainer) { | |
| this.injectEmotesInElement(chatContainer); | |
| } | |
| } | |
| injectEmotesInElement(element) { | |
| if (this.injectedMessages.has(element)) return; | |
| // Find all text nodes in message | |
| const walker = document.createTreeWalker( | |
| element, | |
| NodeFilter.SHOW_TEXT, | |
| null, | |
| false | |
| ); | |
| const textNodes = []; | |
| let node; | |
| while (node = walker.nextNode()) { | |
| // Skip if parent already processed | |
| if (!this.injectedMessages.has(node.parentElement)) { | |
| textNodes.push(node); | |
| } | |
| } | |
| for (const textNode of textNodes) { | |
| this.replaceEmotesInTextNode(textNode); | |
| } | |
| this.injectedMessages.add(element); | |
| } | |
| replaceEmotesInTextNode(textNode) { | |
| const text = textNode.textContent; | |
| const words = text.split(' '); | |
| let hasEmotes = false; | |
| // Check if any word is an emote | |
| for (const word of words) { | |
| if (this.isEmote(word)) { | |
| hasEmotes = true; | |
| break; | |
| } | |
| } | |
| if (!hasEmotes) return; | |
| // Build new content with emotes | |
| const fragment = document.createDocumentFragment(); | |
| for (let i = 0; i < words.length; i++) { | |
| const word = words[i]; | |
| const emoteData = this.getEmoteData(word); | |
| if (emoteData) { | |
| const img = document.createElement('img'); | |
| img.src = emoteData.url; | |
| img.alt = word; | |
| img.title = `${word} (${emoteData.service.toUpperCase()})`; | |
| img.className = 'chat-emote'; | |
| img.style.cssText = 'height: 28px; vertical-align: middle; margin: -4px 2px;'; | |
| fragment.appendChild(img); | |
| } else { | |
| fragment.appendChild(document.createTextNode(word)); | |
| } | |
| // Add space between words (except last) | |
| if (i < words.length - 1) { | |
| fragment.appendChild(document.createTextNode(' ')); | |
| } | |
| } | |
| // Replace text node with fragment | |
| textNode.parentNode.replaceChild(fragment, textNode); | |
| } | |
| isEmote(word) { | |
| return this.emotes['7tv'].has(word) || this.globalEmotes['7tv'].has(word) || | |
| this.emotes.bttv.has(word) || this.globalEmotes.bttv.has(word) || | |
| this.emotes.ffz.has(word) || this.globalEmotes.ffz.has(word); | |
| } | |
| getEmoteData(word) { | |
| return this.emotes['7tv'].get(word) || this.globalEmotes['7tv'].get(word) || | |
| this.emotes.bttv.get(word) || this.globalEmotes.bttv.get(word) || | |
| this.emotes.ffz.get(word) || this.globalEmotes.ffz.get(word) || | |
| null; | |
| } | |
| destroy() { | |
| if (this.observer) { | |
| this.observer.disconnect(); | |
| this.observer = null; | |
| } | |
| } | |
| } | |
| // Wait for page to be ready | |
| function waitForChat() { | |
| if (document.readyState === 'complete') { | |
| const injector = new EmoteInjector(); | |
| injector.init(); | |
| } else { | |
| window.addEventListener('load', () => { | |
| const injector = new EmoteInjector(); | |
| injector.init(); | |
| }); | |
| } | |
| } | |
| waitForChat(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment