// ==UserScript== // @name Twitch Emotes Cache // @namespace http://tampermonkey.net/ // @version 1.31.4 // @description Cache frequently used Twitch emotes to reduce load delay // @author You // @license MIT // @match https://www.twitch.tv/* // @match https://player.twitch.tv/* // @match https://*.ttvnw.net/* // @match https://*.jtvnw.net/* // @match https://cdn.frankerfacez.com/* // @match https://cdn.7tv.app/* // @match https://cdn.betterttv.net/* // @icon https://yt3.googleusercontent.com/ytc/AIdro_nAFS_oYf_Gt3hs5y97Zri6PDs1-oDFyOcfCkjyHlgNEfQ=s900-c-k-c0x00ffffff-no-rj // @downloadURL // @updateURL // @downloadURL none // ==/UserScript== (function() { 'use strict'; // Объект для хранения кэша эмодзи const emoteCache = {}; let currentChannel = null; // Функция для логирования с включением/выключением function log(...args) { if (localStorage.getItem('enableEmoteCacheLogging') === 'true') { console.log('[EmoteCacher]', ...args); } } // Включение/выключение логирования window.setEmoteCacheLogging = function(enabled) { localStorage.setItem('enableEmoteCacheLogging', enabled); log(`Logging ${enabled ? 'enabled' : 'disabled'}`); }; // Функция для извлечения имени канала из URL function getChannelName() { const path = window.location.pathname.split('/'); return path[1] || 'global'; } // Функция для загрузки и кэширования эмодзи async function fetchAndCacheEmote(emoteElement, url, code, provider, priority) { try { log('Fetching emote:', url); const response = await fetch(url, { cache: 'force-cache' }); if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); const blob = await response.blob(); const dataUrl = await new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); const channel = currentChannel || 'global'; if (!emoteCache[channel]) emoteCache[channel] = {}; emoteCache[channel][url] = { dataUrl, code, provider, priority }; localStorage.setItem(`emoteCache_${channel}`, JSON.stringify(emoteCache[channel])); if (emoteElement) { // Сохраняем оригинальные атрибуты emoteElement.setAttribute('data-original-src', url); if (emoteElement.getAttribute('srcset')) { emoteElement.setAttribute('data-original-srcset', emoteElement.getAttribute('srcset')); } emoteElement.src = dataUrl; log('Updated emote src to dataURL:', code); } return dataUrl; } catch (error) { console.error('[EmoteCacher] Error caching emote:', url, error); return null; } } // Функция для замены текстовых кодов на изображения function replaceTextCodes(messageElement) { const channel = currentChannel || 'global'; if (!emoteCache[channel]) return; const textNodes = document.createTreeWalker( messageElement, NodeFilter.SHOW_TEXT, { acceptNode: node => { return node.parentElement.classList.contains('chat-line__message--emote') || node.parentElement.tagName === 'IMG' ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } } ); const nodesToReplace = []; let node; while ((node = textNodes.nextNode())) { const text = node.textContent; for (const [url, { code, dataUrl, provider }] of Object.entries(emoteCache[channel])) { if (text.includes(code)) { nodesToReplace.push({ node, code, dataUrl, provider, url }); } } } nodesToReplace.forEach(({ node, code, dataUrl, provider, url }) => { const parts = node.textContent.split(code); const fragment = document.createDocumentFragment(); parts.forEach((part, index) => { fragment.appendChild(document.createTextNode(part)); if (index < parts.length - 1) { const img = document.createElement('img'); img.src = dataUrl; img.alt = code; img.classList.add(`${provider}-emote`); img.setAttribute('data-provider', provider); img.setAttribute('data-original-src', url); // Сохраняем оригинальный src fragment.appendChild(img); } }); node.replaceWith(fragment); log('Replaced text code:', code); }); } // Функция для обработки новых сообщений в чате function processEmotes(mutations) { // Обрабатываем с задержкой, чтобы дать расширению шанс обработать контекстное меню requestIdleCallback(() => { const channel = currentChannel || 'global'; if (!emoteCache[channel]) emoteCache[channel] = {}; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (!node.querySelectorAll) return; const message = node.classList?.contains('chat-line__message') ? node : node.querySelector('.chat-line__message'); if (!message) return; const emotes = message.querySelectorAll('.bttv-emote, .seventv-emote, .ffz-emote, .twitch-emote'); emotes.forEach((emote) => { const url = emote.src; if (!url) return; // Сохраняем оригинальные атрибуты emote.setAttribute('data-original-src', url); if (emote.getAttribute('srcset')) { emote.setAttribute('data-original-srcset', emote.getAttribute('srcset')); } const code = emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || ''; const provider = emote.classList.contains('bttv-emote') ? 'bttv' : emote.classList.contains('seventv-emote') ? '7tv' : emote.classList.contains('ffz-emote') ? 'ffz' : emote.classList.contains('twitch-emote') ? 'twitch' : ''; if (!provider) return; if (emoteCache[channel][url]?.dataUrl) { emote.src = emoteCache[channel][url].dataUrl; log('Applied cached emote:', code); } else { log('Caching new emote:', url); fetchAndCacheEmote(emote, url, code, provider, 1); } // Обработка 2x-изображений const srcset = emote.getAttribute('srcset'); if (srcset) { const match = srcset.match(/(\S+)\s+2x/); if (match) { const url2x = match[1]; if (url2x && !emoteCache[channel][url2x]?.dataUrl) { log('Caching 2x emote:', url2x); fetchAndCacheEmote(null, url2x, code + '_2x', provider, 0); } } } }); replaceTextCodes(message); }); }); }, { timeout: 100 }); } // Инициализация наблюдателя за чатом function initObserver() { const chatContainer = document.querySelector('.chat-scrollable-area__message-container') || document.querySelector('.chat-room__content') || document.querySelector('[data-a-target="chat-room-content"]') || document.querySelector('.chat-list--default'); if (!chatContainer) { log('Chat container not found, retrying...'); setTimeout(initObserver, 1000); return; } log('Chat container found, starting observer'); const observer = new MutationObserver(processEmotes); observer.observe(chatContainer, { childList: true, subtree: true }); // Загрузка кэша из localStorage currentChannel = getChannelName(); const cachedData = localStorage.getItem(`emoteCache_${currentChannel}`); if (cachedData) { emoteCache[currentChannel] = JSON.parse(cachedData); log('Loaded cache for channel:', currentChannel); } // Мониторинг смены канала let lastChannel = currentChannel; setInterval(() => { const newChannel = getChannelName(); if (newChannel !== lastChannel) { log('Channel changed:', lastChannel, '->', newChannel); lastChannel = newChannel; currentChannel = newChannel; const cachedData = localStorage.getItem(`emoteCache_${currentChannel}`); if (cachedData) { emoteCache[currentChannel] = JSON.parse(cachedData); log('Loaded cache for new channel:', currentChannel); } } }, 2000); } // Логирование событий contextmenu для отладки document.addEventListener('contextmenu', (e) => { if (e.target.tagName === 'IMG') { log('Contextmenu on emote:', e.target.src, e.target.alt); } }, { passive: true }); // Инициализация скрипта log('Script initialized'); initObserver(); })();