Greasy Fork

Greasy Fork is available in English.

Twitch Emotes Cache

Cache frequently used Twitch emotes to reduce load delay and avoid conflicts with Chrome extension

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

// ==UserScript==
// @name         Twitch Emotes Cache
// @namespace    http://tampermonkey.net/
// @version      1.31.5
// @description  Cache frequently used Twitch emotes to reduce load delay and avoid conflicts with Chrome extension
// @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
// @downloadURL
// @updateURL
// ==/UserScript==

(function() {
    'use strict';

    // Инициализация хранилища
    let emoteCache = JSON.parse(localStorage.getItem('twitchEmoteCache')) || {};
    const MAX_CACHE_SIZE = 500; // Максимальный размер кэша для канала
    const CACHE_EXPIRY = 2 * 60 * 60 * 1000; // 2 часа
    let pendingSave = null; // Для дебонсинга
    let currentChannel = getCurrentChannel(); // Текущий канал

    // Функция для логирования с включением/выключением
    function log(...args) {
        if (localStorage.getItem('enableEmoteCacheLogging') === 'true') {
            console.log('[EmoteCacher]', ...args);
        }
    }

    // Включение/выключение логирования
    window.setEmoteCacheLogging = function(enabled) {
        localStorage.setItem('enableEmoteCacheLogging', enabled);
        log(`Logging ${enabled ? 'enabled' : 'disabled'}`);
    };

    // Получение текущего канала из URL
    function getCurrentChannel() {
        const path = window.location.pathname;
        const match = path.match(/^\/([a-zA-Z0-9_]+)/);
        return match ? match[1].toLowerCase() : 'global';
    }

    // Очистка кэша при смене канала
    function clearChannelCache() {
        if (emoteCache[currentChannel]) {
            delete emoteCache[currentChannel];
            saveCache();
            log('Cleared cache for channel:', currentChannel);
        }
    }

    // Сохранение кэша с дебонсингом
    function saveCache() {
        if (pendingSave) return;
        pendingSave = setTimeout(() => {
            requestIdleCallback(() => {
                try {
                    localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache));
                    log('Cache saved successfully');
                } catch (e) {
                    console.error('[EmoteCacher] Error saving cache:', e);
                    // Очистка кэша при переполнении
                    emoteCache = { [currentChannel]: {} };
                    localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache));
                    log('Cache reset due to storage error');
                }
                pendingSave = null;
            }, { timeout: 1000 });
        }, 1000);
    }

    // Кэширование эмодзи
    function cacheEmote(url, dataUrl, code, provider, countIncrement = 1) {
        const now = Date.now();
        const channel = currentChannel || 'global';
        if (!emoteCache[channel]) emoteCache[channel] = {};

        emoteCache[channel][url] = {
            dataUrl: dataUrl || emoteCache[channel][url]?.dataUrl,
            code: code || emoteCache[channel][url]?.code,
            provider: provider || emoteCache[channel][url]?.provider,
            timestamp: now,
            count: (emoteCache[channel][url]?.count || 0) + countIncrement
        };

        // Ограничение размера кэша для канала
        const emoteKeys = Object.keys(emoteCache[channel]);
        if (emoteKeys.length > MAX_CACHE_SIZE) {
            const leastUsedKey = emoteKeys.reduce((a, b) =>
                emoteCache[channel][a].count < emoteCache[channel][b].count ? a : b
            );
            delete emoteCache[channel][leastUsedKey];
            log('Removed least used emote from cache:', leastUsedKey);
        }

        saveCache();
    }

    // Очистка устаревшего кэша
    function cleanOldCache() {
        const now = Date.now();
        for (const channel in emoteCache) {
            for (const url in emoteCache[channel]) {
                if (now - emoteCache[channel][url].timestamp > CACHE_EXPIRY) {
                    delete emoteCache[channel][url];
                    log('Removed expired emote from cache:', url);
                }
            }
            if (!Object.keys(emoteCache[channel]).length) {
                delete emoteCache[channel];
                log('Removed empty channel cache:', channel);
            }
        }
        saveCache();
        preloadPopularEmotes();
    }

    // Загрузка эмодзи
    async function fetchAndCacheEmote(imgElement, url, code, provider, countIncrement = 1, retries = 3) {
        const channel = currentChannel || 'global';
        if (emoteCache[channel]?.[url]?.dataUrl) {
            if (imgElement) {
                imgElement.setAttribute('data-original-src', url);
                if (imgElement.getAttribute('srcset')) {
                    imgElement.setAttribute('data-original-srcset', imgElement.getAttribute('srcset'));
                }
                imgElement.src = emoteCache[channel][url].dataUrl;
                log('Applied cached dataURL for emote:', code, url);
            }
            cacheEmote(url, null, code, provider, countIncrement);
            return;
        }

        for (let attempt = 1; attempt <= retries; attempt++) {
            try {
                const response = await fetch(url, { mode: 'cors' });
                if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`);
                const blob = await response.blob();
                const reader = new FileReader();
                reader.onloadend = () => {
                    const dataUrl = reader.result;
                    cacheEmote(url, dataUrl, code, provider, countIncrement);
                    if (imgElement) {
                        imgElement.setAttribute('data-original-src', url);
                        if (imgElement.getAttribute('srcset')) {
                            imgElement.setAttribute('data-original-srcset', imgElement.getAttribute('srcset'));
                        }
                        imgElement.src = dataUrl;
                        log('Cached and applied new dataURL for emote:', code, url);
                    }
                };
                reader.readAsDataURL(blob);
                return;
            } catch (error) {
                console.error(`[EmoteCacher] Attempt ${attempt} to fetch emote ${url} failed:`, error);
                if (attempt === retries) {
                    console.error('[EmoteCacher] All attempts to fetch emote failed:', url);
                    if (imgElement && code) {
                        imgElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAB9SURBVEhLY2AYBaNgFIyCUTAKRsEoGAWjYBSMglEwCkbBKBgFo2AUjIJRMAoGIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQAANHYvmHjk1oAAAAAASUVORK5CYII=';
                        imgElement.title = `Failed to load: ${code}`;
                    }
                }
            }
        }
    }

    // Предзагрузка популярных эмодзи
    function preloadPopularEmotes() {
        requestIdleCallback(() => {
            const channel = currentChannel || 'global';
            if (!emoteCache[channel]) return;

            const popularEmotes = Object.keys(emoteCache[channel])
                .filter(url => ['ffz', '7tv', 'bttv', 'twitch'].includes(emoteCache[channel][url].provider))
                .sort((a, b) => emoteCache[channel][b].count - emoteCache[channel][a].count)
                .slice(0, 20); // Топ-20 эмодзи

            popularEmotes.forEach((url) => {
                if (!emoteCache[channel][url].dataUrl) {
                    fetchAndCacheEmote(null, url, emoteCache[channel][url].code, emoteCache[channel][url].provider, 0);
                    log('Preloading popular emote:', url);
                }
            });
        }, { timeout: 5000 });
    }

    // Замена текстовых кодов на кэшированные эмодзи
    function replaceTextCodes(message) {
        const channel = currentChannel || 'global';
        if (!emoteCache[channel]) return;

        const textNodes = getTextNodes(message);
        textNodes.forEach((node) => {
            // Пропускаем текстовые узлы внутри элементов ника
            if (node.parentNode.closest('.chat-author__display-name') ||
                node.parentNode.closest('.chat-line__username')) {
                return;
            }

            let text = node.textContent;
            for (const url in emoteCache[channel]) {
                const emote = emoteCache[channel][url];
                if (emote.code && text.includes(emote.code)) {
                    const img = document.createElement('img');
                    img.className = 'chat-line__message--emote';
                    img.src = emote.dataUrl || 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAB9SURBVEhLY2AYBaNgFIyCUTAKRsEoGAWjYBSMglEwCkbBKBgFo2AUjIJRMAoGIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQAANHYvmHjk1oAAAAAASUVORK5CYII=';
                    img.alt = emote.code;
                    img.title = emote.code;
                    img.setAttribute('data-provider', emote.provider);
                    img.setAttribute('data-original-src', url);

                    const parts = text.split(emote.code);
                    text = parts.join('');
                    node.parentNode.insertBefore(img, node);
                    cacheEmote(url, emote.dataUrl, emote.code, emote.provider, 1);
                    log('Replaced text code with emote:', emote.code, url);
                }
            }
            if (text !== node.textContent) {
                node.textContent = text;
            }
        });
    }

    // Получение текстовых узлов
    function getTextNodes(node) {
        const textNodes = [];
        const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
        let currentNode;
        while ((currentNode = walker.nextNode())) {
            textNodes.push(currentNode);
        }
        return textNodes;
    }

    // Обработка эмодзи в чате
    function processEmotes(mutations) {
        requestIdleCallback(() => {
            const channel = currentChannel || 'global';
            if (!emoteCache[channel]) emoteCache[channel] = {};

            log('Processing mutations:', mutations.length);
            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach((node) => {
                    if (!node.querySelectorAll) return;
                    const message = node.classList?.contains('chat-line__message') ? node : node.querySelector('.chat-line__message');
                    if (!message) return;

                    const emotes = message.querySelectorAll('.bttv-emote, .seventv-emote, .ffz-emote, .twitch-emote');
                    log('Found emotes:', emotes.length);
                    emotes.forEach((emote) => {
                        const url = emote.src;
                        if (!url) return;

                        // Сохраняем оригинальные атрибуты
                        emote.setAttribute('data-original-src', url);
                        if (emote.getAttribute('srcset')) {
                            emote.setAttribute('data-original-srcset', emote.getAttribute('srcset'));
                        }

                        const code = emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '';
                        const provider = emote.classList.contains('bttv-emote') ? 'bttv' :
                                        emote.classList.contains('seventv-emote') ? '7tv' :
                                        emote.classList.contains('ffz-emote') ? 'ffz' :
                                        emote.classList.contains('twitch-emote') ? 'twitch' : '';

                        if (!provider) return;

                        if (emoteCache[channel][url]?.dataUrl) {
                            emote.src = emoteCache[channel][url].dataUrl;
                            cacheEmote(url, null, code, provider, 1);
                            log('Applied cached emote:', code, url);
                        } else {
                            log('Caching new emote:', url);
                            fetchAndCacheEmote(emote, url, code, provider, 1);
                        }
                    });

                    replaceTextCodes(message);
                });
            });
        }, { timeout: 100 }); // Уменьшенный таймаут для совместимости с расширением
    }

    // Отслеживание смены канала
    function monitorChannelSwitch() {
        let lastChannel = currentChannel;

        // Проверка изменения URL
        window.addEventListener('popstate', () => {
            const newChannel = getCurrentChannel();
            if (newChannel !== lastChannel) {
                log('Channel switched:', lastChannel, '->', newChannel);
                clearChannelCache();
                currentChannel = newChannel;
                lastChannel = newChannel;
                preloadPopularEmotes();
            }
        });

        // Отслеживание изменений в DOM для SPA-навигации
        const observer = new MutationObserver(() => {
            const newChannel = getCurrentChannel();
            if (newChannel !== lastChannel) {
                log('Channel switched via DOM:', lastChannel, '->', newChannel);
                clearChannelCache();
                currentChannel = newChannel;
                lastChannel = newChannel;
                preloadPopularEmotes();
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    // Логирование событий contextmenu для отладки
    document.addEventListener('contextmenu', (e) => {
        if (e.target.tagName === 'IMG') {
            log('Contextmenu on emote:', e.target.src, e.target.alt, e.target.getAttribute('data-original-src'));
        }
    }, { passive: true });

    // Инициализация
    log('Script initialized');
    cleanOldCache();
    preloadPopularEmotes();
    monitorChannelSwitch();

    // Настройка наблюдателя за чатом
    const chatContainer = document.querySelector('.chat-scrollable-area__message-container') || document.body;
    const observer = new MutationObserver(processEmotes);
    observer.observe(chatContainer, { childList: true, subtree: true });

    // Периодическая очистка кэша (каждые 30 минут)
    setInterval(cleanOldCache, 30 * 60 * 1000);
})();