rython-task-bot-v2/widgets/horizontal/scripts/emotes.js
2026-08-03 15:00:04 +10:00

309 lines
7.3 KiB
JavaScript

"use strict";
class EmoteManager {
#emotes = new Map();
#channelName = "";
#channelId = "";
#providers = ["7tv", "bttv", "ffz"];
#size = "1x";
#loaded = false;
#cacheTTL = 24 * 60 * 60 * 1000;
#CACHE_KEY = "emote-cache-v1";
#pattern = null;
#patternSize = 0;
constructor(config = {}) {
this.#channelName = config.channelName || "";
this.#channelId = config.channelId || "";
if (config.providers) this.#providers = config.providers;
if (config.size) this.#size = config.size;
}
get loaded() {
return this.#loaded;
}
get emoteCount() {
return this.#emotes.size;
}
get providers() {
return [...this.#providers];
}
getEmote(name) {
return this.#emotes.get(name) || null;
}
#buildPattern() {
if (this.#emotes.size === 0) {
this.#pattern = null;
this.#patternSize = 0;
return;
}
if (this.#pattern && this.#emotes.size === this.#patternSize) return;
const sorted = [...this.#emotes.keys()].sort(
(a, b) => b.length - a.length,
);
const escaped = sorted.map((n) =>
n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
this.#pattern = new RegExp(
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
"g",
);
this.#patternSize = this.#emotes.size;
}
renderInto(container, text) {
container.replaceChildren();
if (text == null || text === "") return;
if (!this.#loaded || this.#emotes.size === 0) {
container.textContent = text;
return;
}
this.#buildPattern();
if (!this.#pattern) {
container.textContent = text;
return;
}
const pattern = new RegExp(this.#pattern.source, this.#pattern.flags);
const nodes = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(
document.createTextNode(text.slice(lastIndex, match.index)),
);
}
const name = match[0];
const emote = this.#emotes.get(name);
if (emote?.url && /^https?:\/\//i.test(emote.url)) {
const img = document.createElement("img");
img.src = emote.url;
img.alt = name;
img.title = `${name} (${emote.provider})`;
img.className = "emote-img";
nodes.push(img);
} else {
nodes.push(document.createTextNode(name));
}
lastIndex = match.index + name.length;
}
if (lastIndex < text.length) {
nodes.push(document.createTextNode(text.slice(lastIndex)));
}
container.replaceChildren(...nodes);
}
async init() {
const cached = this.#loadCache();
if (cached) {
this.#emotes = new Map(Object.entries(cached.emotes));
this.#loaded = true;
this.#patternSize = 0;
return;
}
await this.#fetchAll();
this.#loaded = true;
this.#patternSize = 0;
this.#saveCache();
}
async forceRefresh() {
this.#emotes.clear();
this.#patternSize = 0;
await this.#fetchAll();
this.#loaded = true;
this.#patternSize = 0;
this.#saveCache();
}
async #fetchAll() {
const fetchers = [];
if (this.#providers.includes("7tv")) fetchers.push(this.#fetch7TV());
if (this.#providers.includes("bttv")) fetchers.push(this.#fetchBTTV());
if (this.#providers.includes("ffz")) fetchers.push(this.#fetchFFZ());
await Promise.allSettled(fetchers);
}
async #fetch7TV() {
try {
const globalResp = await fetch(
"https://7tv.io/v3/emote-sets/global",
);
if (!globalResp.ok) return;
const globalData = await globalResp.json();
if (globalData.emotes) {
for (const emote of globalData.emotes) {
this.#add7TVEmote(emote);
}
}
if (this.#channelId) {
const userResp = await fetch(
`https://7tv.io/v3/users/twitch/${this.#channelId}`,
);
if (!userResp.ok) return;
const userData = await userResp.json();
if (userData.emote_set?.emotes) {
for (const emote of userData.emote_set.emotes) {
this.#add7TVEmote(emote);
}
}
}
} catch (e) {
console.warn("7TV emote fetch failed:", e.message);
}
}
#add7TVEmote(emote) {
const entry = emote.data || emote;
const id = entry.id || emote.id;
const name = entry.name || emote.name;
if (!id || !name) return;
const nameLower = name;
const host = entry.host.url;
this.#emotes.set(nameLower, {
url: `https:${host}/${this.#size}.webp`,
provider: "7tv",
animated: entry.animated || false,
});
}
async #fetchBTTV() {
try {
const globalResp = await fetch(
"https://api.betterttv.net/3/cached/emotes/global",
);
if (!globalResp.ok) return;
const globalData = await globalResp.json();
if (Array.isArray(globalData)) {
for (const emote of globalData) {
this.#emotes.set(emote.code, {
url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
provider: "bttv",
animated: emote.imageType === "gif",
});
}
}
if (this.#channelId) {
const userResp = await fetch(
`https://api.betterttv.net/3/cached/users/twitch/${this.#channelId}`,
);
if (!userResp.ok) return;
const userData = await userResp.json();
const allEmotes = [
...(userData.channelEmotes || []),
...(userData.sharedEmotes || []),
];
for (const emote of allEmotes) {
this.#emotes.set(emote.code, {
url: `https://cdn.betterttv.net/emote/${emote.id}/${this.#size}`,
provider: "bttv",
animated: emote.imageType === "gif",
});
}
}
} catch (e) {
console.warn("BTTV emote fetch failed:", e.message);
}
}
async #fetchFFZ() {
try {
const globalResp = await fetch(
"https://api.frankerfacez.com/v1/set/global",
);
if (!globalResp.ok) return;
const globalData = await globalResp.json();
if (globalData.sets) {
for (const setId in globalData.sets) {
const set = globalData.sets[setId];
for (const emote of set.emoticons || []) {
if (emote.modifier) continue;
this.#emotes.set(emote.name, {
url: emote.urls[this.#toFFZSize(this.#size)],
provider: "ffz",
animated: false,
});
}
}
}
const ffzId = this.#channelName || this.#channelId;
if (ffzId) {
const roomUrl = this.#channelId
? `https://api.frankerfacez.com/v1/room/id/${this.#channelId}`
: `https://api.frankerfacez.com/v1/room/${this.#channelName}`;
const roomResp = await fetch(roomUrl);
if (!roomResp.ok) return;
const roomData = await roomResp.json();
if (roomData.sets) {
for (const setId in roomData.sets) {
const set = roomData.sets[setId];
for (const emote of set.emoticons || []) {
if (emote.modifier) continue;
this.#emotes.set(emote.name, {
url: emote.urls[this.#toFFZSize(this.#size)],
provider: "ffz",
animated: false,
});
}
}
}
}
} catch (e) {
console.warn("FFZ emote fetch failed:", e.message);
}
}
#toFFZSize(size) {
switch (size) {
case "3x":
case "4x":
return "4";
case "2x":
return "2";
default:
return "1";
}
}
#saveCache() {
try {
const cache = {
timestamp: Date.now(),
channelId: this.#channelId,
emotes: Object.fromEntries(this.#emotes),
};
localStorage.setItem(this.#CACHE_KEY, JSON.stringify(cache));
} catch (e) {}
}
#loadCache() {
try {
const raw = localStorage.getItem(this.#CACHE_KEY);
if (!raw) return null;
const cache = JSON.parse(raw);
if (cache.channelId !== this.#channelId) return null;
if (Date.now() - cache.timestamp > this.#cacheTTL) return null;
return cache;
} catch (e) {
return null;
}
}
}