Previewer Media on Chats 1.2.36

Preview media links including shortened URLs with optimization

当前为 2025-03-30 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Previewer Media on Chats  1.2.36
// @namespace    http://tampermonkey.net/
// @version      1.2.36
// @description  Preview media links including shortened URLs with optimization 
// @author       Gullampis810, optimized by Grok
// @license      MIT
// @grant        GM_xmlhttpRequest
// @match        https://www.twitch.tv/*
// @match        https://grok.com/*
// @match        https://*.imgur.com/*
// @match        https://7tv.app/*
// @match        https://update.greasyfork.org/scripts/530574/Previewer%20Media%20on%20Chats%20%201235.user.js
// @icon         https://yt3.googleusercontent.com/ytc/AOPolaS0epA6kuqQqudVFRN0l9aJ2ScCvwK0YqC7ojbU=s900-c-k-c0x00ffffff-no-rj
// ==/UserScript==

(function() {
    'use strict';

    const urlCache = new Map();
    let previewContainer = null;

    // Определяем тип файла по расширению или хосту
    function getFileType(url) {
        const cleanUrl = url.split('?')[0];
        const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.gifv'];
        const imageExtensions = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp', '.avif'];

        const extension = cleanUrl.substring(cleanUrl.lastIndexOf('.')).toLowerCase();

        if (videoExtensions.includes(extension)) return 'video';
        if (imageExtensions.includes(extension)) return 'image';

        // Специфичные хосты
        if (url.includes('gachi.gay')) return 'image';
        if (url.includes('kappa.lol')) return null; // Требует дополнительной проверки
        if (url.includes('imgur.com') || url.includes('i.imgur.com')) {
            if (extension === '.gifv') return 'video';
            return 'image';
        }
        if (url.includes('emote') || url.includes('cdn.7tv.app') || url.includes('7tv.app/emotes')) return 'image';
        return null;
    }

    // Трансформация URL для 7TV
    function transform7TVUrl(url) {
        const emoteIdMatch = url.match(/7tv\.app\/emotes\/([a-zA-Z0-9]+)/);
        if (emoteIdMatch && emoteIdMatch[1]) {
            return `https://cdn.7tv.app/emote/${emoteIdMatch[1]}/4x.webp`;
        }
        return url;
    }

    // Определяем тип файла по Content-Type
    function getFileTypeFromContentType(contentType) {
        if (!contentType) return null;
        if (contentType.includes('video')) return 'video';
        if (contentType.includes('image')) return 'image';
        return null;
    }

    // Разрешение коротких ссылок
    async function resolveShortUrl(url) {
        if (urlCache.has(url)) return urlCache.get(url);

        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'HEAD',
                url: url,
                headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PreWatcher/1.2.4)' },
                onload: (response) => {
                    const finalUrl = response.finalUrl || url;
                    const contentType = response.responseHeaders.match(/content-type: (.*)/i)?.[1];
                    const result = { resolvedUrl: finalUrl, contentType };
                    urlCache.set(url, result);
                    resolve(result);
                },
                onerror: () => resolve({ resolvedUrl: url, contentType: null })
            });
        });
    }

    // Тестирование, является ли ссылка изображением
    async function testIfImage(url) {
        return new Promise((resolve) => {
            const img = new Image();
            img.onload = () => resolve(true);
            img.onerror = () => resolve(false);
            img.src = url;
        });
    }

    // Тестирование, является ли ссылка видео
    async function testIfVideo(url) {
        return new Promise((resolve) => {
            const video = document.createElement('video');
            video.onloadedmetadata = () => resolve(true);
            video.onerror = () => resolve(false);
            video.oncanplay = () => resolve(true);
            video.src = url;
            video.load();
        });
    }

    // Извлечение медиа из Reddit
    async function extractMediaFromReddit(url) {
        try {
            const response = await fetch(url);
            const text = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(text, 'text/html');

            const video = doc.querySelector('video source[src]');
            if (video) return { url: video.getAttribute('src'), type: 'video' };

            const img = doc.querySelector('img[src]');
            if (img) return { url: img.getAttribute('src'), type: 'image' };

            return null;
        } catch (error) {
            console.error('Ошибка при извлечении медиа из Reddit:', error);
            return null;
        }
    }

    // Создание и обновление контейнера предпросмотра
    function updatePreviewElement(url, type) {
        if (!previewContainer) {
            previewContainer = document.createElement('div');
            previewContainer.style.position = 'fixed';
            previewContainer.style.zIndex = '1000';
            previewContainer.style.background = '#0e1a1a';
            previewContainer.style.border = '1px solid #ccc';
            previewContainer.style.padding = '5px';
            previewContainer.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
            previewContainer.style.display = 'none';
            previewContainer.style.maxWidth = '400px';
            previewContainer.style.maxHeight = '300px';
            document.body.appendChild(previewContainer);
        }

        previewContainer.innerHTML = '';

        let element;
        if (type === 'video') {
            element = document.createElement('video');
            element.src = url;
            element.controls = true;
            element.muted = true;
        } else {
            element = document.createElement('img');
            element.src = url;
            element.draggable = false;
        }
        element.style.maxWidth = '100%';
        element.style.maxHeight = '100%';
        previewContainer.appendChild(element);
        return previewContainer;
    }

    // Обработка ссылок в чате
    async function processLinks() {
        const chatContainer = document.querySelector('.chat-scrollable-area__message-container');
        if (!chatContainer) return;

        const messages = chatContainer.querySelectorAll('.chat-line__message:not([data-processed])');
        for (let message of messages) {
            const link = message.querySelector('a[href]');
            if (!link || link.dataset.processed) continue;

            let url = link.getAttribute('href');
            let fileType = getFileType(url);
            let mediaUrl = url;

            // Трансформация URL для 7TV
            if (url.includes('7tv.app/emotes')) {
                mediaUrl = transform7TVUrl(url);
                fileType = getFileType(mediaUrl);
            }

            // Разрешение коротких ссылок и определение типа
            if (!fileType || url.includes('gachi.gay') || url.includes('kappa.lol') || url.includes('t.co') || url.includes('bit.ly') || url.includes('imgur.com')) {
                const { resolvedUrl, contentType } = await resolveShortUrl(url);
                mediaUrl = resolvedUrl;
                fileType = getFileType(mediaUrl) || getFileTypeFromContentType(contentType);

                // Если тип не определен, тестируем
                if (!fileType) {
                    const isVideo = await testIfVideo(mediaUrl);
                    if (isVideo) {
                        fileType = 'video';
                    } else {
                        const isImage = await testIfImage(mediaUrl);
                        fileType = isImage ? 'image' : null;
                    }
                }
            }

            // Обработка Reddit
            if (mediaUrl.includes('reddit.com') && !fileType) {
                const media = await extractMediaFromReddit(mediaUrl);
                if (media) {
                    mediaUrl = media.url;
                    fileType = media.type;
                }
            }

            if (!fileType) continue;

            link.dataset.mediaUrl = mediaUrl;
            link.dataset.fileType = fileType;
            link.dataset.processed = 'true';

            // Событие наведения для предпросмотра
            link.addEventListener('mouseenter', (e) => {
                const preview = updatePreviewElement(link.dataset.mediaUrl, link.dataset.fileType);
                preview.style.display = 'block';
                preview.style.left = `${e.pageX - 77}px`;
                preview.style.top = `${e.pageY + 10}px`;
                if (link.dataset.fileType === 'video') preview.querySelector('video')?.play();
            });

            // Событие ухода курсора
            link.addEventListener('mouseleave', () => {
                previewContainer.style.display = 'none';
                if (previewContainer.querySelector('video')) {
                    const video = previewContainer.querySelector('video');
                    video?.pause();
                    video.currentTime = 0;
                }
            });

            message.dataset.processed = 'true';
        }
    }

    // Дебаунс для оптимизации
    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    // Инициализация и наблюдение за изменениями
    const debouncedProcessLinks = debounce(processLinks, 500);
    document.addEventListener('DOMContentLoaded', debouncedProcessLinks);

    const observer = new MutationObserver(debouncedProcessLinks);
    observer.observe(document.body, { childList: true, subtree: true });

    window.previewLinks = debouncedProcessLinks;

    // Добавление стилей
    const style = document.createElement('style');
    style.textContent = `a[href] { position: relative; }`;
    document.head.appendChild(style);
})();