// ==UserScript== // @name Emote Cache for 7TV, FFZ, BTTV 1.32.11 // @namespace http://tampermonkey.net/ // @version 1.32.11 // @description Cache frequently used Twitch emotes using IndexedDB with clean URLs to reduce load delay // @author You // @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 none // ==/UserScript== (function() { 'use strict'; // Константы const MAX_CACHE_SIZE = 30; const MAX_CHANNELS = 5; const CACHE_EXPIRY = 2 * 60 * 60 * 1000; const MAX_CACHE_BYTES = 5 * 1024 * 1024; const USE_BROWSER_CACHE = true; 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: 32px; right: 75px; background: rgb(62 31 65 / 57%); color: #1d968a; font-size: 11px; padding: 1px 8px; border-radius: 18px; pointer-events: none; z-index: 1; } .chat-line__message .emote-container { position: relative; display: inline-block; } `; 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; } } // Загрузка кэша из 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'); }; img.onload = () => { log('Preloaded emote:', normalizedUrl); markEmote(normalizedUrl, 'cached'); // Mark as cached after loading error }; window.__emoteCache = window.__emoteCache || {}; window.__emoteCache[normalizedUrl] = img; }); } // Пометка смайлов function markEmote(url, status) { const emotes = document.querySelectorAll(`.chat-line__message img[src="${url}"]`); emotes.forEach(emote => { if (emote.parentElement.querySelector('.emote-label')) return; let container = emote.parentElement; if (!container.classList.contains('emote-container')) { container = document.createElement('span'); container.classList.add('emote-container'); emote.parentElement.insertBefore(container, emote); container.appendChild(emote); } const label = document.createElement('span'); label.classList.add('emote-label'); label.textContent = status; container.appendChild(label); log(`Added ${status} label to 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(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'); }; } 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; } 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); }; dbRequest.onerror = () => { console.error('[EmoteCacher] Failed to open IndexedDB:', dbRequest.error); }; })();