// ==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 and kappa.lol privacy fix
// @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);
})();