// ==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.icu/scripts/530574/Previewer%20Media%20on%20Chats%20%201235.user.js // @icon https://yt3.googleusercontent.com/ytc/AOPolaS0epA6kuqQqudVFRN0l9aJ2ScCvwK0YqC7ojbU=s900-c-k-c0x00ffffff-no-rj // @downloadURL https://update.greasyfork.icu/scripts/530574/Previewer%20Media%20on%20Chats%20%201236.user.js // @updateURL https://update.greasyfork.icu/scripts/530574/Previewer%20Media%20on%20Chats%20%201236.meta.js // ==/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); })();