Skip to content

Instantly share code, notes, and snippets.

@Janinnho
Last active October 30, 2025 15:49
Show Gist options
  • Select an option

  • Save Janinnho/ba888e4f71ee9e73b7662dd7580fb250 to your computer and use it in GitHub Desktop.

Select an option

Save Janinnho/ba888e4f71ee9e73b7662dd7580fb250 to your computer and use it in GitHub Desktop.
multistream-emotes User Skript
// ==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