Twitch Chat Image Embed

Embeds images, GIFs and videos directly in Twitch chat instead of plain links. Works alongside FrankerFaceZ (FFZ) - replaces FFZ link cards with inline previews.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);

})();