// ==UserScript==
// @name Emote Cache for 7TV, FFZ, BTTV 1.32.19
// @namespace http://tampermonkey.net/
// @version 1.32.18
// @description Cache frequently used Twitch emotes using IndexedDB with clean URLs to reduce load delay
// @author gaullampis810
// @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 = 2;
const CACHE_EXPIRY = 2 * 60 * 60 * 1000;
const MAX_CACHE_BYTES = 5 * 1024 * 1024;
const USE_BROWSER_CACHE = true;
const RETRY_INTERVAL = 5000; // Интервал для повторных попыток (5 секунд)
const MAX_RETRY_ATTEMPTS = 50; // Максимальное количество попыток загрузки
const failedEmotes = new Map(); // Хранит { url: { element, attempts, code, provider } }
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: -16px;
color: #1d968a;
font-size: 10px;
padding: 1px 2px;
border-radius: 18px;
white-space: nowrap;
pointer-events: none;
z-index: -2;
line-height: 8px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
background: none;
}
.chat-line__message .emote-container {
position: relative;
display: inline-block;
vertical-align: middle;
line-height: normal;
}
.chat-line__message .emote-container img {
position: relative;
z-index: 1;
vertical-align: middle !important;
margin: 0 !important;
padding: 0 !important;
}
`;
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;
}
}
// Проверка полупрозрачности смайла
const transparencyCache = new Map();
async function isTransparentEmote(url, code) {
if (transparencyCache.has(url)) {
log('Using cached transparency result for emote:', url, transparencyCache.get(url));
return transparencyCache.get(url);
}
if (code.match(/[wcvhlrz]!$/i)) {
log('Skipping transparency check for effect emote:', url, code);
transparencyCache.set(url, true);
return true;
}
try {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = url;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Image failed to load'));
});
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height).data;
for (let i = 3; i < imageData.length; i += 4) {
if (imageData[i] < 255) {
transparencyCache.set(url, true);
return true;
}
}
transparencyCache.set(url, false);
return false;
} catch (e) {
log('Error checking transparency for emote:', url, e);
transparencyCache.set(url, false);
return false;
}
}
// Загрузка кэша из 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');
// Сохраняем в failedEmotes
failedEmotes.set(normalizedUrl, {
element: img,
attempts: 0,
code: '',
provider: ''
});
};
img.onload = () => {
log('Preloaded emote:', normalizedUrl);
markEmote(normalizedUrl, 'cached');
failedEmotes.delete(normalizedUrl); // Удаляем из неудавшихся
};
window.__emoteCache = window.__emoteCache || {};
window.__emoteCache[normalizedUrl] = img;
});
}
// Пометка смайлов
async function markEmote(url, status) {
const emotes = document.querySelectorAll(`.chat-line__message img[src="${url}"]`);
emotes.forEach(async (emote) => {
if (emote.parentElement.querySelector(`.emote-label[data-emote-url="${url}"]`)) {
log('Label already exists for emote:', url);
return;
}
if (!emote.complete || emote.naturalWidth === 0) {
log('Skipping label for unloaded emote:', url);
return;
}
const width = emote.naturalWidth || emote.width;
const height = emote.naturalHeight || emote.height;
if (width < 16 || height < 16) {
log('Skipping label for small emote:', url, `size: ${width}x${height}`);
return;
}
const code = emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '';
const isTransparent = await isTransparentEmote(url, code);
if (isTransparent) {
log('Skipping label for transparent emote:', url, code);
return;
}
let container = emote.closest('.emote-container');
if (!container) {
log('No emote-container found for emote:', url);
return;
}
const leftOffset = emote.offsetLeft;
const label = document.createElement('span');
label.classList.add('emote-label');
label.textContent = status;
label.setAttribute('data-emote-url', url);
label.style.left = `${leftOffset}px`;
container.appendChild(label);
// Добавляем возможность клика для повторной загрузки
if (status === 'failed') {
emote.style.cursor = 'pointer';
emote.title = 'Click to retry loading';
emote.addEventListener('click', () => {
retryEmote(url, emote);
});
}
log(`Added ${status} label to emote:`, url, `size: ${width}x${height}`, 'position:', `left: ${label.style.left}, bottom: -16px`, 'code:', code);
});
}
// Повторная попытка загрузки смайла
async function retryEmote(url, emoteElement) {
const emoteData = failedEmotes.get(url);
if (!emoteData || emoteData.attempts >= MAX_RETRY_ATTEMPTS) {
log('Max retry attempts reached or emote not found:', url);
return;
}
emoteData.attempts += 1;
failedEmotes.set(url, emoteData);
log(`Retrying emote: ${url}, attempt ${emoteData.attempts}`);
// Обновляем метку на "retrying"
const label = emoteElement.parentElement.querySelector(`.emote-label[data-emote-url="${url}"]`);
if (label) {
label.textContent = 'retrying';
}
const img = new Image();
img.src = url;
img.onerror = () => {
log(`Retry failed for emote: ${url}, attempt ${emoteData.attempts}`);
markEmote(url, 'failed');
};
img.onload = () => {
log(`Retry successful for emote: ${url}`);
emoteElement.src = url; // Обновляем src элемента в чате
markEmote(url, 'cached');
failedEmotes.delete(url); // Удаляем из неудавшихся
// Обновляем кэш
cacheEmote(url, emoteData.code || emoteElement.alt || '', emoteData.provider || '');
};
}
// Автоматическая повторная загрузка неудавшихся смайлов
function retryFailedEmotes() {
if (!isActiveTab) {
log('Skipping retryFailedEmotes: tab is not active');
return;
}
failedEmotes.forEach((emoteData, url) => {
if (emoteData.attempts < MAX_RETRY_ATTEMPTS) {
retryEmote(url, emoteData.element);
} else {
log(`Max retry attempts reached for 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(async (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');
// Сохраняем в failedEmotes
failedEmotes.set(normalizedUrl, {
element: emote,
attempts: 0,
code: emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '',
provider: emote.classList.contains('bttv-emote') ? 'bttv' :
emote.classList.contains('seventv-emote') ? '7tv' :
emote.classList.contains('ffz-emote') ? 'ffz' : ''
});
};
if (emote.complete && emote.naturalWidth === 0) {
log('Emote failed to load (invalid image):', normalizedUrl);
markEmote(normalizedUrl, 'failed');
failedEmotes.set(normalizedUrl, {
element: emote,
attempts: 0,
code: emote.alt || emote.title || emote.getAttribute('data-tooltip-type') || '',
provider: emote.classList.contains('bttv-emote') ? 'bttv' :
emote.classList.contains('seventv-emote') ? '7tv' :
emote.classList.contains('ffz-emote') ? 'ffz' : ''
});
}
}
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;
}
const isEffectModifier = code.match(/[wcvhlrz]!$/i);
if (isEffectModifier) {
log('Detected effect modifier:', code, normalizedUrl);
}
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);
setInterval(retryFailedEmotes, RETRY_INTERVAL);
};
dbRequest.onerror = () => {
console.error('[EmoteCacher] Failed to open IndexedDB:', dbRequest.error);
};
})();