Embeds images, GIFs and videos directly in Twitch chat instead of plain links. Works alongside FrankerFaceZ (FFZ) - replaces FFZ link cards with inline previews.
// ==UserScript==
// @name Twitch Chat Image Embed
// @name:ru Встраивание изображений в чат Twitch
// @namespace http://tampermonkey.net/
// @version 1.4
// @description Embeds images, GIFs and videos directly in Twitch chat instead of plain links. Works alongside FrankerFaceZ (FFZ) - replaces FFZ link cards with inline previews.
// @description:ru Отображает изображения, гифки и видео прямо в чате Twitch вместо ссылок. Работает самостоятельно и совместим с FrankerFaceZ - заменяет карточки ссылок FFZ на встроенные превью.
// @author You
// @match https://www.twitch.tv/*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const CONFIG = {
maxWidth: 300,
maxHeight: 500,
keepLink: false,
};
const style = document.createElement('style');
style.textContent = `
.tie-embed-container {
display: block;
margin-top: 4px;
margin-left: 2rem;
opacity: 1 !important;
filter: none !important;
}
.tie-embed-container img,
.tie-embed-container video {
max-width: ${CONFIG.maxWidth}px;
max-height: ${CONFIG.maxHeight}px;
border-radius: 4px;
cursor: pointer;
display: block;
}
.tie-hide-ffz-card {
display: none !important;
}
`;
document.head.appendChild(style);
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i;
const VIDEO_EXT = /\.(mp4|webm)(\?.*)?$/i;
const SPECIAL_HOSTS = [
{ host: 'i.imgur.com', transform: url => url },
{ host: 'imgur.com', transform: url => {
const m = url.match(/imgur\.com\/([a-zA-Z0-9]+)$/);
return m ? `https://i.imgur.com/${m[1]}.jpg` : null;
}},
{ host: 'tenor.com', transform: url => {
if (url.endsWith('.gif')) return url;
const m = url.match(/tenor\.com\/view\/[^/]*-(\d+)$/);
return m ? `https://media.tenor.com/${m[1]}.gif` : null;
}},
{ host: 'www.tenor.com', transform: url => {
if (url.endsWith('.gif')) return url;
const m = url.match(/tenor\.com\/view\/[^/]*-(\d+)$/);
return m ? `https://media.tenor.com/${m[1]}.gif` : null;
}},
{ host: 'media.tenor.com', transform: url => url },
{ host: 'media1.tenor.com', transform: url => url },
{ host: 'c.tenor.com', transform: url => url },
{ host: 'giphy.com', transform: url => {
const m = url.match(/giphy\.com\/(?:gifs|stickers|clips)\/(?:[^/]*-)?([a-zA-Z0-9]+)(?:\?.*)?$/);
return m ? `https://i.giphy.com/${m[1]}.gif` : null;
}},
{ host: 'www.giphy.com', transform: url => {
const m = url.match(/giphy\.com\/(?:gifs|stickers|clips)\/(?:[^/]*-)?([a-zA-Z0-9]+)(?:\?.*)?$/);
return m ? `https://i.giphy.com/${m[1]}.gif` : null;
}},
{ host: 'media.giphy.com', transform: url => url },
{ host: 'media0.giphy.com', transform: url => url },
{ host: 'media1.giphy.com', transform: url => url },
{ host: 'media2.giphy.com', transform: url => url },
{ host: 'media3.giphy.com', transform: url => url },
{ host: 'media4.giphy.com', transform: url => url },
{ host: 'i.giphy.com', transform: url => url },
{ host: 'i.redd.it', transform: url => url },
{ host: 'kappa.lol', transform: url => url },
{ host: 'www.kappa.lol', transform: url => url },
{ host: 'i.nuuls.com', transform: url => url },
{ host: 'nuuls.com', transform: url => url },
{ host: 'cdn.discordapp.com', transform: url => url },
{ host: 'media.discordapp.net', transform: url => url },
{ host: 'pbs.twimg.com', transform: url => url },
{ host: 'i.ibb.co', transform: url => url },
{ host: 'prnt.sc', transform: url => {
const m = url.match(/prnt\.sc\/([a-zA-Z0-9]+)$/);
return m ? `https://image.prntscr.com/image/${m[1]}.png` : null;
}},
];
function isImageUrl(url) {
if (IMAGE_EXT.test(url)) return 'image';
if (VIDEO_EXT.test(url)) return 'video';
try {
const u = new URL(url);
for (const sh of SPECIAL_HOSTS) {
if (u.hostname === sh.host) {
const transformed = sh.transform(url);
if (transformed) return 'image';
}
}
} catch(e) {}
return null;
}
function getTransformedUrl(url) {
try {
const u = new URL(url);
for (const sh of SPECIAL_HOSTS) {
if (u.hostname === sh.host) {
const transformed = sh.transform(url);
if (transformed) return transformed;
}
}
} catch(e) {}
return url;
}
function createMedia(url, type) {
const wrapper = document.createElement('div');
wrapper.className = 'tie-embed-container';
let el;
if (type === 'video') {
el = document.createElement('video');
el.src = url;
el.controls = true;
el.loop = true;
el.muted = true;
el.autoplay = true;
el.playsInline = true;
el.setAttribute('autoplay', '');
el.setAttribute('playsinline', '');
} else {
el = document.createElement('img');
el.src = url;
el.loading = 'lazy';
}
el.addEventListener('click', () => window.open(url, '_blank'));
wrapper.appendChild(el);
return wrapper;
}
function findMessageContainer(link) {
let node = link;
while (node && node !== document.body) {
if (node.classList && (
node.classList.contains('chat-line__message') ||
node.getAttribute('data-a-target') === 'chat-line-message'
)) {
return node;
}
node = node.parentElement;
}
return null;
}
function hideFfzCardsFor(msgContainer, imageUrls) {
if (!msgContainer || imageUrls.size === 0) return;
const cards = msgContainer.querySelectorAll('.ffz--chat-card');
cards.forEach(card => {
const cardLink = card.querySelector('a[href]');
if (!cardLink) return;
if (imageUrls.has(cardLink.href)) {
card.classList.add('tie-hide-ffz-card');
}
});
}
function processLinksInMessage(msgContainer) {
if (!msgContainer || !msgContainer.parentElement) return;
const parent = msgContainer.parentElement;
const links = msgContainer.querySelectorAll('a[href]');
const processedUrls = new Set();
links.forEach(link => {
if (link.closest('.ffz--chat-card')) {
return;
}
const href = link.href;
const type = isImageUrl(href);
if (!type) return;
processedUrls.add(href);
// Check if a preview for this URL already exists next to the message
const escapedUrl = href.replace(/"/g, '\\"');
let existing = null;
try {
existing = parent.querySelector(`.tie-embed-container[data-tie-url="${CSS.escape(href)}"]`);
} catch(e) {
// Fallback in case CSS.escape fails - search manually
const candidates = parent.querySelectorAll('.tie-embed-container');
for (const c of candidates) {
if (c.dataset.tieUrl === href) { existing = c; break; }
}
}
if (existing) {
// Preview already exists - just make sure the link stays hidden
if (!CONFIG.keepLink) link.style.display = 'none';
link.dataset.imgProcessed = 'true';
return;
}
// No preview yet - create one (even if imgProcessed flag was already set)
link.dataset.imgProcessed = 'true';
const finalUrl = getTransformedUrl(href);
const media = createMedia(finalUrl, type);
media.dataset.tieUrl = href;
parent.insertBefore(media, msgContainer.nextSibling);
if (!CONFIG.keepLink) {
link.style.display = 'none';
}
});
hideFfzCardsFor(msgContainer, processedUrls);
}
function scanNode(root) {
if (!root || !root.querySelectorAll) return;
const messages = new Set();
const rootMsg = root.closest && root.closest('.chat-line__message');
if (rootMsg) messages.add(rootMsg);
root.querySelectorAll('.chat-line__message').forEach(m => messages.add(m));
messages.forEach(msg => {
processLinksInMessage(msg);
setTimeout(() => processLinksInMessage(msg), 500);
setTimeout(() => processLinksInMessage(msg), 1500);
});
}
function startObserver() {
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
scanNode(node);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
scanNode(document.body);
}
setTimeout(startObserver, 2000);
})();