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