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