// ==UserScript== // @name Emote Cache for 7TV, FFZ, BTTV 1.32.19 // @namespace http://tampermonkey.net/ // @version 1.32.18 // @description Cache frequently used Twitch emotes using IndexedDB with clean URLs to reduce load delay // @author gaullampis810 // @license MIT // @match https://*.twitch.tv/* // @icon https://yt3.googleusercontent.com/ytc/AIdro_nAFS_oYf_Gt3hs5y97Zri6PDs1-oDFyOcfCkjyHlgNEfQ=s900-c-k-c0x00ffffff-no-rj // @grant none // @updateURL // @downloadURL https://update.greasyfork.icu/scripts/535299/Emote%20Cache%20for%207TV%2C%20FFZ%2C%20BTTV%20%2013219.user.js // @updateURL https://update.greasyfork.icu/scripts/535299/Emote%20Cache%20for%207TV%2C%20FFZ%2C%20BTTV%20%2013219.meta.js // ==/UserScript== (function() { 'use strict'; // Константы const MAX_CACHE_SIZE = 30; const MAX_CHANNELS = 2; const CACHE_EXPIRY = 2 * 60 * 60 * 1000; const MAX_CACHE_BYTES = 5 * 1024 * 1024; const USE_BROWSER_CACHE = true; const RETRY_INTERVAL = 5000; // Интервал для повторных попыток (5 секунд) const MAX_RETRY_ATTEMPTS = 50; // Максимальное количество попыток загрузки const failedEmotes = new Map(); // Хранит { url: { element, attempts, code, provider } } let currentChannel = getCurrentChannel(); let isActiveTab = document.visibilityState === 'visible'; const tabId = Math.random().toString(36).substring(2); let myMostusedEmotesChat = []; // Открытие IndexedDB const dbRequest = indexedDB.open('EmoteCache', 2); dbRequest.onupgradeneeded = function(event) { const db = event.target.result; if (!db.objectStoreNames.contains('emotes')) { db.createObjectStore('emotes', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('mostUsed')) { const store = db.createObjectStore('mostUsed', { keyPath: 'channel' }); store.createIndex('totalSize', 'totalSize', { unique: false }); } }; // Логирование function log(...args) { if (localStorage.getItem('enableEmoteCacheLogging') === 'true') { console.log(`[EmoteCacher][Tab:${tabId}]`, ...args); } } // Нормализация URL function normalizeUrl(url) { try { const urlObj = new URL(url); urlObj.search = ''; return urlObj.toString(); } catch (e) { log('Error normalizing URL:', url, e); return url; } } // Вставка CSS-стилей function injectStyles() { const style = document.createElement('style'); style.textContent = ` .emote-label { position: absolute; bottom: -16px; color: #1d968a; font-size: 10px; padding: 1px 2px; border-radius: 18px; white-space: nowrap; pointer-events: none; z-index: -2; line-height: 8px; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; background: none; } .chat-line__message .emote-container { position: relative; display: inline-block; vertical-align: middle; line-height: normal; } .chat-line__message .emote-container img { position: relative; z-index: 1; vertical-align: middle !important; margin: 0 !important; padding: 0 !important; } `; document.head.appendChild(style); } // Получение текущего канала function getCurrentChannel() { const path = window.location.pathname; let match = path.match(/^\/([a-zA-Z0-9_]+)/) || path.match(/^\/popout\/([a-zA-Z0-9_]+)\/chat/); let channel = match ? match[1].toLowerCase() : null; if (!channel) { const channelElement = document.querySelector('.channel-header__user h1, .tw-title, [data-a-target="channel-header-display-name"]'); if (channelElement && channelElement.textContent) { channel = channelElement.textContent.trim().toLowerCase().replace(/[^a-z0-9_]/g, ''); } } const result = channel || 'global'; log('Detected channel:', result); return result; } // Получение размера изображения async function getImageSize(url) { try { const response = await fetch(url, { method: 'HEAD' }); const size = parseInt(response.headers.get('content-length'), 10) || 0; return size; } catch (error) { log('Error fetching image size:', url, error); return 0; } } // Проверка полупрозрачности смайла const transparencyCache = new Map(); async function isTransparentEmote(url, code) { if (transparencyCache.has(url)) { log('Using cached transparency result for emote:', url, transparencyCache.get(url)); return transparencyCache.get(url); } if (code.match(/[wcvhlrz]!$/i)) { log('Skipping transparency check for effect emote:', url, code); transparencyCache.set(url, true); return true; } try { const img = new Image(); img.crossOrigin = 'Anonymous'; img.src = url; await new Promise((resolve, reject) => { img.onload = () => resolve(true); img.onerror = () => reject(new Error('Image failed to load')); }); const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, img.width, img.height).data; for (let i = 3; i < imageData.length; i += 4) { if (imageData[i] < 255) { transparencyCache.set(url, true); return true; } } transparencyCache.set(url, false); return false; } catch (e) { log('Error checking transparency for emote:', url, e); transparencyCache.set(url, false); return false; } } // Загрузка кэша из IndexedDB async function loadCache() { return new Promise((resolve, reject) => { const db = dbRequest.result; const transaction = db.transaction(['emotes'], 'readonly'); const store = transaction.objectStore('emotes'); const request = store.getAll(); request.onsuccess = () => { const cache = {}; request.result.forEach(emote => { const normalizedUrl = normalizeUrl(emote.url); if (!cache[emote.channel]) cache[emote.channel] = {}; cache[emote.channel][normalizedUrl] = { code: emote.code, provider: emote.provider, timestamp: emote.timestamp, size: emote.size || 0 }; }); log('Loaded cache, channels:', Object.keys(cache).length); resolve(cache); }; request.onerror = () => reject(request.error); }); } // Загрузка myMostusedEmotesChat async function loadMostUsedEmotes() { return new Promise((resolve, reject) => { const db = dbRequest.result; const transaction = db.transaction(['mostUsed'], 'readonly'); const store = transaction.objectStore('mostUsed'); const request = store.get(currentChannel); request.onsuccess = () => { myMostusedEmotesChat = request.result?.urls || []; log('Loaded myMostusedEmotesChat:', myMostusedEmotesChat.length, 'for channel:', currentChannel); resolve(myMostusedEmotesChat); }; request.onerror = () => reject(request.error); }); } // Сохранение эмодзи async function saveEmote(url, channel, code, provider, timestamp, size) { return new Promise((resolve, reject) => { const db = dbRequest.result; const transaction = db.transaction(['emotes', 'mostUsed'], 'readwrite'); const emoteStore = transaction.objectStore('emotes'); const mostUsedStore = transaction.objectStore('mostUsed'); const id = `${channel}:${url}`; const request = emoteStore.put({ id, url, channel, code, provider, timestamp, size }); request.onsuccess = () => { const mostUsedRequest = mostUsedStore.get(channel); mostUsedRequest.onsuccess = () => { const mostUsedData = mostUsedRequest.result || { channel, urls: [], totalSize: 0 }; if (!mostUsedData.urls.includes(url)) { mostUsedData.totalSize = (mostUsedData.totalSize || 0) + size; mostUsedData.urls.push(url); } mostUsedStore.put(mostUsedData); log('Saved emote:', url, 'size:', size, 'channel:', channel); resolve(); }; mostUsedRequest.onerror = () => reject(mostUsedRequest.error); }; request.onerror = () => reject(request.error); }); } // Сохранение myMostusedEmotesChat async function saveMostUsedEmotes() { return new Promise((resolve, reject) => { const db = dbRequest.result; const transaction = db.transaction(['mostUsed'], 'readwrite'); const store = transaction.objectStore('mostUsed'); const request = store.put({ channel: currentChannel, urls: myMostusedEmotesChat, totalSize: myMostusedEmotesChat.reduce((acc, url) => { const cache = loadCache(); return acc + (cache[currentChannel]?.[url]?.size || 0); }, 0) }); request.onsuccess = () => { log('Saved myMostusedEmotesChat:', myMostusedEmotesChat.length, 'for channel:', currentChannel); resolve(); }; request.onerror = () => reject(request.error); }); } // Кэширование эмодзи async function cacheEmote(url, code, provider) { if (!isActiveTab) { log('Skipping cacheEmote: tab is not active'); return; } const channel = currentChannel || 'global'; const timestamp = Date.now(); const normalizedUrl = normalizeUrl(url); const cache = await loadCache(); if (!cache[channel]) cache[channel] = {}; if (cache[channel][normalizedUrl]) { cache[channel][normalizedUrl].timestamp = timestamp; await saveEmote(normalizedUrl, channel, cache[channel][normalizedUrl].code, cache[channel][normalizedUrl].provider, timestamp, cache[channel][normalizedUrl].size || 0); log('Updated timestamp for emote:', normalizedUrl); await updateMostUsedEmotes(cache[channel]); return; } if (USE_BROWSER_CACHE && window.__emoteCache?.[normalizedUrl]) { log('Using browser-cached emote:', normalizedUrl); await saveEmote(normalizedUrl, channel, code, provider, timestamp, cache[channel][normalizedUrl]?.size || 0); await updateMostUsedEmotes(cache[channel]); return; } const size = await getImageSize(normalizedUrl); const mostUsedData = await new Promise(resolve => { const db = dbRequest.result; const transaction = db.transaction(['mostUsed'], 'readonly'); const store = transaction.objectStore('mostUsed'); const request = store.get(channel); request.onsuccess = () => resolve(request.result || { totalSize: 0 }); request.onerror = () => resolve({ totalSize: 0 }); }); if ((mostUsedData.totalSize || 0) + size > MAX_CACHE_BYTES) { await freeCacheSpace(channel, size); } cache[channel][normalizedUrl] = { code, provider, timestamp, size }; await saveEmote(normalizedUrl, channel, code, provider, timestamp, size); const emoteKeys = Object.keys(cache[channel]); if (emoteKeys.length > MAX_CACHE_SIZE) { const oldestKey = emoteKeys.reduce((a, b) => cache[channel][a].timestamp < cache[channel][b].timestamp ? a : b); const deletedSize = cache[channel][oldestKey].size || 0; delete cache[channel][oldestKey]; const id = `${channel}:${oldestKey}`; const db = dbRequest.result; const transaction = db.transaction(['emotes', 'mostUsed'], 'readwrite'); const emoteStore = transaction.objectStore('emotes'); const mostUsedStore = transaction.objectStore('mostUsed'); emoteStore.delete(id); const mostUsedRequest = mostUsedStore.get(channel); mostUsedRequest.onsuccess = () => { const mostUsedData = mostUsedRequest.result || { channel, urls: [], totalSize: 0 }; mostUsedData.totalSize = Math.max(0, (mostUsedData.totalSize || 0) - deletedSize); mostUsedData.urls = mostUsedData.urls.filter(url => url !== oldestKey); mostUsedStore.put(mostUsedData); }; log('Removed oldest emote:', oldestKey); } await updateMostUsedEmotes(cache[channel]); } // Освобождение места в кэше async function freeCacheSpace(channel, requiredSize) { const db = dbRequest.result; const transaction = db.transaction(['emotes', 'mostUsed'], 'readwrite'); const emoteStore = transaction.objectStore('emotes'); const mostUsedStore = transaction.objectStore('mostUsed'); const request = emoteStore.getAll(); return new Promise(resolve => { request.onsuccess = () => { const emotes = request.result.filter(emote => emote.channel === channel); emotes.sort((a, b) => a.timestamp - b.timestamp); let freedSize = 0; const mostUsedRequest = mostUsedStore.get(channel); mostUsedRequest.onsuccess = () => { const mostUsedData = mostUsedRequest.result || { channel, urls: [], totalSize: 0 }; for (const emote of emotes) { if (mostUsedData.totalSize + requiredSize - freedSize <= MAX_CACHE_BYTES) break; emoteStore.delete(emote.id); freedSize += emote.size || 0; mostUsedData.urls = mostUsedData.urls.filter(url => url !== emote.url); log('Removed emote to free space:', emote.url, 'size:', emote.size); } mostUsedData.totalSize = Math.max(0, mostUsedData.totalSize - freedSize); mostUsedStore.put(mostUsedData); resolve(); }; }; request.onerror = () => resolve(); }); } // Обновление myMostusedEmotesChat async function updateMostUsedEmotes(channelCache) { myMostusedEmotesChat = Object.keys(channelCache) .map(url => normalizeUrl(url)) .sort((a, b) => channelCache[b].timestamp - channelCache[a].timestamp) .slice(0, MAX_CACHE_SIZE); await saveMostUsedEmotes(); preloadEmotes(); } // Предзагрузка эмодзи function preloadEmotes() { if (!isActiveTab) { log('Skipping preloadEmotes: tab is not active'); return; } myMostusedEmotesChat.forEach(url => { const normalizedUrl = normalizeUrl(url); const img = new Image(); img.src = normalizedUrl; img.loading = 'eager'; img.onerror = () => { log('Failed to preload emote:', normalizedUrl); markEmote(normalizedUrl, 'failed'); // Сохраняем в failedEmotes failedEmotes.set(normalizedUrl, { element: img, attempts: 0, code: '', provider: '' }); }; img.onload = () => { log('Preloaded emote:', normalizedUrl); markEmote(normalizedUrl, 'cached'); failedEmotes.delete(normalizedUrl); // Удаляем из неудавшихся }; window.__emoteCache = window.__emoteCache || {}; window.__emoteCache[normalizedUrl] = img; }); } // Пометка смайлов async function markEmote(url, status) { const emotes = document.querySelectorAll(`.chat-line__message img[src="${url}"]`); emotes.forEach(async (emote) => { if (emote.parentElement.querySelector(`.emote-label[data-emote-url="${url}"]`)) { log('Label already exists for emote:', url); return; } if (!emote.complete || emote.naturalWidth === 0) { log('Skipping label for unloaded emote:', url); return; } const width = emote.naturalWidth || emote.width; const height = emote.naturalHeight || emote.height; if (width < 16 || height < 16) { log('Skipping label for small emote:', url, `size: ${width}x${height}`); return; } const code = emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || ''; const isTransparent = await isTransparentEmote(url, code); if (isTransparent) { log('Skipping label for transparent emote:', url, code); return; } let container = emote.closest('.emote-container'); if (!container) { log('No emote-container found for emote:', url); return; } const leftOffset = emote.offsetLeft; const label = document.createElement('span'); label.classList.add('emote-label'); label.textContent = status; label.setAttribute('data-emote-url', url); label.style.left = `${leftOffset}px`; container.appendChild(label); // Добавляем возможность клика для повторной загрузки if (status === 'failed') { emote.style.cursor = 'pointer'; emote.title = 'Click to retry loading'; emote.addEventListener('click', () => { retryEmote(url, emote); }); } log(`Added ${status} label to emote:`, url, `size: ${width}x${height}`, 'position:', `left: ${label.style.left}, bottom: -16px`, 'code:', code); }); } // Повторная попытка загрузки смайла async function retryEmote(url, emoteElement) { const emoteData = failedEmotes.get(url); if (!emoteData || emoteData.attempts >= MAX_RETRY_ATTEMPTS) { log('Max retry attempts reached or emote not found:', url); return; } emoteData.attempts += 1; failedEmotes.set(url, emoteData); log(`Retrying emote: ${url}, attempt ${emoteData.attempts}`); // Обновляем метку на "retrying" const label = emoteElement.parentElement.querySelector(`.emote-label[data-emote-url="${url}"]`); if (label) { label.textContent = 'retrying'; } const img = new Image(); img.src = url; img.onerror = () => { log(`Retry failed for emote: ${url}, attempt ${emoteData.attempts}`); markEmote(url, 'failed'); }; img.onload = () => { log(`Retry successful for emote: ${url}`); emoteElement.src = url; // Обновляем src элемента в чате markEmote(url, 'cached'); failedEmotes.delete(url); // Удаляем из неудавшихся // Обновляем кэш cacheEmote(url, emoteData.code || emoteElement.alt || '', emoteData.provider || ''); }; } // Автоматическая повторная загрузка неудавшихся смайлов function retryFailedEmotes() { if (!isActiveTab) { log('Skipping retryFailedEmotes: tab is not active'); return; } failedEmotes.forEach((emoteData, url) => { if (emoteData.attempts < MAX_RETRY_ATTEMPTS) { retryEmote(url, emoteData.element); } else { log(`Max retry attempts reached for emote: ${url}`); } }); } // Очистка устаревшего кэша async function cleanOldCache() { const now = Date.now(); const db = dbRequest.result; const transaction = db.transaction(['emotes', 'mostUsed'], 'readwrite'); const emoteStore = transaction.objectStore('emotes'); const mostUsedStore = transaction.objectStore('mostUsed'); const request = emoteStore.getAll(); return new Promise(resolve => { request.onsuccess = () => { const channelSizes = {}; request.result.forEach(emote => { if (now - emote.timestamp > CACHE_EXPIRY) { emoteStore.delete(emote.id); channelSizes[emote.channel] = (channelSizes[emote.channel] || 0) + (emote.size || 0); log('Removed expired emote:', emote.url, 'size:', emote.size); } }); Object.keys(channelSizes).forEach(channel => { const mostUsedRequest = mostUsedStore.get(channel); mostUsedRequest.onsuccess = () => { const mostUsedData = mostUsedRequest.result || { channel, urls: [], totalSize: 0 }; mostUsedData.totalSize = Math.max(0, mostUsedData.totalSize - channelSizes[channel]); mostUsedData.urls = mostUsedData.urls.filter(url => { const id = `${channel}:${url}`; return request.result.some(emote => emote.id === id); }); mostUsedStore.put(mostUsedData); }; }); loadCache().then(cache => { if (cache[currentChannel]) { updateMostUsedEmotes(cache[currentChannel]); } resolve(); }); }; request.onerror = () => resolve(); }); } // Обработка эмодзи в чате async function processEmotes(mutations) { if (!isActiveTab) { log('Skipping processEmotes: tab is not active'); return; } 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(` .chat-line__message .bttv-emote, .chat-line__message .seventv-emote, .chat-line__message .ffz-emote `); emotes.forEach(async (emote) => { const url = emote.getAttribute('data-original-src') || emote.src; if (!url) { log('No URL found for emote:', emote); return; } const normalizedUrl = normalizeUrl(url); if (window.__emoteCache?.[normalizedUrl]) { emote.src = normalizedUrl; log('Replaced emote src with cached:', normalizedUrl); markEmote(normalizedUrl, 'cached'); } else { log('Emote not found in cache:', normalizedUrl); emote.onerror = () => { log('Emote failed to load:', normalizedUrl); markEmote(normalizedUrl, 'failed'); // Сохраняем в failedEmotes failedEmotes.set(normalizedUrl, { element: emote, attempts: 0, code: emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '', provider: emote.classList.contains('bttv-emote') ? 'bttv' : emote.classList.contains('seventv-emote') ? '7tv' : emote.classList.contains('ffz-emote') ? 'ffz' : '' }); }; if (emote.complete && emote.naturalWidth === 0) { log('Emote failed to load (invalid image):', normalizedUrl); markEmote(normalizedUrl, 'failed'); failedEmotes.set(normalizedUrl, { element: emote, attempts: 0, code: emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '', provider: emote.classList.contains('bttv-emote') ? 'bttv' : emote.classList.contains('seventv-emote') ? '7tv' : emote.classList.contains('ffz-emote') ? 'ffz' : '' }); } } 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' : ''; if (!provider) { log('No provider detected for emote:', normalizedUrl); return; } const isEffectModifier = code.match(/[wcvhlrz]!$/i); if (isEffectModifier) { log('Detected effect modifier:', code, normalizedUrl); } cacheEmote(normalizedUrl, code, provider); }); }); }); } // Отслеживание смены канала function monitorChannelSwitch() { let lastChannel = currentChannel; window.addEventListener('popstate', async () => { const newChannel = getCurrentChannel(); if (newChannel !== lastChannel && isActiveTab) { log('Channel switched:', lastChannel, '->', newChannel); currentChannel = newChannel; lastChannel = newChannel; await loadMostUsedEmotes(); preloadEmotes(); } }); const observer = new MutationObserver(async () => { const newChannel = getCurrentChannel(); if (newChannel !== lastChannel && isActiveTab) { log('Channel switched via DOM:', lastChannel, '->', newChannel); currentChannel = newChannel; lastChannel = newChannel; await loadMostUsedEmotes(); preloadEmotes(); } }); observer.observe(document.body, { childList: true, subtree: true }); } // Отслеживание активности вкладки function monitorTabActivity() { document.addEventListener('visibilitychange', async () => { isActiveTab = document.visibilityState === 'visible'; if (isActiveTab) { const newChannel = getCurrentChannel(); if (newChannel !== currentChannel) { log('Channel updated:', currentChannel, '->', newChannel); currentChannel = newChannel; } await loadMostUsedEmotes(); preloadEmotes(); log('Tab became active, channel:', currentChannel); } else { log('Tab became inactive, channel:', currentChannel); } }); } // Инициализация dbRequest.onsuccess = async () => { log('Script initialized, channel:', currentChannel); injectStyles(); await loadMostUsedEmotes(); preloadEmotes(); cleanOldCache(); monitorChannelSwitch(); monitorTabActivity(); const chatContainer = document.querySelector('.chat-scrollable-area__message-container') || document.body; const observer = new MutationObserver(mutations => { if (isActiveTab) { requestIdleCallback(() => processEmotes(mutations), { timeout: 500 }); } }); observer.observe(chatContainer, { childList: true, subtree: true }); setInterval(cleanOldCache, 30 * 60 * 1000); setInterval(retryFailedEmotes, RETRY_INTERVAL); }; dbRequest.onerror = () => { console.error('[EmoteCacher] Failed to open IndexedDB:', dbRequest.error); }; })();