Greasy Fork

Greasy Fork is available in English.

Emote Cache for 7TV, FFZ, BTTV 1.32.19

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

目前为 2025-05-15 提交的版本,查看 最新版本

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