Emote Cache for 7TV, FFZ, BTTV 1.32.19

Cache frequently used Twitch emotes using IndexedDB with clean URLs to reduce load delay

// ==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
// ==/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);
    };
})();