Greasy Fork

Greasy Fork is available in English.

Twitch Emotes Cache 1.31.1

Cache frequently used Twitch emotes to reduce load delay

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

// ==UserScript==
// @name         Twitch Emotes Cache   1.31.1
// @namespace    http://tampermonkey.net/
// @version      1.31.1
// @description  Cache frequently used Twitch emotes 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
// @downloadURL
// @updateURL
// ==/UserScript==



(function() {
    'use strict';

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

    // Получение текущего канала из 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();
        }
    }

    // Сохранение кэша с дебонсингом
    function saveCache() {
        if (pendingSave) return;
        pendingSave = setTimeout(() => {
            requestIdleCallback(() => {
                try {
                    localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache));
                } catch (e) {
                    console.error('Ошибка сохранения кэша:', e);
                    // Очистка кэша при переполнении
                    emoteCache = { [currentChannel]: {} };
                    localStorage.setItem('twitchEmoteCache', JSON.stringify(emoteCache));
                }
                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];
        }

        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];
                }
            }
            if (!Object.keys(emoteCache[channel]).length) {
                delete emoteCache[channel];
            }
        }
        saveCache();
    }

    // Загрузка эмодзи
    async function fetchAndCacheEmote(imgElement, url, code, provider, countIncrement = 1) {
        // Пропускаем, если эмодзи уже в кэше
        const channel = currentChannel || 'global';
        if (emoteCache[channel]?.[url]?.dataUrl) {
            if (imgElement) imgElement.src = emoteCache[channel][url].dataUrl;
            cacheEmote(url, null, code, provider, countIncrement);
            return;
        }

        try {
            const response = await fetch(url, { mode: 'cors' });
            if (!response.ok) throw new Error('Network response was not ok');
            const blob = await response.blob();
            const reader = new FileReader();
            reader.onloadend = () => {
                const dataUrl = reader.result;
                cacheEmote(url, dataUrl, code, provider, countIncrement);
                if (imgElement) imgElement.src = dataUrl;
            };
            reader.readAsDataURL(blob);
        } catch (error) {
            console.error('Ошибка при кэшировании эмодзи:', url, error);
            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'].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);
                }
            });
        }, { timeout: 5000 });
    }

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

        const textNodes = getTextNodes(message);
        textNodes.forEach((node) => {
            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,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAB9SURBVEhLY2AYBaNgFIyCUTAKRsEoGAWjYBSMglEwCkbBKBgFo2AUjIJRMAoGIA0F0A4gDQXQDgAN BdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQXQDgANBdAOIA0F0A4gDQAANHYvmHjk1oAAAAAASUVORK5CYII=';
                    img.alt = emote.code;
                    img.title = emote.code;

                    const parts = text.split(emote.code);
                    text = parts.join('');
                    node.parentNode.insertBefore(img, node);
                    cacheEmote(url, emote.dataUrl, emote.code, emote.provider, 1);
                }
            }
            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) {
        const channel = currentChannel || 'global';
        if (!emoteCache[channel]) emoteCache[channel] = {};

        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');
                emotes.forEach((emote) => {
                    const url = emote.src;
                    if (!url) return;

                    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) return; // Пропускаем, если не FFZ, 7TV, BTTV

                    if (emoteCache[channel][url]?.dataUrl) {
                        emote.src = emoteCache[channel][url].dataUrl; // Мгновенная подстановка
                        cacheEmote(url, null, code, provider, 1);
                    } else {
                        fetchAndCacheEmote(emote, url, code, provider, 1);
                    }
                });

                // Замена текстовых кодов только для нового сообщения
                replaceTextCodes(message);
            });
        });
    }

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

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

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

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

    // Наблюдатель criação
    const observer = new MutationObserver((mutations) => {
        requestIdleCallback(() => processEmotes(mutations), { timeout: 500 });
    });

    // Инициализация
    cleanOldCache();
    preloadPopularEmotes();
    monitorChannelSwitch();

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

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