From e6c58a1ec732de884415996857dfa5310d4ec569 Mon Sep 17 00:00:00 2001 From: liyunze <50455574+liyunze-coding@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:55:55 +1000 Subject: [PATCH] refresh, added emote support --- Code.cs | 28 +- widgets/horizontal/config.js | 23 +- widgets/horizontal/index.html | 1 + widgets/horizontal/scripts/emotes.js | 273 ++++++++++++++++++ widgets/horizontal/scripts/streamerbot.js | 81 ++---- widgets/horizontal/scripts/tasklist-view.js | 13 +- widgets/horizontal/styles/style.css | 11 + widgets/streamer-only/config.js | 14 +- widgets/streamer-only/index.html | 1 + widgets/streamer-only/scripts/emotes.js | 273 ++++++++++++++++++ widgets/streamer-only/scripts/streamerbot.js | 79 ++--- .../streamer-only/scripts/tasklist-view.js | 13 +- widgets/streamer-only/styles/style.css | 25 +- widgets/vertical/README.md | 12 +- widgets/vertical/config.js | 23 +- widgets/vertical/index.html | 1 + widgets/vertical/scripts/emotes.js | 273 ++++++++++++++++++ widgets/vertical/scripts/streamerbot.js | 81 ++---- widgets/vertical/scripts/tasklist-view.js | 13 +- widgets/vertical/styles/style.css | 11 + 20 files changed, 1016 insertions(+), 233 deletions(-) create mode 100644 widgets/horizontal/scripts/emotes.js create mode 100644 widgets/streamer-only/scripts/emotes.js create mode 100644 widgets/vertical/scripts/emotes.js diff --git a/Code.cs b/Code.cs index 08dbf28..ef9bbd2 100644 --- a/Code.cs +++ b/Code.cs @@ -464,7 +464,7 @@ public class TaskOperations } int newIndex = taskData[key].Tasks.Count - 1; - broadcast(new { mode = "add", task = taskName, completed = completed, focused = focused }, null); + broadcast(new { mode = "refresh", task = taskName, completed = completed, focused = focused }, null); return new Response<(int, string)>(true, (newIndex, taskName), null); } @@ -481,7 +481,7 @@ public class TaskOperations string oldName = userTasks[index].Name; userTasks[index].Name = newTask; SaveIntoTasks(userTasks); - broadcast(new { mode = "edit", index = index, task = newTask }, null); + broadcast(new { mode = "refresh", index = index, task = newTask }, null); return new Response<(string, string)>(true, (oldName, newTask), null); } @@ -502,7 +502,7 @@ public class TaskOperations UnfocusAll(tasks); tasks[n].Focused = true; SaveIntoTasks(tasks); - broadcast(new { mode = "focus", index = n }, null); + broadcast(new { mode = "refresh", index = n }, null); return new Response<(int, string)>(true, (n, tasks[n].Name), null); } else if (indexByName > -1) @@ -513,7 +513,7 @@ public class TaskOperations UnfocusAll(tasks); tasks[n].Focused = true; SaveIntoTasks(tasks); - broadcast(new { mode = "focus", index = n }, null); + broadcast(new { mode = "refresh", index = n }, null); return new Response<(int, string)>(true, (n, tasks[n].Name), null); } else @@ -521,7 +521,7 @@ public class TaskOperations var response = AddTask(rawInput, false, true); if (response.Success) { - broadcast(new { mode = "focus", index = response.Data.Item1 }, null); + broadcast(new { mode = "refresh", index = response.Data.Item1 }, null); return new Response<(int, string)>(true, response.Data, null); } @@ -846,7 +846,7 @@ public class CPHInline string completedTaskName = userTasks[focusedTaskIndex].Name; userTasks[focusedTaskIndex].Completed = true; userTasks[focusedTaskIndex].Focused = false; - Broadcast(new { mode = "done", index = focusedTaskIndex }, null); + Broadcast(new { mode = "refresh", index = focusedTaskIndex }, null); operations.SaveIntoTasks(userTasks); SaveTasks(); Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2)); @@ -997,7 +997,7 @@ public class CPHInline foreach (int i in taskIndices.OrderByDescending(n => n)) { userTasks.RemoveAt(i); - Broadcast(new { mode = "remove", index = i }, null); + Broadcast(new { mode = "refresh", index = i }, null); } operations.SaveIntoTasks(userTasks); @@ -1024,7 +1024,7 @@ public class CPHInline operations.RemoveUser(key); SaveTasks(); Respond(BotResponses.AdminDeleteSuccess); - Broadcast(new { mode = "admindelete", id = key }, null); + Broadcast(new { mode = "refresh", id = key }, null); return true; } @@ -1081,7 +1081,7 @@ public class CPHInline { userTasks[i].Completed = true; userTasks[i].Focused = false; - Broadcast(new { mode = "done", index = i }, null); + Broadcast(new { mode = "refresh", index = i }, null); } IncrementDoneCount(taskIndices.Count); @@ -1096,7 +1096,7 @@ public class CPHInline operations.Unfocus(); SaveTasks(); Respond(BotResponses.Unfocused); - Broadcast(new { mode = "unfocus" }, null); + Broadcast(new { mode = "refresh" }, null); return true; } @@ -1144,7 +1144,7 @@ public class CPHInline { userTasks[i].Completed = false; userTasks[i].Focused = false; - Broadcast(new { mode = "undone", index = i }, null); + Broadcast(new { mode = "refresh", index = i }, null); } operations.SaveIntoTasks(userTasks); @@ -1169,7 +1169,7 @@ public class CPHInline operations.ClearUserCompletedTasks(key); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "clearmydone" }, null); + Broadcast(new { mode = "refresh" }, null); Respond(BotResponses.ClearMyDone); return true; } @@ -1179,7 +1179,7 @@ public class CPHInline operations.ClearCompletedTasks(); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "cleardone" }, null); + Broadcast(new { mode = "refresh" }, null); Respond(BotResponses.ClearDone); return true; } @@ -1189,7 +1189,7 @@ public class CPHInline operations.FilterToStreamers(GetStreamerUsernames()); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "clearns" }, null); + Broadcast(new { mode = "refresh" }, null); Respond(BotResponses.ClearNotStreamer); return true; } diff --git a/widgets/horizontal/config.js b/widgets/horizontal/config.js index d8d4fb0..be20050 100644 --- a/widgets/horizontal/config.js +++ b/widgets/horizontal/config.js @@ -3,7 +3,7 @@ const configs = (function () { const streamerBotSettings = { host: "127.0.0.1", - port: 8080, + port: 6968, endpoint: "/", }; @@ -17,18 +17,23 @@ const configs = (function () { "!clearmydone", ]; - const twitchSettings = { - autoUserColor: true - } + const userColorSettings = { + autoUserColor: true, + }; - const kickSettings = { - autouserColor: true - } + const emoteSettings = { + enabled: true, + channelName: "rythondev", + channelId: "248474026", + providers: ["7tv", "bttv", "ffz"], + size: "1x", + cacheHours: 24, + }; return { streamerBotSettings, commands, - twitchSettings, - kickSettings + userColorSettings, + emoteSettings, }; })(); diff --git a/widgets/horizontal/index.html b/widgets/horizontal/index.html index 43b57f5..6fc7ee0 100644 --- a/widgets/horizontal/index.html +++ b/widgets/horizontal/index.html @@ -5,6 +5,7 @@ + diff --git a/widgets/horizontal/scripts/emotes.js b/widgets/horizontal/scripts/emotes.js new file mode 100644 index 0000000..881f87e --- /dev/null +++ b/widgets/horizontal/scripts/emotes.js @@ -0,0 +1,273 @@ +"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( + `(? { + const emote = this.#emotes.get(match); + if (!emote) return match; + console.log(emote); + return `${match}`; + }); + } + + 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; + } + } +} diff --git a/widgets/horizontal/scripts/streamerbot.js b/widgets/horizontal/scripts/streamerbot.js index 00a73b6..f838b36 100644 --- a/widgets/horizontal/scripts/streamerbot.js +++ b/widgets/horizontal/scripts/streamerbot.js @@ -21,8 +21,9 @@ function getAllLocalstorage() { } client.on("General.Custom", (data) => onCustom(data)); -if (configs.twitchSettings.autoUserColor) { - client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); +if (configs.userColorSettings.autoUserColor) { + client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); + client.on("Kick.ChatMessage", (data) => onChatMessage(data)); } let taskList; @@ -57,12 +58,10 @@ function relativeLuminance({ r, g, b }) { return 0.2126 * R + 0.7152 * G + 0.0722 * B; } -async function onTwitchFirstWord(data) { +async function onChatMessage(data) { let userColor = data.data.user.color; // hex colour #FF69B4 - - - if (userColor != undefined || userColor != 'undefined') { + if (userColor != undefined || userColor != "undefined") { // update localstorage // get id: platform-userID let userId = data.data.user.id; @@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) { localStorage.setItem(`${key}-color`, userColor); userColors[`${key}-color`] = userColor; } - + return; } @@ -135,6 +134,22 @@ async function refresh() { // LOAD TASK LIST async function onConnect() { taskList = new TaskList(".task-panel"); + + if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) { + window.emoteManager = new EmoteManager({ + channelName: configs.emoteSettings.channelName, + channelId: configs.emoteSettings.channelId, + providers: configs.emoteSettings.providers, + size: configs.emoteSettings.size, + }); + + window.emoteManager.init().then(() => { + if (taskList) { + taskList.load(taskList.getData()); + } + }); + } + refresh(); } @@ -162,59 +177,9 @@ function onCustom(payload) { const username = data.username; switch (body.mode) { - case "add": - // body.task; - let taskPayload = { - text: body.task, - done: body.completed, - focused: body.focused, - }; - - let usernameColor = - userColors[id] ?? localStorage.getItem(`${id}-color`); - - if (configs.twitchSettings.autoUserColor && usernameColor != undefined && usernameColor != null) { - taskPayload["color"] = usernameColor; - } - - taskList.addTask(id, taskPayload, username); - break; - case "focus": - taskList.focusTask(id, body.index, usernameColor); - break; - case "edit": - taskList.editTask(id, body.index, body.task, usernameColor); - break; - case "remove": - taskList.removeTask(id, body.index, usernameColor); - break; - case "done": - taskList.doneTask(id, body.index, usernameColor); - break; - case "admindelete": - taskList.removeSection(body.id); - break; - case "clearns": + case "refresh": refresh(); break; - case "cleardone": - taskList.cleardone(); - break; - case "clearmydone": - taskList.clearmydone(id); - break; - case "clearall": - refresh(); - break; - case "undone": - taskList.undoneTask(id, body.index); - break; - case "unfocus": - taskList.unfocusTask(id); - break; - case "admindelete": - taskList.removeSection(body.id); - break; default: break; } diff --git a/widgets/horizontal/scripts/tasklist-view.js b/widgets/horizontal/scripts/tasklist-view.js index 283a9a0..62b5911 100644 --- a/widgets/horizontal/scripts/tasklist-view.js +++ b/widgets/horizontal/scripts/tasklist-view.js @@ -302,6 +302,13 @@ class TaskList { // ── dom helpers ──────────────────────────────────── + #parseTaskText(text) { + if (window.emoteManager && window.emoteManager.loaded) { + return window.emoteManager.parseText(text); + } + return text; + } + #createTaskEl(task, index) { const div = document.createElement("div"); div.className = @@ -314,7 +321,8 @@ class TaskList { numberSpan.textContent = `${index + 1}.`; const textSpan = document.createElement("span"); - textSpan.textContent = task.text; + textSpan.className = "task-text"; + textSpan.innerHTML = this.#parseTaskText(task.text); div.replaceChildren(numberSpan, textSpan); return div; @@ -366,7 +374,8 @@ class TaskList { } if (el.dataset.text !== task.text) { el.dataset.text = task.text; - el.querySelector("span:last-child").textContent = task.text; + const textEl = el.querySelector(".task-text") || el.querySelector("span:last-child"); + textEl.innerHTML = this.#parseTaskText(task.text); } el.querySelector(".task-number").textContent = `${i + 1}.`; diff --git a/widgets/horizontal/styles/style.css b/widgets/horizontal/styles/style.css index 650f1f3..a77a541 100644 --- a/widgets/horizontal/styles/style.css +++ b/widgets/horizontal/styles/style.css @@ -141,3 +141,14 @@ body { min-width: 16px; text-align: right; } + +.emote-img { + height: 1.4em; + vertical-align: middle; + display: inline-block; + margin: -0.2em 0; +} + +.task.done .emote-img { + opacity: 0.5; +} diff --git a/widgets/streamer-only/config.js b/widgets/streamer-only/config.js index 72d9fb6..cf97326 100644 --- a/widgets/streamer-only/config.js +++ b/widgets/streamer-only/config.js @@ -3,7 +3,7 @@ const configs = (function () { const streamerBotSettings = { host: "127.0.0.1", - port: 8080, + port: 6968, endpoint: "/", }; @@ -19,12 +19,17 @@ const configs = (function () { "!clearmydone", ]; - const twitchSettings = { + const userColorSettings = { autoUserColor: true, }; - const kickSettings = { - autouserColor: true, + const emoteSettings = { + enabled: true, + channelName: "rythondev", + channelId: "248474026", + providers: ["7tv", "bttv", "ffz"], + size: "1x", + cacheHours: 24, }; return { @@ -33,5 +38,6 @@ const configs = (function () { commands, twitchSettings, kickSettings, + emoteSettings, }; })(); diff --git a/widgets/streamer-only/index.html b/widgets/streamer-only/index.html index 43b57f5..6fc7ee0 100644 --- a/widgets/streamer-only/index.html +++ b/widgets/streamer-only/index.html @@ -5,6 +5,7 @@ + diff --git a/widgets/streamer-only/scripts/emotes.js b/widgets/streamer-only/scripts/emotes.js new file mode 100644 index 0000000..881f87e --- /dev/null +++ b/widgets/streamer-only/scripts/emotes.js @@ -0,0 +1,273 @@ +"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( + `(? { + const emote = this.#emotes.get(match); + if (!emote) return match; + console.log(emote); + return `${match}`; + }); + } + + 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; + } + } +} diff --git a/widgets/streamer-only/scripts/streamerbot.js b/widgets/streamer-only/scripts/streamerbot.js index 36bf696..9dfa7bd 100644 --- a/widgets/streamer-only/scripts/streamerbot.js +++ b/widgets/streamer-only/scripts/streamerbot.js @@ -21,8 +21,9 @@ function getAllLocalstorage() { } client.on("General.Custom", (data) => onCustom(data)); -if (configs.twitchSettings.autoUserColor) { - client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); +if (configs.userColorSettings.autoUserColor) { + client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); + client.on("Kick.ChatMessage", (data) => onChatMessage(data)); } let taskList; @@ -57,7 +58,7 @@ function relativeLuminance({ r, g, b }) { return 0.2126 * R + 0.7152 * G + 0.0722 * B; } -async function onTwitchFirstWord(data) { +async function onChatMessage(data) { let userColor = data.data.user.color; // hex colour if (userColor != undefined || userColor != "undefined") { @@ -134,6 +135,22 @@ async function refresh() { // LOAD TASK LIST async function onConnect() { taskList = new TaskList(".task-panel"); + + if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) { + window.emoteManager = new EmoteManager({ + channelName: configs.emoteSettings.channelName, + channelId: configs.emoteSettings.channelId, + providers: configs.emoteSettings.providers, + size: configs.emoteSettings.size, + }); + + window.emoteManager.init().then(() => { + if (taskList) { + taskList.load(taskList.getData()); + } + }); + } + refresh(); } @@ -161,63 +178,9 @@ function onCustom(payload) { const username = data.username; switch (body.mode) { - case "add": - // body.task; - let taskPayload = { - text: body.task, - done: body.completed, - focused: body.focused, - }; - - let usernameColor = - userColors[id] ?? localStorage.getItem(`${id}-color`); - - if ( - configs.twitchSettings.autoUserColor && - usernameColor != undefined && - usernameColor != null - ) { - taskPayload["color"] = usernameColor; - } - - taskList.addTask(id, taskPayload, username); - break; - case "focus": - taskList.focusTask(id, body.index, usernameColor); - break; - case "edit": - taskList.editTask(id, body.index, body.task, usernameColor); - break; - case "remove": - taskList.removeTask(id, body.index, usernameColor); - break; - case "done": - taskList.doneTask(id, body.index, usernameColor); - break; - case "admindelete": - taskList.removeSection(body.id); - break; - case "clearns": + case "refresh": refresh(); break; - case "cleardone": - taskList.cleardone(); - break; - case "clearmydone": - taskList.clearmydone(id); - break; - case "clearall": - refresh(); - break; - case "undone": - taskList.undoneTask(id, body.index); - break; - case "unfocus": - taskList.unfocusTask(id); - break; - case "admindelete": - taskList.removeSection(body.id); - break; default: break; } diff --git a/widgets/streamer-only/scripts/tasklist-view.js b/widgets/streamer-only/scripts/tasklist-view.js index 455f49b..88af472 100644 --- a/widgets/streamer-only/scripts/tasklist-view.js +++ b/widgets/streamer-only/scripts/tasklist-view.js @@ -279,6 +279,13 @@ class TaskList { // ── dom helpers ──────────────────────────────────── + #parseTaskText(text) { + if (window.emoteManager && window.emoteManager.loaded) { + return window.emoteManager.parseText(text); + } + return text; + } + #createTaskEl(task, index) { const div = document.createElement("div"); div.className = @@ -291,7 +298,8 @@ class TaskList { numberSpan.textContent = `${index + 1}.`; const textSpan = document.createElement("span"); - textSpan.textContent = task.text; + textSpan.className = "task-text"; + textSpan.innerHTML = this.#parseTaskText(task.text); div.replaceChildren(numberSpan, textSpan); return div; @@ -343,7 +351,8 @@ class TaskList { } if (el.dataset.text !== task.text) { el.dataset.text = task.text; - el.querySelector("span:last-child").textContent = task.text; + const textEl = el.querySelector(".task-text") || el.querySelector("span:last-child"); + textEl.innerHTML = this.#parseTaskText(task.text); } el.querySelector(".task-number").textContent = `${i + 1}.`; diff --git a/widgets/streamer-only/styles/style.css b/widgets/streamer-only/styles/style.css index 69bfdcf..ee2c2d7 100644 --- a/widgets/streamer-only/styles/style.css +++ b/widgets/streamer-only/styles/style.css @@ -19,18 +19,18 @@ --focused-padding: 2px 4px; /* Spacing inside focused task highlight */ /* -- Font Sizes -- */ - --title-font-size: 1.5rem; /* Size of the panel title */ - --section-title-font-size: 1.25rem; /* Size of section headings */ - --header-font-size: 14px; /* Size of the header text */ - --header-subtitle-font-size: 13px; /* Size of the header subtitle */ - --task-font-size: 1.2rem; /* Size of task text */ + --title-font-size: 1rem; /* Size of the panel title */ + --section-title-font-size: 1rem; /* Size of section headings */ + --header-font-size: 1rem; /* Size of the header text */ + --header-subtitle-font-size: 1rem; /* Size of the header subtitle */ + --task-font-size: 1rem; /* Size of task text */ /* -- Borders & Dividers -- */ --header-border-color: #e0dbd5; /* Line below the header */ --section-border-color: white; /* Line between sections */ /* -- Spacing -- */ - --header-padding: 16px 16px 12px; /* Spacing inside the header */ + --header-padding: 10px 16px 5px; /* Spacing inside the header */ --section-padding: 10px 16px 6px; /* Spacing inside each section */ --task-gap: 6px; /* Gap between task items */ --task-padding: 3px 0; /* Spacing around each task */ @@ -104,7 +104,7 @@ body { } .section-title.has-user-color { - color: var(--user-color); /*auto user color*/ + color: var(--user-color); /*auto user color*/ } .task.done, @@ -129,3 +129,14 @@ body { min-width: 16px; text-align: right; } + +.emote-img { + height: 1.4em; + vertical-align: middle; + display: inline-block; + margin: -0.2em 0; +} + +.task.done .emote-img { + opacity: 0.5; +} diff --git a/widgets/vertical/README.md b/widgets/vertical/README.md index f49b08a..aec396b 100644 --- a/widgets/vertical/README.md +++ b/widgets/vertical/README.md @@ -1,8 +1,8 @@ # Widget - Rython Task Bot v2 -## Instructions +Optional widget for [Rython Task Bot v2](https://github.com/liyunze-coding/rython-task-bot-v2) -### Websocket Server +## Instructions Websocket server running is required for browser source to work. @@ -13,14 +13,6 @@ Websocket server running is required for browser source to work. 3. If port number already taken, change port number 4. Ensure that [config.js](./config.js)'s websocket settings are in-sync with Streamer.Bot's -### OBS - -1. Add a new browser source in your scene -2. Tick on "Local" -3. Navigate to the index.html file -4. Optionally customise the width and height -5. Click on "Ok" - ## Style Customisation Note: `styles/structure.css` is meant to be the bare structure to allow the task widget to be functional. diff --git a/widgets/vertical/config.js b/widgets/vertical/config.js index d8d4fb0..be20050 100644 --- a/widgets/vertical/config.js +++ b/widgets/vertical/config.js @@ -3,7 +3,7 @@ const configs = (function () { const streamerBotSettings = { host: "127.0.0.1", - port: 8080, + port: 6968, endpoint: "/", }; @@ -17,18 +17,23 @@ const configs = (function () { "!clearmydone", ]; - const twitchSettings = { - autoUserColor: true - } + const userColorSettings = { + autoUserColor: true, + }; - const kickSettings = { - autouserColor: true - } + const emoteSettings = { + enabled: true, + channelName: "rythondev", + channelId: "248474026", + providers: ["7tv", "bttv", "ffz"], + size: "1x", + cacheHours: 24, + }; return { streamerBotSettings, commands, - twitchSettings, - kickSettings + userColorSettings, + emoteSettings, }; })(); diff --git a/widgets/vertical/index.html b/widgets/vertical/index.html index 43b57f5..6fc7ee0 100644 --- a/widgets/vertical/index.html +++ b/widgets/vertical/index.html @@ -5,6 +5,7 @@ + diff --git a/widgets/vertical/scripts/emotes.js b/widgets/vertical/scripts/emotes.js new file mode 100644 index 0000000..881f87e --- /dev/null +++ b/widgets/vertical/scripts/emotes.js @@ -0,0 +1,273 @@ +"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( + `(? { + const emote = this.#emotes.get(match); + if (!emote) return match; + console.log(emote); + return `${match}`; + }); + } + + 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; + } + } +} diff --git a/widgets/vertical/scripts/streamerbot.js b/widgets/vertical/scripts/streamerbot.js index 00a73b6..f838b36 100644 --- a/widgets/vertical/scripts/streamerbot.js +++ b/widgets/vertical/scripts/streamerbot.js @@ -21,8 +21,9 @@ function getAllLocalstorage() { } client.on("General.Custom", (data) => onCustom(data)); -if (configs.twitchSettings.autoUserColor) { - client.on("Twitch.ChatMessage", (data) => onTwitchFirstWord(data)); +if (configs.userColorSettings.autoUserColor) { + client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); + client.on("Kick.ChatMessage", (data) => onChatMessage(data)); } let taskList; @@ -57,12 +58,10 @@ function relativeLuminance({ r, g, b }) { return 0.2126 * R + 0.7152 * G + 0.0722 * B; } -async function onTwitchFirstWord(data) { +async function onChatMessage(data) { let userColor = data.data.user.color; // hex colour #FF69B4 - - - if (userColor != undefined || userColor != 'undefined') { + if (userColor != undefined || userColor != "undefined") { // update localstorage // get id: platform-userID let userId = data.data.user.id; @@ -71,7 +70,7 @@ async function onTwitchFirstWord(data) { localStorage.setItem(`${key}-color`, userColor); userColors[`${key}-color`] = userColor; } - + return; } @@ -135,6 +134,22 @@ async function refresh() { // LOAD TASK LIST async function onConnect() { taskList = new TaskList(".task-panel"); + + if (configs.emoteSettings.enabled && configs.emoteSettings.channelId) { + window.emoteManager = new EmoteManager({ + channelName: configs.emoteSettings.channelName, + channelId: configs.emoteSettings.channelId, + providers: configs.emoteSettings.providers, + size: configs.emoteSettings.size, + }); + + window.emoteManager.init().then(() => { + if (taskList) { + taskList.load(taskList.getData()); + } + }); + } + refresh(); } @@ -162,59 +177,9 @@ function onCustom(payload) { const username = data.username; switch (body.mode) { - case "add": - // body.task; - let taskPayload = { - text: body.task, - done: body.completed, - focused: body.focused, - }; - - let usernameColor = - userColors[id] ?? localStorage.getItem(`${id}-color`); - - if (configs.twitchSettings.autoUserColor && usernameColor != undefined && usernameColor != null) { - taskPayload["color"] = usernameColor; - } - - taskList.addTask(id, taskPayload, username); - break; - case "focus": - taskList.focusTask(id, body.index, usernameColor); - break; - case "edit": - taskList.editTask(id, body.index, body.task, usernameColor); - break; - case "remove": - taskList.removeTask(id, body.index, usernameColor); - break; - case "done": - taskList.doneTask(id, body.index, usernameColor); - break; - case "admindelete": - taskList.removeSection(body.id); - break; - case "clearns": + case "refresh": refresh(); break; - case "cleardone": - taskList.cleardone(); - break; - case "clearmydone": - taskList.clearmydone(id); - break; - case "clearall": - refresh(); - break; - case "undone": - taskList.undoneTask(id, body.index); - break; - case "unfocus": - taskList.unfocusTask(id); - break; - case "admindelete": - taskList.removeSection(body.id); - break; default: break; } diff --git a/widgets/vertical/scripts/tasklist-view.js b/widgets/vertical/scripts/tasklist-view.js index 969713d..ed5be9a 100644 --- a/widgets/vertical/scripts/tasklist-view.js +++ b/widgets/vertical/scripts/tasklist-view.js @@ -302,6 +302,13 @@ class TaskList { // ── dom helpers ──────────────────────────────────── + #parseTaskText(text) { + if (window.emoteManager && window.emoteManager.loaded) { + return window.emoteManager.parseText(text); + } + return text; + } + #createTaskEl(task, index) { const div = document.createElement("div"); div.className = @@ -314,7 +321,8 @@ class TaskList { numberSpan.textContent = `${index + 1}.`; const textSpan = document.createElement("span"); - textSpan.textContent = task.text; + textSpan.className = "task-text"; + textSpan.innerHTML = this.#parseTaskText(task.text); div.replaceChildren(numberSpan, textSpan); return div; @@ -366,7 +374,8 @@ class TaskList { } if (el.dataset.text !== task.text) { el.dataset.text = task.text; - el.querySelector("span:last-child").textContent = task.text; + const textEl = el.querySelector(".task-text") || el.querySelector("span:last-child"); + textEl.innerHTML = this.#parseTaskText(task.text); } el.querySelector(".task-number").textContent = `${i + 1}.`; diff --git a/widgets/vertical/styles/style.css b/widgets/vertical/styles/style.css index 69bfdcf..7a09d04 100644 --- a/widgets/vertical/styles/style.css +++ b/widgets/vertical/styles/style.css @@ -129,3 +129,14 @@ body { min-width: 16px; text-align: right; } + +.emote-img { + height: 1.4em; + vertical-align: middle; + display: inline-block; + margin: -0.2em 0; +} + +.task.done .emote-img { + opacity: 0.5; +}