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