Greasy Fork

Greasy Fork is available in English.

Emote Cache for 7TV, FFZ, BTTV 1.32.11

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

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

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