Add 7TV emote support

This commit is contained in:
2026-01-14 01:01:16 +01:00
parent c75ada41f9
commit 9147479b00
11 changed files with 510 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE channels ADD COLUMN allow_7tv_emotes_for_assets BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE channels
SET allow_7tv_emotes_for_assets = TRUE
WHERE allow_7tv_emotes_for_assets IS NULL;

View File

@@ -9,6 +9,7 @@ setUpElectronWindowFrame();
const renderer = new BroadcastRenderer({ canvas, scriptLayer, broadcaster, showToast });
const defaultScriptSettings = {
allowChannelEmotesForAssets: true,
allowSevenTvEmotesForAssets: true,
allowScriptChatAccess: true,
};
let currentScriptSettings = { ...defaultScriptSettings };
@@ -29,15 +30,33 @@ const settingsPromise = fetch(`/api/channels/${encodeURIComponent(broadcaster)}/
renderer.setScriptSettings(defaultScriptSettings);
});
fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`)
const emoteCatalog = { global: [], channel: [], sevenTv: [] };
const twitchEmotePromise = fetch(`/api/twitch/emotes?channel=${encodeURIComponent(broadcaster)}`)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load Twitch emotes");
}
return response.json();
})
.then((catalog) => renderer.setEmoteCatalog(catalog))
.then((catalog) => {
emoteCatalog.global = Array.isArray(catalog?.global) ? catalog.global : [];
emoteCatalog.channel = Array.isArray(catalog?.channel) ? catalog.channel : [];
})
.catch((error) => console.warn("Unable to load Twitch emotes", error));
const sevenTvEmotePromise = fetch(`/api/7tv/emotes?channel=${encodeURIComponent(broadcaster)}`)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load 7TV emotes");
}
return response.json();
})
.then((catalog) => {
emoteCatalog.sevenTv = Array.isArray(catalog?.channel) ? catalog.channel : [];
})
.catch((error) => console.warn("Unable to load 7TV emotes", error));
Promise.allSettled([twitchEmotePromise, sevenTvEmotePromise]).then(() => renderer.setEmoteCatalog(emoteCatalog));
let disconnectChat = () => {};
settingsPromise.finally(() => {
if (!currentScriptSettings.allowScriptChatAccess) {

View File

@@ -30,8 +30,11 @@ export class BroadcastRenderer {
this.emoteCatalogById = new Map();
this.globalEmotes = [];
this.channelEmotes = [];
this.sevenTvEmotes = [];
this.sevenTvEmotesByName = new Map();
this.lastChatPruneAt = 0;
this.allowChannelEmotesForAssets = true;
this.allowSevenTvEmotesForAssets = true;
this.allowScriptChatAccess = true;
this.obsBrowser = !!globalThis.obsstudio;
@@ -429,6 +432,7 @@ export class BroadcastRenderer {
setScriptSettings(settings) {
this.allowChannelEmotesForAssets = settings?.allowChannelEmotesForAssets !== false;
this.allowSevenTvEmotesForAssets = settings?.allowSevenTvEmotesForAssets !== false;
this.allowScriptChatAccess = settings?.allowScriptChatAccess !== false;
if (!this.allowScriptChatAccess) {
this.chatMessages = [];
@@ -478,15 +482,22 @@ export class BroadcastRenderer {
setEmoteCatalog(catalog) {
this.globalEmotes = Array.isArray(catalog?.global) ? catalog.global : [];
this.channelEmotes = Array.isArray(catalog?.channel) ? catalog.channel : [];
this.sevenTvEmotes = Array.isArray(catalog?.sevenTv) ? catalog.sevenTv : [];
this.refreshEmoteCatalog();
}
refreshEmoteCatalog() {
const allowedChannelEmotes = this.allowChannelEmotesForAssets ? this.channelEmotes : [];
this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes];
const allowedSevenTvEmotes = this.allowSevenTvEmotesForAssets ? this.sevenTvEmotes : [];
this.emoteCatalog = [...this.globalEmotes, ...allowedChannelEmotes, ...allowedSevenTvEmotes];
this.emoteCatalogById = new Map(
this.emoteCatalog.map((entry) => [String(entry?.id || ""), entry]).filter(([key]) => key),
);
this.sevenTvEmotesByName = new Map(
allowedSevenTvEmotes
.map((entry) => [String(entry?.name || ""), entry])
.filter(([key]) => key),
);
if (this.chatMessages.length) {
this.chatMessages = this.chatMessages.map((message) => {
const fragments = this.buildMessageFragments(message.message || "", message.tags);
@@ -534,7 +545,7 @@ export class BroadcastRenderer {
}
const emotes = this.parseEmoteOffsets(tags?.emotes);
if (!emotes.length) {
return [{ type: "text", text: message }];
return this.applySevenTvEmotes([{ type: "text", text: message }]);
}
const sorted = emotes.sort((a, b) => a.start - b.start);
const fragments = [];
@@ -561,7 +572,43 @@ export class BroadcastRenderer {
if (cursor < message.length) {
fragments.push({ type: "text", text: message.slice(cursor) });
}
return fragments;
return this.applySevenTvEmotes(fragments);
}
applySevenTvEmotes(fragments) {
if (!this.sevenTvEmotesByName.size) {
return fragments;
}
const enhanced = [];
fragments.forEach((fragment) => {
if (fragment?.type !== "text" || !fragment.text) {
enhanced.push(fragment);
return;
}
const parts = fragment.text.split(/(\\s+)/);
parts.forEach((part) => {
if (!part) {
return;
}
if (/^\\s+$/.test(part)) {
enhanced.push({ type: "text", text: part });
return;
}
const emote = this.sevenTvEmotesByName.get(part);
if (emote) {
enhanced.push({
type: "emote",
id: emote.id,
text: part,
name: emote.name || part,
url: emote.url || null,
});
} else {
enhanced.push({ type: "text", text: part });
}
});
});
return enhanced;
}
receiveChatMessage(message) {

View File

@@ -8,6 +8,7 @@ const elements = {
canvasStatus: document.getElementById("canvas-status"),
canvasSaveButton: document.getElementById("save-canvas-btn"),
allowChannelEmotes: document.getElementById("allow-channel-emotes"),
allowSevenTvEmotes: document.getElementById("allow-7tv-emotes"),
allowScriptChat: document.getElementById("allow-script-chat"),
scriptSettingsStatus: document.getElementById("script-settings-status"),
scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"),
@@ -250,6 +251,9 @@ function renderScriptSettings(settings) {
if (elements.allowChannelEmotes) {
elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false;
}
if (elements.allowSevenTvEmotes) {
elements.allowSevenTvEmotes.checked = settings.allowSevenTvEmotesForAssets !== false;
}
if (elements.allowScriptChat) {
elements.allowScriptChat.checked = settings.allowScriptChatAccess !== false;
}
@@ -260,13 +264,18 @@ async function fetchScriptSettings() {
const data = await fetchJson("/settings", {}, "Failed to load script settings");
renderScriptSettings(data);
} catch (error) {
renderScriptSettings({ allowChannelEmotesForAssets: true, allowScriptChatAccess: true });
renderScriptSettings({
allowChannelEmotesForAssets: true,
allowSevenTvEmotesForAssets: true,
allowScriptChatAccess: true,
});
showToast("Using default script settings. Unable to load saved preferences.", "warning");
}
}
async function saveScriptSettings() {
const allowChannelEmotesForAssets = elements.allowChannelEmotes?.checked ?? true;
const allowSevenTvEmotesForAssets = elements.allowSevenTvEmotes?.checked ?? true;
const allowScriptChatAccess = elements.allowScriptChat?.checked ?? true;
if (elements.scriptSettingsStatus) elements.scriptSettingsStatus.textContent = "Saving...";
setButtonBusy(elements.scriptSettingsSaveButton, true, "Saving...");
@@ -276,7 +285,11 @@ async function saveScriptSettings() {
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowChannelEmotesForAssets, allowScriptChatAccess }),
body: JSON.stringify({
allowChannelEmotesForAssets,
allowSevenTvEmotesForAssets,
allowScriptChatAccess,
}),
},
"Failed to save script settings",
);

View File

@@ -94,6 +94,10 @@
<input id="allow-channel-emotes" type="checkbox" checked />
Allow script assets to use this channel's Twitch emotes.
</label>
<label class="checkbox-row">
<input id="allow-7tv-emotes" type="checkbox" checked />
Allow script assets to use this channel's 7TV emotes.
</label>
<label class="checkbox-row">
<input id="allow-script-chat" type="checkbox" checked />
Allow script assets to access this channel's Twitch chat log.