diff --git a/Code.cs b/Code.cs index ef9bbd2..c6a2d66 100644 --- a/Code.cs +++ b/Code.cs @@ -464,7 +464,7 @@ public class TaskOperations } int newIndex = taskData[key].Tasks.Count - 1; - broadcast(new { mode = "refresh", task = taskName, completed = completed, focused = focused }, null); + broadcast(new { mode = "add", 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 = "refresh", index = index, task = newTask }, null); + broadcast(new { mode = "edit", 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 = "refresh", index = n }, null); + broadcast(new { mode = "focus", 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 = "refresh", index = n }, null); + broadcast(new { mode = "focus", 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 = "refresh", index = response.Data.Item1 }, null); + broadcast(new { mode = "focus", index = response.Data.Item1 }, null); return new Response<(int, string)>(true, response.Data, null); } @@ -846,9 +846,9 @@ public class CPHInline string completedTaskName = userTasks[focusedTaskIndex].Name; userTasks[focusedTaskIndex].Completed = true; userTasks[focusedTaskIndex].Focused = false; - Broadcast(new { mode = "refresh", index = focusedTaskIndex }, null); operations.SaveIntoTasks(userTasks); SaveTasks(); + Broadcast(new { mode = "done", index = focusedTaskIndex }, null); Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2)); return true; } @@ -997,12 +997,15 @@ public class CPHInline foreach (int i in taskIndices.OrderByDescending(n => n)) { userTasks.RemoveAt(i); - Broadcast(new { mode = "refresh", index = i }, null); } operations.SaveIntoTasks(userTasks); operations.Cleanup(true); SaveTasks(); + foreach (int i in taskIndices.OrderByDescending(n => n)) + { + Broadcast(new { mode = "remove", index = i }, null); + } Respond(MessageBuilder.BuildRemoveMessage(tasksRemoved, tasksFailedToRemove, allTasks)); return true; } @@ -1024,7 +1027,7 @@ public class CPHInline operations.RemoveUser(key); SaveTasks(); Respond(BotResponses.AdminDeleteSuccess); - Broadcast(new { mode = "refresh", id = key }, null); + Broadcast(new { mode = "admindelete", id = key }, null); return true; } @@ -1081,12 +1084,15 @@ public class CPHInline { userTasks[i].Completed = true; userTasks[i].Focused = false; - Broadcast(new { mode = "refresh", index = i }, null); } IncrementDoneCount(taskIndices.Count); operations.SaveIntoTasks(userTasks); SaveTasks(); + foreach (int i in taskIndices) + { + Broadcast(new { mode = "done", index = i }, null); + } Respond(MessageBuilder.BuildCompletedMessage(tasksCompleted, tasksFailedToComplete, allTasks)); return true; } @@ -1096,7 +1102,7 @@ public class CPHInline operations.Unfocus(); SaveTasks(); Respond(BotResponses.Unfocused); - Broadcast(new { mode = "refresh" }, null); + Broadcast(new { mode = "unfocus" }, null); return true; } @@ -1144,11 +1150,14 @@ public class CPHInline { userTasks[i].Completed = false; userTasks[i].Focused = false; - Broadcast(new { mode = "refresh", index = i }, null); } operations.SaveIntoTasks(userTasks); SaveTasks(); + foreach (int i in taskIndices) + { + Broadcast(new { mode = "undone", index = i }, null); + } Respond(MessageBuilder.BuildUndoneMessage(tasksCompleted, tasksFailedToComplete)); return true; } @@ -1169,7 +1178,7 @@ public class CPHInline operations.ClearUserCompletedTasks(key); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "refresh" }, null); + Broadcast(new { mode = "clearmydone" }, null); Respond(BotResponses.ClearMyDone); return true; } @@ -1179,7 +1188,7 @@ public class CPHInline operations.ClearCompletedTasks(); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "refresh" }, null); + Broadcast(new { mode = "cleardone" }, null); Respond(BotResponses.ClearDone); return true; } @@ -1189,7 +1198,7 @@ public class CPHInline operations.FilterToStreamers(GetStreamerUsernames()); operations.Cleanup(false); SaveTasks(); - Broadcast(new { mode = "refresh" }, null); + Broadcast(new { mode = "clearns" }, null); Respond(BotResponses.ClearNotStreamer); return true; } diff --git a/widgets/horizontal/README.md b/widgets/horizontal/README.md index f49b08a..c8eb6d8 100644 --- a/widgets/horizontal/README.md +++ b/widgets/horizontal/README.md @@ -6,7 +6,7 @@ Websocket server running is required for browser source to work. -> ![websocket](./images/websocket.png) +> ![websocket](../shared/images/websocket.png) 1. Streamer.Bot -> Servers/Clients -> Websocket Server 2. Auto Start: `ON`; Click on `Start Server` diff --git a/widgets/horizontal/index.html b/widgets/horizontal/index.html index 6fc7ee0..318dfcf 100644 --- a/widgets/horizontal/index.html +++ b/widgets/horizontal/index.html @@ -3,11 +3,11 @@ - + - - - + + + diff --git a/widgets/horizontal/scripts/emotes.js b/widgets/horizontal/scripts/emotes.js deleted file mode 100644 index c6eafb9..0000000 --- a/widgets/horizontal/scripts/emotes.js +++ /dev/null @@ -1,273 +0,0 @@ -"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 deleted file mode 100644 index 51a2b9c..0000000 --- a/widgets/horizontal/scripts/streamerbot.js +++ /dev/null @@ -1,200 +0,0 @@ -// STREAMER.BOT SETTINGS - -const client = new StreamerbotClient({ - host: configs.streamerBotSettings.host, - port: configs.streamerBotSettings.port, - endpoint: configs.streamerBotSettings.endpoint, - onConnect: onConnect, - onDisconnect: onDisconnect, - onError: onError, -}); - -function getAllLocalstorage() { - const allLocalStorage = {}; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - allLocalStorage[key] = localStorage.getItem(key); - } - - return JSON.stringify(allLocalStorage); -} - -client.on("General.Custom", (data) => onCustom(data)); -if (configs.userColorSettings.autoUserColor) { - client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); - client.on("Kick.ChatMessage", (data) => onChatMessage(data)); -} - -let taskList; -let userColors = {}; - -function parseHexColor(hex) { - if (typeof hex !== "string") return null; - const s = hex.trim(); - const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); - if (!m) return null; - let h = m[1].toLowerCase(); - if (h.length === 3) { - h = h - .split("") - .map((c) => c + c) - .join(""); - } - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - return { r, g, b }; -} - -function relativeLuminance({ r, g, b }) { - const toLinear = (v) => { - const s = v / 255; - return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); - }; - const R = toLinear(r); - const G = toLinear(g); - const B = toLinear(b); - return 0.2126 * R + 0.7152 * G + 0.0722 * B; -} - -async function onChatMessage(data) { - let userColor = data.data.user.color; // hex colour #FF69B4 - - if (userColor != undefined || userColor != "undefined") { - // update localstorage - // get id: platform-userID - let userId = data.data.user.id; - let key = `twitch-${userId}`; - - localStorage.setItem(`${key}-color`, userColor); - userColors[`${key}-color`] = userColor; - } - - return; -} - -function onDisconnect() { - showConnectionError("Connection Failed: Unable to connect to Streamer.bot"); -} - -function onError(err) { - showConnectionError( - "Connection Failed: " + (err?.message || "Unknown error"), - ); -} - -function showConnectionError(message) { - // Remove existing popup if any - const existing = document.getElementById("connection-error"); - if (existing) existing.remove(); - - const popup = document.createElement("div"); - popup.id = "connection-error"; - popup.textContent = message; - Object.assign(popup.style, { - position: "fixed", - top: "20px", - left: "50%", - transform: "translateX(-50%)", - background: "#e53935", - color: "#fff", - padding: "12px 24px", - borderRadius: "8px", - fontFamily: "'Fredoka', sans-serif", - fontSize: "1.1rem", - fontWeight: "700", - zIndex: "9999", - boxShadow: "0 4px 12px rgba(0,0,0,0.4)", - textAlign: "center", - }); - document.body.appendChild(popup); - - // Auto-dismiss after 5 seconds - setTimeout(() => { - popup.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 300, - fill: "forwards", - }).onfinish = () => popup.remove(); - }, 5000); -} - -async function refresh() { - const response = await client.getGlobal("rython-task-bot", true); - console.log(response); - - if (response.status !== "ok" || !response.variable?.value) return; - - const users = JSON.parse(response.variable.value); - const sections = transformToSections(users); - - taskList.load(sections); -} - -// LOAD TASK LIST -async function onConnect() { - taskList = new TaskList(".task-panel"); - - if (configs.emoteSettings.enabled) { - let broadcaster = await client.getBroadcaster(); - - console.log(broadcaster); - - let broadcasterName = - broadcaster.platforms.twitch?.broadcastUser ?? - broadcaster.platforms.youtube?.broadcastUser ?? - broadcaster.platforms.kick?.broadcastUser; - - let broadcasterId = - broadcaster.platforms.twitch?.broadcastUserId ?? - broadcaster.platforms.youtube?.broadcastUserId ?? - broadcaster.platforms.kick?.broadcastUserId; - - window.emoteManager = new EmoteManager({ - channelName: broadcasterName, - channelId: broadcasterId, - providers: configs.emoteSettings.providers, - size: configs.emoteSettings.size, - }); - - window.emoteManager.init().then(() => { - if (taskList) { - taskList.load(taskList.getData()); - } - }); - } - - refresh(); -} - -function transformToSections(users) { - return Object.entries(users).map(([userId, userData]) => ({ - id: userId, - title: userData.Username, - tasks: userData.Tasks.map((task) => ({ - text: task.Name, - done: task.Completed, - focused: task.Focused, - })), - })); -} - -// Update task list action by action -function onCustom(payload) { - const data = payload.data; - if (!data.source && data.source != "rython-task-bot") { - return; - } - if (!taskList) return; - const body = data.body; - const id = data.id; - const username = data.username; - - switch (body.mode) { - case "refresh": - refresh(); - break; - default: - break; - } -} diff --git a/widgets/horizontal/scripts/tasklist-view.js b/widgets/horizontal/scripts/tasklist-view.js deleted file mode 100644 index edff58c..0000000 --- a/widgets/horizontal/scripts/tasklist-view.js +++ /dev/null @@ -1,591 +0,0 @@ -// Task list view - -class TaskList { - #data = []; - #firstRender = true; - #pendingRemovals = 0; - - #scroll = { - offset: 0, - contentW: 0, - viewportW: 0, - speed: 50, - raf: null, - lastT: 0, - active: false, - wantStop: false, - }; - - #els = {}; - - #ANIM_DURATION = 400; - #ANIM_EASING = "cubic-bezier(0.22, 1, 0.36, 1)"; - - #parseHexColor(hex) { - if (typeof hex !== "string") return null; - const s = hex.trim(); - const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); - if (!m) return null; - let h = m[1].toLowerCase(); - if (h.length === 3) { - h = h - .split("") - .map((c) => c + c) - .join(""); - } - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - return { r, g, b }; - } - - #relativeLuminance({ r, g, b }) { - const toLinear = (v) => { - const s = v / 255; - return s <= 0.04045 - ? s / 12.92 - : Math.pow((s + 0.055) / 1.055, 2.4); - }; - const R = toLinear(r); - const G = toLinear(g); - const B = toLinear(b); - return 0.2126 * R + 0.7152 * G + 0.0722 * B; - } - - constructor(container) { - const el = - typeof container === "string" - ? document.querySelector(container) - : container; - - this.#els = { - track: el.querySelector(".scroll-track"), - content: el.querySelector(".scroll-content"), - viewport: el.querySelector(".scroll-viewport"), - }; - } - - // ── public API ───────────────────────────────────── - - load(sections) { - this.#data = structuredClone(sections); - this.#render(); - return this; - } - - addSection(section) { - this.#data.push(structuredClone(section)); - this.#render(); - return this; - } - - removeSection(id) { - const i = this.#data.findIndex((s) => s.id === id); - if (i === -1) return this; - this.#data.splice(i, 1); - this.#render(); - return this; - } - - updateSection(id, updater) { - const section = this.#data.find((s) => s.id === id); - if (!section) return this; - updater(section); - this.#render(); - return this; - } - - addTask(sectionId, task, sectionTitle) { - if (!this.#data.find((s) => s.id === sectionId)) { - this.addSection({ - id: sectionId, - title: sectionTitle || sectionId, - tasks: [], - }); - } - - return this.updateSection(sectionId, (s) => { - s.tasks.push(structuredClone(task)); - }); - } - - removeTask(sectionId, taskIndex) { - this.updateSection(sectionId, (s) => { - s.tasks.splice(taskIndex, 1); - }); - this.#data = this.#data.filter((s) => s.tasks.length > 0); - this.#render(); - return this; - } - - editTask(sectionId, taskIndex, newText, userColor) { - return this.updateSection(sectionId, (s) => { - if (s.tasks[taskIndex]) { - s.tasks[taskIndex].text = newText; - } - }); - } - - doneTask(sectionId, taskIndex) { - return this.updateSection(sectionId, (s) => { - if (s.tasks[taskIndex]) { - s.tasks[taskIndex].done = true; - s.tasks[taskIndex].focused = false; - } - }); - } - - undoneTask(sectionId, taskIndex) { - return this.updateSection(sectionId, (s) => { - if (s.tasks[taskIndex]) { - s.tasks[taskIndex].done = false; - } - }); - } - - focusTask(sectionId, taskIndex) { - // unfocus any previously focused task - this.unfocusTask(sectionId); - - return this.updateSection(sectionId, (s) => { - if (s.tasks[taskIndex]) { - s.tasks[taskIndex].focused = true; - } - }); - } - - unfocusTask(sectionId) { - return this.updateSection(sectionId, (s) => { - for (let i = 0; i < s.tasks.length; i++) { - s.tasks[i].focused = false; - } - }); - } - - clearmydone(sectionId) { - this.updateSection(sectionId, (s) => { - s.tasks = s.tasks.filter((t) => !t.done); - }); - this.#data = this.#data.filter((s) => s.tasks.length > 0); - this.#render(); - return this; - } - - cleardone() { - this.#data.forEach((s) => { - s.tasks = s.tasks.filter((t) => !t.done); - }); - this.#data = this.#data.filter((s) => s.tasks.length > 0); - this.#render(); - return this; - } - - getData() { - return structuredClone(this.#data); - } - - get sectionCount() { - return this.#data.length; - } - - get taskCount() { - return this.#data.reduce((sum, s) => sum + s.tasks.length, 0); - } - - get doneCount() { - return this.#data.reduce( - (sum, s) => sum + s.tasks.filter((t) => t.done).length, - 0, - ); - } - - destroy() { - this.#forceStop(); - this.#els.content.innerHTML = ""; - this.#data = []; - this.#firstRender = true; - } - - // ── render ───────────────────────────────────────── - - #render() { - if (this.#firstRender) { - this.#data.forEach((s) => - this.#els.content.appendChild(this.#createSectionEl(s)), - ); - this.#firstRender = false; - } else { - this.#patchContainer(this.#els.content, this.#data, true); - } - - if (this.#pendingRemovals === 0) { - this.#syncScroll(); - } - } - - // ── animations ───────────────────────────────────── - - #animateIn(el) { - el.style.overflow = "hidden"; - - const w = el.scrollWidth; - el.style.width = "0px"; - el.style.opacity = "0"; - - requestAnimationFrame(() => { - const anim = el.animate( - [ - { - width: "0px", - opacity: 0, - transform: "translateX(-8px)", - }, - { - width: w + "px", - opacity: 1, - transform: "translateX(0)", - }, - ], - { - duration: this.#ANIM_DURATION, - easing: this.#ANIM_EASING, - fill: "forwards", - }, - ); - - anim.onfinish = () => { - el.style.width = ""; - el.style.opacity = ""; - el.style.overflow = ""; - el.style.transform = ""; - anim.cancel(); - this.#syncScroll(); - }; - }); - } - - #animateRemove(el) { - this.#pendingRemovals++; - const w = el.scrollWidth; - - const anim = el.animate( - [ - { width: w + "px", opacity: 1, transform: "translateX(0)" }, - { width: "0px", opacity: 0, transform: "translateX(-8px)" }, - ], - { - duration: this.#ANIM_DURATION, - easing: this.#ANIM_EASING, - fill: "forwards", - }, - ); - - anim.onfinish = () => { - el.remove(); - this.#pendingRemovals--; - - if (this.#pendingRemovals === 0) { - this.#syncScroll(); - } - }; - } - - #animateStrikethrough(el) { - el.animate( - [ - { opacity: 0.5, transform: "scale(0.98)" }, - { opacity: 1, transform: "scale(1)" }, - ], - { duration: 250, easing: "ease-out" }, - ); - } - - // ── 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 = - "task" + - (task.done ? " done" : "") + - (task.focused ? " focused" : ""); - div.dataset.text = task.text; - const numberSpan = document.createElement("span"); - numberSpan.className = "task-number"; - numberSpan.textContent = `${index + 1}.`; - - const textSpan = document.createElement("span"); - textSpan.className = "task-text"; - textSpan.innerHTML = this.#parseTaskText(task.text); - - div.replaceChildren(numberSpan, textSpan); - return div; - } - - #createSectionEl(section) { - const div = document.createElement("div"); - div.className = "section"; - div.dataset.key = section.id; - if (section.title) { - const t = document.createElement("div"); - t.className = "section-title"; - t.textContent = section.title; - let color = - section.color ?? localStorage.getItem(`${section.id}-color`); - if ( - configs.userColorSettings.autoUserColor && - color != undefined && - color != "undefined" && - color != null - ) { - t.style.setProperty("--user-color", color); - t.classList.add("has-user-color"); - } - - div.appendChild(t); - } - section.tasks.forEach((task, i) => - div.appendChild(this.#createTaskEl(task, i)), - ); - return div; - } - - // ── patching ─────────────────────────────────────── - - #patchTasks(sectionEl, tasks, animate) { - const existing = [...sectionEl.querySelectorAll(":scope > .task")]; - - tasks.forEach((task, i) => { - if (i < existing.length) { - const el = existing[i]; - const wantClass = - "task" + - (task.done ? " done" : "") + - (task.focused ? " focused" : ""); - if (el.className !== wantClass) { - el.className = wantClass; - if (animate) this.#animateStrikethrough(el); - } - if (el.dataset.text !== task.text) { - el.dataset.text = 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}.`; - - // Update color if changed - if (task.color != undefined && task.color != "undefined") { - el.style.setProperty("--user-color", task.color); - el.classList.add("has-user-color"); - } else { - el.style.removeProperty("--user-color"); - el.classList.remove("has-user-color"); - } - } else { - const newEl = this.#createTaskEl(task, i); - sectionEl.appendChild(newEl); - if (animate) this.#animateIn(newEl); - } - }); - - for (let i = existing.length - 1; i >= tasks.length; i--) { - if (animate) { - this.#animateRemove(existing[i]); - } else { - existing[i].remove(); - } - } - } - - #patchContainer(container, sections, animate) { - const oldMap = new Map(); - container.querySelectorAll(":scope > .section").forEach((el) => { - oldMap.set(el.dataset.key, el); - }); - - let cursor = container.firstElementChild; - - sections.forEach((section) => { - const key = section.id; - const existing = oldMap.get(key); - - if (existing) { - this.#patchTasks(existing, section.tasks, animate); - if (existing !== cursor) { - container.insertBefore(existing, cursor); - } else { - cursor = cursor.nextElementSibling; - } - oldMap.delete(key); - } else { - const newEl = this.#createSectionEl(section); - container.insertBefore(newEl, cursor); - if (animate) this.#animateIn(newEl); - } - }); - - oldMap.forEach((el) => { - if (animate) { - this.#animateRemove(el); - } else { - el.remove(); - } - }); - } - - // ── scroll ───────────────────────────────────────── - - #tick = (now) => { - const s = this.#scroll; - const dt = (now - s.lastT) / 1000; - s.lastT = now; - - if (dt < 0.2) { - s.contentW = this.#els.content.scrollWidth; - s.offset += s.speed * dt; - - if (s.contentW > 0 && s.offset >= s.contentW) { - s.offset -= s.contentW; - - if (s.wantStop) { - s.wantStop = false; - this.#forceStop(); - return; - } - } - - this.#els.track.style.transform = `translateX(${-s.offset}px)`; - } - - s.raf = requestAnimationFrame(this.#tick); - }; - - #startScroll() { - const s = this.#scroll; - s.wantStop = false; - if (s.active) return; - s.active = true; - s.lastT = performance.now(); - s.raf = requestAnimationFrame(this.#tick); - } - - #requestStop() { - if (!this.#scroll.active) return; - this.#scroll.wantStop = true; - } - - #forceStop() { - const s = this.#scroll; - s.active = false; - s.wantStop = false; - cancelAnimationFrame(s.raf); - s.raf = null; - s.offset = 0; - this.#els.track.style.transform = ""; - - const clone = this.#els.track.querySelector(".scroll-clone"); - if (clone) clone.remove(); - } - - #syncScroll() { - const s = this.#scroll; - s.contentW = this.#els.content.scrollWidth; - s.viewportW = this.#els.viewport.clientWidth; - const needs = s.contentW > s.viewportW; - - let clone = this.#els.track.querySelector(".scroll-clone"); - - if (needs) { - if (!clone) { - clone = this.#els.content.cloneNode(true); - clone.id = ""; - clone.classList.add("scroll-clone"); - this.#els.track.appendChild(clone); - } else { - this.#patchContainer(clone, this.#data, false); - } - if (s.offset >= s.contentW) { - s.offset %= s.contentW; - } - this.#startScroll(); - } else { - if (s.active) { - if (clone) this.#patchContainer(clone, this.#data, false); - this.#requestStop(); - } else { - if (clone) clone.remove(); - } - } - } -} - -// ── usage ────────────────────────────────────────────── - -// const list2 = new TaskList(".task-panel"); - -// list2.load([ -// { -// title: "someone", -// tasks: [ -// { text: "dinner" }, -// { text: "watch bad bunny halftime show" }, -// { text: "tie tf down", done: true }, -// { text: "boy dinner", done: true }, -// { text: "3 hour SOOP" }, -// ], -// }, -// { -// title: "Cape_Codder", -// tasks: [ -// { text: "Part 2", done: true }, -// { text: "Ch 1-2", done: true }, -// ], -// }, -// { -// title: "sunflawer", -// tasks: [ -// { text: "fix walls from home office" }, -// { text: "sand walls zzzsdkjhfsk", done: true }, -// { text: "meal prep" }, -// { text: "buy wrist brace maybe" }, -// ], -// }, -// { -// title: "extra_user1", -// tasks: [ -// { text: "go grocery shopping" }, -// { text: "clean the kitchen", done: true }, -// { text: "respond to emails" }, -// ], -// }, -// ]); - -// demo - -// setTimeout(() => { -// list2.addTask("someone2", { text: "new task just dropped" }); -// }, 1000); - -// setTimeout(() => { -// list2.; -// }, 3000); - -// setTimeout(() => { -// list.toggleTask("sunflawer", 0); -// }, 10000); - -// setTimeout(() => { -// list.removeSection("Cape_Codder"); -// }, 12000); - -// setTimeout(() => { -// list.removeTask("extra_user1", 1); -// }, 14000); diff --git a/widgets/horizontal/images/websocket.png b/widgets/shared/images/websocket.png similarity index 100% rename from widgets/horizontal/images/websocket.png rename to widgets/shared/images/websocket.png diff --git a/widgets/horizontal/lib/streamerbot-client.js b/widgets/shared/lib/streamerbot-client.js similarity index 100% rename from widgets/horizontal/lib/streamerbot-client.js rename to widgets/shared/lib/streamerbot-client.js diff --git a/widgets/vertical/scripts/emotes.js b/widgets/shared/scripts/emotes.js similarity index 100% rename from widgets/vertical/scripts/emotes.js rename to widgets/shared/scripts/emotes.js diff --git a/widgets/vertical/scripts/streamerbot.js b/widgets/shared/scripts/streamerbot.js similarity index 67% rename from widgets/vertical/scripts/streamerbot.js rename to widgets/shared/scripts/streamerbot.js index 6aa5b00..2c09e78 100644 --- a/widgets/vertical/scripts/streamerbot.js +++ b/widgets/shared/scripts/streamerbot.js @@ -9,17 +9,6 @@ const client = new StreamerbotClient({ onError: onError, }); -function getAllLocalstorage() { - const allLocalStorage = {}; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - allLocalStorage[key] = localStorage.getItem(key); - } - - return JSON.stringify(allLocalStorage); -} - client.on("General.Custom", (data) => onCustom(data)); if (configs.userColorSettings.autoUserColor) { client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); @@ -29,49 +18,35 @@ if (configs.userColorSettings.autoUserColor) { let taskList; let userColors = {}; -function parseHexColor(hex) { - if (typeof hex !== "string") return null; - const s = hex.trim(); - const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); - if (!m) return null; - let h = m[1].toLowerCase(); - if (h.length === 3) { - h = h - .split("") - .map((c) => c + c) - .join(""); - } - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - return { r, g, b }; +function getUserColor(sectionId) { + return ( + userColors[`${sectionId}-color`] ?? + localStorage.getItem(`${sectionId}-color`) + ); } -function relativeLuminance({ r, g, b }) { - const toLinear = (v) => { - const s = v / 255; - return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); - }; - const R = toLinear(r); - const G = toLinear(g); - const B = toLinear(b); - return 0.2126 * R + 0.7152 * G + 0.0722 * B; +function taskColorFromSettings(sectionId) { + const usernameColor = getUserColor(sectionId); + if ( + configs.userColorSettings.autoUserColor && + usernameColor != undefined && + usernameColor != null + ) { + return usernameColor; + } + return undefined; } async function onChatMessage(data) { - let userColor = data.data.user.color; // hex colour #FF69B4 + const userColor = data.data.user.color; - if (userColor != undefined || userColor != "undefined") { - // update localstorage - // get id: platform-userID - let userId = data.data.user.id; - let key = `twitch-${userId}`; + if (userColor != undefined && userColor != "undefined") { + const userId = data.data.user.id; + const key = `twitch-${userId}`; localStorage.setItem(`${key}-color`, userColor); userColors[`${key}-color`] = userColor; } - - return; } function onDisconnect() { @@ -85,7 +60,6 @@ function onError(err) { } function showConnectionError(message) { - // Remove existing popup if any const existing = document.getElementById("connection-error"); if (existing) existing.remove(); @@ -110,7 +84,6 @@ function showConnectionError(message) { }); document.body.appendChild(popup); - // Auto-dismiss after 5 seconds setTimeout(() => { popup.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, @@ -121,7 +94,6 @@ function showConnectionError(message) { async function refresh() { const response = await client.getGlobal("rython-task-bot", true); - console.log(response); if (response.status !== "ok" || !response.variable?.value) return; @@ -131,19 +103,18 @@ async function refresh() { taskList.load(sections); } -// LOAD TASK LIST async function onConnect() { taskList = new TaskList(".task-panel"); if (configs.emoteSettings.enabled) { - let broadcaster = await client.getBroadcaster(); + const broadcaster = await client.getBroadcaster(); - let broadcasterName = + const broadcasterName = broadcaster.platforms.twitch?.broadcastUser ?? broadcaster.platforms.youtube?.broadcastUser ?? broadcaster.platforms.kick?.broadcastUser; - let broadcasterId = + const broadcasterId = broadcaster.platforms.twitch?.broadcastUserId ?? broadcaster.platforms.youtube?.broadcastUserId ?? broadcaster.platforms.kick?.broadcastUserId; @@ -177,19 +148,58 @@ function transformToSections(users) { })); } -// Update task list action by action function onCustom(payload) { const data = payload.data; - if (!data.source && data.source != "rython-task-bot") { + if (data.source !== "rython-task-bot") { return; } if (!taskList) return; + const body = data.body; const id = data.id; const username = data.username; switch (body.mode) { - case "refresh": + case "add": { + const taskPayload = { + text: body.task, + done: body.completed, + focused: body.focused, + }; + const color = taskColorFromSettings(id); + if (color) taskPayload.color = color; + taskList.addTask(id, taskPayload, username); + break; + } + case "focus": + taskList.focusTask(id, body.index); + break; + case "edit": + taskList.editTask(id, body.index, body.task, taskColorFromSettings(id)); + break; + case "remove": + taskList.removeTask(id, body.index); + break; + case "done": + taskList.doneTask(id, body.index); + break; + case "undone": + taskList.undoneTask(id, body.index); + break; + case "unfocus": + taskList.unfocusTask(id); + break; + case "admindelete": + taskList.removeSection(body.id); + break; + case "clearmydone": + taskList.clearmydone(id); + break; + case "cleardone": + taskList.cleardone(); + break; + case "clearall": + case "clearns": refresh(); break; default: diff --git a/widgets/vertical/scripts/tasklist-view.js b/widgets/shared/scripts/tasklist-view.js similarity index 100% rename from widgets/vertical/scripts/tasklist-view.js rename to widgets/shared/scripts/tasklist-view.js diff --git a/widgets/streamer-only/README.md b/widgets/streamer-only/README.md index f49b08a..c8eb6d8 100644 --- a/widgets/streamer-only/README.md +++ b/widgets/streamer-only/README.md @@ -6,7 +6,7 @@ Websocket server running is required for browser source to work. -> ![websocket](./images/websocket.png) +> ![websocket](../shared/images/websocket.png) 1. Streamer.Bot -> Servers/Clients -> Websocket Server 2. Auto Start: `ON`; Click on `Start Server` diff --git a/widgets/streamer-only/images/websocket.png b/widgets/streamer-only/images/websocket.png deleted file mode 100644 index 9bfc514..0000000 Binary files a/widgets/streamer-only/images/websocket.png and /dev/null differ diff --git a/widgets/streamer-only/index.html b/widgets/streamer-only/index.html index 6fc7ee0..033836f 100644 --- a/widgets/streamer-only/index.html +++ b/widgets/streamer-only/index.html @@ -3,11 +3,11 @@ - + - + - + diff --git a/widgets/streamer-only/lib/streamerbot-client.js b/widgets/streamer-only/lib/streamerbot-client.js deleted file mode 100644 index 1f1ad44..0000000 --- a/widgets/streamer-only/lib/streamerbot-client.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";var Streamerbot=(()=>{var W=Object.create;var p=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var D=Object.getPrototypeOf,H=Object.prototype.hasOwnProperty;var O=(n,e)=>()=>(e||n((e={exports:{}}).exports,e),e.exports),q=(n,e)=>{for(var t in e)p(n,t,{get:e[t],enumerable:!0})},E=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of U(e))!H.call(n,o)&&o!==t&&p(n,o,{get:()=>e[o],enumerable:!(r=L(e,o))||r.enumerable});return n};var M=(n,e,t)=>(t=n!=null?W(D(n)):{},E(e||!n||!n.__esModule?p(t,"default",{value:n,enumerable:!0}):t,n)),B=n=>E(p({},"__esModule",{value:!0}),n);var G=O((Q,P)=>{"use strict";P.exports=function(){throw new Error("ws does not work in the browser. Browser clients must use the native WebSocket object")}});var _={};q(_,{Client:()=>l});var m=class{constructor(e={}){this.logLevels={verbose:0,debug:1,info:2,warn:3,error:4,none:5};this.level=e.level||"info",this.customLogger=e.customLogger}setLevel(e){this.level=e}setCustomLogger(e){this.customLogger=e}verbose(...e){this.log("verbose",...e)}debug(...e){this.log("debug",...e)}info(...e){this.log("info",...e)}warn(...e){this.log("warn",...e)}error(...e){this.log("error",...e)}log(e,...t){if(!(this.logLevels[e]y.getRandomValues(n);function C(n="req"){return`sb:client:${n}:${Date.now()}-${R(new Uint32Array(12))[0]}`}function k(n){let e;return n.code==1e3?e="Connection closed.":n.code==1001?e='Endpoint is "going away".':n.code==1002?e="Connection closed due to a protocol error.":n.code==1003||n.code==1007||n.code==1008||n.code==1010?e="Bad request.":n.code==1004?e="Reserved":n.code==1005?e="Missing status code.":n.code==1006?e="The connection was closed abnormally.":n.code==1009?e="Message size limit exceeded.":n.code==1011?e="Server terminated connection because due to unexpected condition.":n.code==1015?e="TLS handshake failure":e="Unknown error",e}async function h(n,e){let{timeout:t,message:r="Operation timed out.",controller:o}=e,s;return await Promise.race([new Promise((i,a)=>{s=setTimeout(()=>(o.abort(),console.debug("[withTimeout] timeout reached",e),a(new Error(r))),t),e.signal?.addEventListener("abort",()=>{clearTimeout(s),o?.abort(),a(new Error("Operation aborted."))},{once:!0})}),n]).finally(()=>{clearTimeout(s),o.abort()})}async function S(n){let e=new TextEncoder().encode(n),t=await T.digest("SHA-256",e),o=Array.from(new Uint8Array(t)).map(s=>s.toString(16).padStart(2,"0")).join("");return I(o)}function I(n){let e=new Uint8Array(n.match(/.{1,2}/g).map(r=>parseInt(r,16)));return btoa(String.fromCharCode.apply(null,Array.from(e)))}function v({timeout:n=1e4,addEventListener:e,removeEventListener:t}){let r=C("res"),o=new AbortController,s=o.signal,i=new Promise((a,c)=>{let d=b=>{let f=b?.data;f?.eventName===r&&(o.abort(),a(f?.args))};e("Custom.Event",d);let u=setTimeout(()=>{o.abort(),c(new Error("Timed out waiting for Custom Event"))},n);s.addEventListener("abort",()=>{clearTimeout(u),t(b=>b.events?.includes("Custom.Event")&&b.callback===d)},{once:!0})});return{responseId:r,promise:i,controller:o}}var A={scheme:"ws",host:"127.0.0.1",port:8080,endpoint:"/",immediate:!0,autoReconnect:!0,retries:-1,subscribe:{},logger:w,logLevel:"info"},l=class{constructor(e=A){this._authEnabled=!1;this._authenticated=!1;this.listeners=[];this.subscriptions={};this._explicitlyClosed=!1;this._retried=0;this._connectController=new AbortController;this._reconnectTimeout=void 0;this.options={...A,...e},this.logger=this.options.logger||null,this.logger&&this.options.logLevel&&this.logger.setLevel(this.options.logLevel),this.options.immediate===!0&&this.connect().catch(t=>this.logger?.warn("Failed to connect:",t))}get authenticated(){return!!this.socket&&this.socket.readyState===this.socket.OPEN&&this._authenticated}get ready(){return!this.socket||this.socket.readyState!==this.socket.OPEN||this._authEnabled&&!this._authenticated?!1:!!this.info&&!!this.version}async connect(e=1e4){if(this.socket?.readyState!==this.socket?.CLOSED)try{await this.disconnect()}catch{}this._explicitlyClosed=!1,this._connectController.abort(),this._connectController=new AbortController;let t=new AbortController;return this._connectController.signal.addEventListener("abort",()=>{t.abort()},{once:!0}),await h(new Promise(async(r,o)=>{try{this.options.password&&(this._authEnabled=!0);let s=`${this.options.scheme}://${this.options.host}:${this.options.port}${this.options.endpoint}`;this.logger?.debug("Connecting to Streamer.bot WebSocket server at",s,this._authEnabled?"with authentication":""),this.socket=globalThis?.process?.versions?.node?new(await Promise.resolve().then(()=>M(G(),1))).WebSocket(s):new WebSocket(s),this.socket.onmessage=this.onMessage.bind(this),this.socket.onopen=this.onOpen.bind(this),this.socket.onclose=this.onClose.bind(this),this.socket.onerror=this.onError.bind(this),this.socket.addEventListener("open",()=>{if(!this.socket)return o(new Error("WebSocket not initialized"));r()},{signal:t.signal}),this.socket.addEventListener("close",()=>o(new Error("WebSocket closed")),{once:!0})}catch(s){try{await this.disconnect(),this?.options?.onError?.(s)}catch(i){this.logger?.warn("Error invoking onError handler",i)}o(s)}}),{timeout:e,message:"WebSocket connection timeout exceeded",controller:t})}async disconnect(e=1e3,t=1e3){if(this._explicitlyClosed=!0,this._connectController.abort(),this._reconnectTimeout&&clearTimeout(this._reconnectTimeout),!this.socket||this.socket.readyState===this.socket.CLOSED)return;let r=new AbortController,o=r.signal;return await h(new Promise((s,i)=>{if(this.socket?.addEventListener("close",()=>{this.logger?.debug("Disconnected from Streamer.bot WebSocket server"),s()},{signal:o}),this.socket?.readyState!==this.socket?.CLOSING)try{this.socket?.close(e)}catch(a){i(a)}}),{timeout:t,message:"Timeout exceeded while closing connection",controller:r})}async handshake(){if(!this.socket)throw new Error("WebSocket not initialized");let e=new AbortController,{signal:t}=e;this._connectController.signal.addEventListener("abort",()=>{e.abort()},{once:!0,signal:t});let r=await h(new Promise((o,s)=>{this.socket?.addEventListener("message",async i=>{if(!("data"in i)||!i.data||typeof i.data!="string"){this.logger?.debug("Unknown message received",i);return}try{let a=JSON.parse(i.data);a&&"info"in a&&o(a)}catch(a){this.logger?.warn("Invalid JSON payload received",i.data),s(a)}},{signal:t})}),{timeout:5e3,message:"Handshake timeout exceeded",controller:e});if(!r||!("info"in r))throw new Error("Handshake failed (invalid payload)");if("request"in r&&r?.request==="Hello"&&r.authentication)return await this.authenticate(r);if(r.info&&!r.authentication){this.logger?.debug("Connected to Streamer.bot WebSocket server",r.info),this.info=r.info,this.version=r.info.version;return}throw new Error("Handshake failed (unknown)")}async authenticate(e){if(!this._authEnabled||!this.options.password){if(this.logger?.debug("No password provided for authentication. Checking if auth is enforced for all requests..."),(await this.getInfo()).status==="ok"){this._authenticated=!1,this.version=e.info.version,this.info=e.info;return}throw await this.disconnect(),new Error("Authentication required")}if(!e.authentication)throw this.logger?.debug("Missing authentication payload"),await this.disconnect(),new Error("Invalid authentication payload");this.logger?.debug("Authenticating with Streamer.bot WebSocket server...");let{salt:t,challenge:r}=e?.authentication,o=await S(`${this.options.password}${t}`),s=await S(`${o}${r}`);if((await this.request({request:"Authenticate",authentication:s})).status==="ok")this._authenticated=!0,this.version=e.info.version,this.info=e.info;else throw await this.disconnect(),new Error("Authentication failed")}async onOpen(){this._retried=0,this._reconnectTimeout&&clearTimeout(this._reconnectTimeout);try{this._authEnabled||this.getInfo().catch(()=>this.logger?.debug("Failed to fetch Streamer.bot instance info")),await this.handshake(),this.version&&this.info&&(this.logger?.debug(`Connected to Streamer.bot: v${this.version} (${this.info.name})`),await this.updateSupportedEvents(),this?.options?.onConnect?.(this.info))}catch(e){return this.logger?.warn("Failed handshake with Streamer.bot",e),this.options?.onError?.(e instanceof Error?e:new Error("Failed handshake with Streamer.bot")),await this.disconnect()}try{if(this.options.subscribe==="*"||typeof this.options.subscribe=="object"&&!Array.isArray(this.options.subscribe)&&Object.keys(this.options.subscribe??{}).length)this.logger?.debug("Subscribing to initial events from options:",this.options.subscribe),await this.subscribe(this.options.subscribe);else if(typeof this.options.subscribe=="string"||Array.isArray(this.options.subscribe)){this.logger?.debug("Subscribing to initial events from options:",this.options.subscribe);let e=await this.getSubscriptionsFromEventStrings(this.options.subscribe);this.logger?.debug("Parsed subscriptions from options:",e),e&&await this.subscribe(e)}if(this.listeners.length){let e=await this.getSubscriptionsFromListeners();await this.subscribe(e)}this.logger?.verbose("Subscribed to requested events",this.subscriptions,this.listeners)}catch(e){this.logger?.warn("Error subscribing to requested events",e)}}onClose(e){this._connectController.abort();try{(e.type==="error"||!e.wasClean)&&this.options.onError&&this?.options?.onError(new Error(k(e))),this?.options?.onDisconnect?.()}catch(t){this.logger?.warn("Error invoking user-provided onDisconnect handler",t)}if(this._explicitlyClosed||!this.options.autoReconnect)return this.logger?.debug("Cleaning up..."),this.cleanup();this._retried+=1,typeof this.options.retries=="number"&&(this.options.retries<0||this._retried{if(!(this.socket&&this.socket.readyState!==this.socket.CLOSED)){this.logger?.debug(`Reconnecting... (attempt ${this._retried})`);try{await this.connect(1e4)}catch(t){this._retried&&this.logger?.warn(`Failed to reconnect (attempt ${this._retried-1})`,t)}}},Math.min(3e4,this._retried*1e3))):(this.logger?.debug("Auto-reconnect limit reached. Cleaning up..."),this.cleanup())}async onMessage(e){if(!e.data||typeof e.data!="string"){this.logger?.debug("Unknown message received",e);return}let t;try{t=JSON.parse(e.data)}catch(r){this.logger?.warn("Invalid JSON payload received",e.data,r);return}this.logger?.verbose("RECV",t);try{this.options.onData&&this?.options?.onData(t)}catch(r){this.logger?.warn("Error occurred within user-provided onData callback",r)}if(t?.event?.source&&t?.event?.type){for(let r of this.listeners)if(r.events?.length&&r.events.find(o=>o==="*"||o===`${t?.event?.source}.${t?.event?.type}`||o.split(".",2)?.[1]==="*"&&o.split(".",2)?.[0]===t?.event?.source))try{r.callback(t)}catch(o){this.logger?.warn(`Error occurred within user-provided event callback (${r.events})`,o)}}}onError(e){this.logger?.debug("WebSocket onError",e),this.socket&&this.socket.readyState!==this.socket.OPEN&&this._connectController.abort();try{this?.options?.onError?.(new Error("WebSocket Error"))}catch(t){this.logger?.warn("Error occurred within user-provided onError callback",t)}}cleanup(){this.socket&&(this.socket.onopen=null,this.socket.onclose=null,this.socket.onerror=null,this.socket.onmessage=null,this.socket=void 0),this.listeners=[],this._retried=0,this._connectController.abort(),this._reconnectTimeout&&clearTimeout(this._reconnectTimeout)}send(e){this.socket?.send(JSON.stringify(e))}async request(e,t="",r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");t||(t=C());let o=new AbortController,s=o.signal;this._connectController.signal.addEventListener("abort",()=>{o.abort()},{once:!0,signal:s});let i=await h(new Promise((a,c)=>{this.socket?.addEventListener("message",d=>{if(!("data"in d)||!d.data||typeof d.data!="string"){this.logger?.debug("Unknown message received",d.data);return}try{let u=JSON.parse(d?.data);if(u?.id===t)return this.logger?.verbose(`RECV :: ${e.request}`,u),a(u)}catch(u){this.logger?.warn("Invalid JSON payload received",d.data),c(u)}},{signal:s}),this.logger?.verbose(`SEND :: ${e.request}`,{...e,id:t}),this.send({...e,id:t})}),{timeout:r,message:"Request timed out",controller:o,signal:s});if(i?.status==="ok"){try{this.options.onData&&this?.options?.onData(i)}catch(a){this.logger?.warn("Error invoking onData handler",a)}return{event:{source:"Request",type:e.request??"Unknown"},...i}}throw new Error("Request failed")}async on(e,t){try{if(!e)return;let r={events:[e],callback:t};if(this.listeners.push(r),this.ready){let o=await this.getSubscriptionsFromListeners([r]);await this.subscribe(o)}this.logger?.debug(`Added event listener for "${e}"`)}catch(r){this.logger?.warn(`Failed adding event listener for "${e}"`,r)}}async updateSupportedEvents(){if(this.ready)try{let e=await this.getEvents();if(e.status!=="ok"||!e.events)throw new Error(e.status);this.supportedEvents=e.events,this.logger?.debug(`Successfully fetched supported event types for Streamer.bot v${this.version}`)}catch(e){this.logger?.warn("Failed to fetch supported events from Streamer.bot, falling back to stored events type.",e),this.supportedEvents=g}}async getSupportedEvents(){return this.supportedEvents||(this.logger?.warn("Supported event types not yet initialized, fetching from Streamer.bot instance..."),await this.updateSupportedEvents()),this.supportedEvents??g}getEventsFromListeners(e){return(e??this.listeners).reduce((t,r)=>(r.events.forEach(o=>{t[o]||(t[o]=[]),t[o].push(r.callback)}),t),{})}async getSubscriptionsFromListeners(e){let t=this.getEventsFromListeners(e);return this.getSubscriptionsFromEventStrings(Object.keys(t))}async getSubscriptionsFromEventStrings(e){let t={};typeof e=="string"&&(e=[e]);for(let r of e){let o=await this.parseEventString(r);if(o)for(let s of o){let{source:i,eventTypes:a}=s,c=new Set([...t[i]??[],...a]);t[i]=[...c]}}return t}async parseEventString(e){let t=await this.getSupportedEvents();if(!e||typeof e!="string"){this.logger?.warn(`Invalid event subscription requested "${e}"`);return}if(e==="*")return Object.keys(t).map(r=>{let o=r,s=t[o]??[];return{source:o,eventTypes:s}});{let[r,o]=e.split(".",2);if(!r||!o||!(r in t)){this.logger?.warn(`Invalid event subscription requested "${e}"`);return}let s=r,i=o;if(i)return[{source:s,eventTypes:i==="*"?t[s]:[i]}];this.logger?.warn(`Invalid event type requested "${e}"`);return}}async subscribe(e){let t=await this.getSupportedEvents();e==="*"&&(e=t);for(let r in e){if(!r||r==="err")continue;if(!(r in t)){this.logger?.warn(`Attempted to subscribe to empty or unknown event source: "${r}"`,Object.keys(e));continue}let o=r,s=e[o]??[];if(s&&s.length){let i=new Set([...this.subscriptions[o]??[],...s]);this.subscriptions[o]=[...i]}}return Object.keys(this.subscriptions).length===0?(this.logger?.warn("No valid events to subscribe to. Please provide valid event sources and types."),{id:"invalid",status:"error",error:"No valid events to subscribe to"}):await this.request({request:"Subscribe",events:this.subscriptions})}async unsubscribe(e){let t=await this.getSupportedEvents();e==="*"&&(e=t);for(let r in e){if(r===void 0||!Object.keys(t).includes(r))continue;let o=r,s=e[o];if(s&&s.length)for(let i of s)i&&this.subscriptions[o]?.filter&&(this.subscriptions[o]=this.subscriptions[o])?.filter(a=>i!==a)}return await this.request({request:"UnSubscribe",events:e})}async getEvents(){return await this.request({request:"GetEvents"})}async getActions(){return await this.request({request:"GetActions"})}async doAction(e,t,r){if(r?.customEventResponse)return this.doActionWithCustomEventResponse(e,t);let o,s;return typeof e=="string"?o=e:(o=e.id,s=e.name),await this.request({request:"DoAction",action:{id:o,name:s},args:t})}async doActionWithCustomEventResponse(e,t={},r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");let{responseId:o,promise:s,controller:i}=v({timeout:r,addEventListener:(c,d)=>this.on(c,d),removeEventListener:c=>{this.listeners=this.listeners.filter(d=>!c(d))}}),a={...t,sbClientResponse:o};try{let c=await this.doAction(e,a),d=await s;return{...c,customEventResponseArgs:d}}catch(c){throw i.signal.aborted||i.abort(),c}}async getBroadcaster(){return await this.request({request:"GetBroadcaster"})}async getMonitoredYouTubeBroadcasts(){return await this.request({request:"GetMonitoredYouTubeBroadcasts"})}async getCredits(){return await this.request({request:"GetCredits"})}async testCredits(){return await this.request({request:"TestCredits"})}async clearCredits(){return await this.request({request:"ClearCredits"})}async getInfo(){return await this.request({request:"GetInfo"})}async getActiveViewers(){return await this.request({request:"GetActiveViewers"})}async executeCodeTrigger(e,t,r){return r?.customEventResponse?this.executeCodeTriggerWithCustomEventResponse(e,t):await this.request({request:"ExecuteCodeTrigger",triggerName:e,args:t})}async executeCodeTriggerWithCustomEventResponse(e,t={},r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");let{responseId:o,promise:s,controller:i}=v({timeout:r,addEventListener:(c,d)=>this.on(c,d),removeEventListener:c=>{this.listeners=this.listeners.filter(d=>!c(d))}}),a={...t,sbClientResponse:o};try{let c=await this.executeCodeTrigger(e,a),d=await s;return{...c,customEventResponseArgs:d}}catch(c){throw i.signal.aborted||i.abort(),c}}async getCodeTriggers(){return await this.request({request:"GetCodeTriggers"})}async getCommands(){return await this.request({request:"GetCommands"})}async getEmotes(e){switch(e){case"twitch":return await this.request({request:"TwitchGetEmotes"});case"youtube":return await this.request({request:"YouTubeGetEmotes"});default:throw new Error("Invalid platform")}}async getGlobals(e=!0){return await this.request({request:"GetGlobals",persisted:e})}async getGlobal(e,t=!0){let r=await this.request({request:"GetGlobal",variable:e,persisted:t});return r.status==="ok"?r.variables[e]?{id:r.id,status:r.status,variable:r.variables[e]}:{status:"error",error:"Variable not found"}:r}async getUserGlobals(e,t=null,r=!0){let s={twitch:"TwitchGetUserGlobals",youtube:"YouTubeGetUserGlobals",trovo:"TrovoGetUserGlobals",kick:"KickGetUserGlobals"}[e];if(!s)throw new Error("Invalid platform");return await this.request({request:s,variable:t,persisted:r})}async getUserGlobal(e,t,r=null,o=!0){let i={twitch:"TwitchGetUserGlobal",youtube:"YouTubeGetUserGlobal",trovo:"TrovoGetUserGlobal",kick:"KickGetUserGlobal"}[e];if(!i)throw new Error("Invalid platform");let a=await this.request({request:i,userId:t,variable:r||null,persisted:o});if(a.status==="ok"&&t&&r){let c=a.variables.find(d=>d.name===r);return c?{id:a.id,status:a.status,variable:c}:{status:"error",error:"Variable not found"}}return a}async sendMessage(e,t,{bot:r=!1,internal:o=!0,...s}={}){if(!this._authenticated)return{status:"error",error:"Authentication required"};let i={platform:e,message:t,bot:r,internal:o};return["twitch","kick"].includes(e)&&s.replyId&&Object.assign(i,{replyId:s.replyId}),["youtube"].includes(e)&&s.broadcastId&&Object.assign(i,{broadcastId:s.broadcastId}),await this.request({...i,request:"SendMessage"})}async getUserPronouns(e,t){return await this.request({request:"GetUserPronouns",platform:e,userLogin:t})}};Object.assign(globalThis,{StreamerbotClient:l});return B(_);})(); diff --git a/widgets/streamer-only/scripts/emotes.js b/widgets/streamer-only/scripts/emotes.js deleted file mode 100644 index c6eafb9..0000000 --- a/widgets/streamer-only/scripts/emotes.js +++ /dev/null @@ -1,273 +0,0 @@ -"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 deleted file mode 100644 index 54bef1f..0000000 --- a/widgets/streamer-only/scripts/streamerbot.js +++ /dev/null @@ -1,201 +0,0 @@ -// STREAMER.BOT SETTINGS - -const client = new StreamerbotClient({ - host: configs.streamerBotSettings.host, - port: configs.streamerBotSettings.port, - endpoint: configs.streamerBotSettings.endpoint, - onConnect: onConnect, - onDisconnect: onDisconnect, - onError: onError, -}); - -function getAllLocalstorage() { - const allLocalStorage = {}; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - allLocalStorage[key] = localStorage.getItem(key); - } - - return JSON.stringify(allLocalStorage); -} - -client.on("General.Custom", (data) => onCustom(data)); -if (configs.userColorSettings.autoUserColor) { - client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); - client.on("Kick.ChatMessage", (data) => onChatMessage(data)); -} - -let taskList; -let userColors = {}; - -function parseHexColor(hex) { - if (typeof hex !== "string") return null; - const s = hex.trim(); - const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); - if (!m) return null; - let h = m[1].toLowerCase(); - if (h.length === 3) { - h = h - .split("") - .map((c) => c + c) - .join(""); - } - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - return { r, g, b }; -} - -function relativeLuminance({ r, g, b }) { - const toLinear = (v) => { - const s = v / 255; - return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); - }; - const R = toLinear(r); - const G = toLinear(g); - const B = toLinear(b); - return 0.2126 * R + 0.7152 * G + 0.0722 * B; -} - -async function onChatMessage(data) { - let userColor = data.data.user.color; // hex colour - - if (userColor != undefined || userColor != "undefined") { - // update localstorage - // get id: platform-userID - let userId = data.data.user.id; - let key = `twitch-${userId}`; - - localStorage.setItem(`${key}-color`, userColor); - userColors[`${key}-color`] = userColor; - } - - return; -} - -function onDisconnect() { - showConnectionError("Connection Failed: Unable to connect to Streamer.bot"); -} - -function onError(err) { - showConnectionError( - "Connection Failed: " + (err?.message || "Unknown error"), - ); -} - -function showConnectionError(message) { - // Remove existing popup if any - const existing = document.getElementById("connection-error"); - if (existing) existing.remove(); - - const popup = document.createElement("div"); - popup.id = "connection-error"; - popup.textContent = message; - Object.assign(popup.style, { - position: "fixed", - top: "20px", - left: "50%", - transform: "translateX(-50%)", - background: "#e53935", - color: "#fff", - padding: "12px 24px", - borderRadius: "8px", - fontFamily: "'Fredoka', sans-serif", - fontSize: "1.1rem", - fontWeight: "700", - zIndex: "9999", - boxShadow: "0 4px 12px rgba(0,0,0,0.4)", - textAlign: "center", - }); - document.body.appendChild(popup); - - // Auto-dismiss after 5 seconds - setTimeout(() => { - popup.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 300, - fill: "forwards", - }).onfinish = () => popup.remove(); - }, 5000); -} - -async function refresh() { - const response = await client.getGlobal("rython-task-bot", true); - console.log(response); - - if (response.status !== "ok" || !response.variable?.value) return; - - const users = JSON.parse(response.variable.value); - console.log(users); - const sections = transformToSections(users); - - taskList.load(sections); -} - -// LOAD TASK LIST -async function onConnect() { - taskList = new TaskList(".task-panel"); - - if (configs.emoteSettings.enabled) { - let broadcaster = await client.getBroadcaster(); - - console.log(broadcaster); - - let broadcasterName = - broadcaster.platforms.twitch?.broadcastUser ?? - broadcaster.platforms.youtube?.broadcastUser ?? - broadcaster.platforms.kick?.broadcastUser; - - let broadcasterId = - broadcaster.platforms.twitch?.broadcastUserId ?? - broadcaster.platforms.youtube?.broadcastUserId ?? - broadcaster.platforms.kick?.broadcastUserId; - - window.emoteManager = new EmoteManager({ - channelName: broadcasterName, - channelId: broadcasterId, - providers: configs.emoteSettings.providers, - size: configs.emoteSettings.size, - }); - - window.emoteManager.init().then(() => { - if (taskList) { - taskList.load(taskList.getData()); - } - }); - } - - refresh(); -} - -function transformToSections(users) { - return Object.entries(users).map(([userId, userData]) => ({ - id: userId, - title: userData.Username, - tasks: userData.Tasks.map((task) => ({ - text: task.Name, - done: task.Completed, - focused: task.Focused, - })), - })); -} - -// Update task list action by action -function onCustom(payload) { - const data = payload.data; - if (!data.source && data.source != "rython-task-bot") { - return; - } - if (!taskList) return; - const body = data.body; - const id = data.id; - const username = data.username; - - switch (body.mode) { - case "refresh": - refresh(); - break; - default: - break; - } -} diff --git a/widgets/vertical/README.md b/widgets/vertical/README.md index aec396b..52ab2a8 100644 --- a/widgets/vertical/README.md +++ b/widgets/vertical/README.md @@ -6,7 +6,7 @@ Optional widget for [Rython Task Bot v2](https://github.com/liyunze-coding/rytho Websocket server running is required for browser source to work. -> ![websocket](./images/websocket.png) +> ![websocket](../shared/images/websocket.png) 1. Streamer.Bot -> Servers/Clients -> Websocket Server 2. Auto Start: `ON`; Click on `Start Server` diff --git a/widgets/vertical/images/websocket.png b/widgets/vertical/images/websocket.png deleted file mode 100644 index 9bfc514..0000000 Binary files a/widgets/vertical/images/websocket.png and /dev/null differ diff --git a/widgets/vertical/index.html b/widgets/vertical/index.html index 6fc7ee0..318dfcf 100644 --- a/widgets/vertical/index.html +++ b/widgets/vertical/index.html @@ -3,11 +3,11 @@ - + - - - + + + diff --git a/widgets/vertical/lib/streamerbot-client.js b/widgets/vertical/lib/streamerbot-client.js deleted file mode 100644 index 1f1ad44..0000000 --- a/widgets/vertical/lib/streamerbot-client.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";var Streamerbot=(()=>{var W=Object.create;var p=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var U=Object.getOwnPropertyNames;var D=Object.getPrototypeOf,H=Object.prototype.hasOwnProperty;var O=(n,e)=>()=>(e||n((e={exports:{}}).exports,e),e.exports),q=(n,e)=>{for(var t in e)p(n,t,{get:e[t],enumerable:!0})},E=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of U(e))!H.call(n,o)&&o!==t&&p(n,o,{get:()=>e[o],enumerable:!(r=L(e,o))||r.enumerable});return n};var M=(n,e,t)=>(t=n!=null?W(D(n)):{},E(e||!n||!n.__esModule?p(t,"default",{value:n,enumerable:!0}):t,n)),B=n=>E(p({},"__esModule",{value:!0}),n);var G=O((Q,P)=>{"use strict";P.exports=function(){throw new Error("ws does not work in the browser. Browser clients must use the native WebSocket object")}});var _={};q(_,{Client:()=>l});var m=class{constructor(e={}){this.logLevels={verbose:0,debug:1,info:2,warn:3,error:4,none:5};this.level=e.level||"info",this.customLogger=e.customLogger}setLevel(e){this.level=e}setCustomLogger(e){this.customLogger=e}verbose(...e){this.log("verbose",...e)}debug(...e){this.log("debug",...e)}info(...e){this.log("info",...e)}warn(...e){this.log("warn",...e)}error(...e){this.log("error",...e)}log(e,...t){if(!(this.logLevels[e]y.getRandomValues(n);function C(n="req"){return`sb:client:${n}:${Date.now()}-${R(new Uint32Array(12))[0]}`}function k(n){let e;return n.code==1e3?e="Connection closed.":n.code==1001?e='Endpoint is "going away".':n.code==1002?e="Connection closed due to a protocol error.":n.code==1003||n.code==1007||n.code==1008||n.code==1010?e="Bad request.":n.code==1004?e="Reserved":n.code==1005?e="Missing status code.":n.code==1006?e="The connection was closed abnormally.":n.code==1009?e="Message size limit exceeded.":n.code==1011?e="Server terminated connection because due to unexpected condition.":n.code==1015?e="TLS handshake failure":e="Unknown error",e}async function h(n,e){let{timeout:t,message:r="Operation timed out.",controller:o}=e,s;return await Promise.race([new Promise((i,a)=>{s=setTimeout(()=>(o.abort(),console.debug("[withTimeout] timeout reached",e),a(new Error(r))),t),e.signal?.addEventListener("abort",()=>{clearTimeout(s),o?.abort(),a(new Error("Operation aborted."))},{once:!0})}),n]).finally(()=>{clearTimeout(s),o.abort()})}async function S(n){let e=new TextEncoder().encode(n),t=await T.digest("SHA-256",e),o=Array.from(new Uint8Array(t)).map(s=>s.toString(16).padStart(2,"0")).join("");return I(o)}function I(n){let e=new Uint8Array(n.match(/.{1,2}/g).map(r=>parseInt(r,16)));return btoa(String.fromCharCode.apply(null,Array.from(e)))}function v({timeout:n=1e4,addEventListener:e,removeEventListener:t}){let r=C("res"),o=new AbortController,s=o.signal,i=new Promise((a,c)=>{let d=b=>{let f=b?.data;f?.eventName===r&&(o.abort(),a(f?.args))};e("Custom.Event",d);let u=setTimeout(()=>{o.abort(),c(new Error("Timed out waiting for Custom Event"))},n);s.addEventListener("abort",()=>{clearTimeout(u),t(b=>b.events?.includes("Custom.Event")&&b.callback===d)},{once:!0})});return{responseId:r,promise:i,controller:o}}var A={scheme:"ws",host:"127.0.0.1",port:8080,endpoint:"/",immediate:!0,autoReconnect:!0,retries:-1,subscribe:{},logger:w,logLevel:"info"},l=class{constructor(e=A){this._authEnabled=!1;this._authenticated=!1;this.listeners=[];this.subscriptions={};this._explicitlyClosed=!1;this._retried=0;this._connectController=new AbortController;this._reconnectTimeout=void 0;this.options={...A,...e},this.logger=this.options.logger||null,this.logger&&this.options.logLevel&&this.logger.setLevel(this.options.logLevel),this.options.immediate===!0&&this.connect().catch(t=>this.logger?.warn("Failed to connect:",t))}get authenticated(){return!!this.socket&&this.socket.readyState===this.socket.OPEN&&this._authenticated}get ready(){return!this.socket||this.socket.readyState!==this.socket.OPEN||this._authEnabled&&!this._authenticated?!1:!!this.info&&!!this.version}async connect(e=1e4){if(this.socket?.readyState!==this.socket?.CLOSED)try{await this.disconnect()}catch{}this._explicitlyClosed=!1,this._connectController.abort(),this._connectController=new AbortController;let t=new AbortController;return this._connectController.signal.addEventListener("abort",()=>{t.abort()},{once:!0}),await h(new Promise(async(r,o)=>{try{this.options.password&&(this._authEnabled=!0);let s=`${this.options.scheme}://${this.options.host}:${this.options.port}${this.options.endpoint}`;this.logger?.debug("Connecting to Streamer.bot WebSocket server at",s,this._authEnabled?"with authentication":""),this.socket=globalThis?.process?.versions?.node?new(await Promise.resolve().then(()=>M(G(),1))).WebSocket(s):new WebSocket(s),this.socket.onmessage=this.onMessage.bind(this),this.socket.onopen=this.onOpen.bind(this),this.socket.onclose=this.onClose.bind(this),this.socket.onerror=this.onError.bind(this),this.socket.addEventListener("open",()=>{if(!this.socket)return o(new Error("WebSocket not initialized"));r()},{signal:t.signal}),this.socket.addEventListener("close",()=>o(new Error("WebSocket closed")),{once:!0})}catch(s){try{await this.disconnect(),this?.options?.onError?.(s)}catch(i){this.logger?.warn("Error invoking onError handler",i)}o(s)}}),{timeout:e,message:"WebSocket connection timeout exceeded",controller:t})}async disconnect(e=1e3,t=1e3){if(this._explicitlyClosed=!0,this._connectController.abort(),this._reconnectTimeout&&clearTimeout(this._reconnectTimeout),!this.socket||this.socket.readyState===this.socket.CLOSED)return;let r=new AbortController,o=r.signal;return await h(new Promise((s,i)=>{if(this.socket?.addEventListener("close",()=>{this.logger?.debug("Disconnected from Streamer.bot WebSocket server"),s()},{signal:o}),this.socket?.readyState!==this.socket?.CLOSING)try{this.socket?.close(e)}catch(a){i(a)}}),{timeout:t,message:"Timeout exceeded while closing connection",controller:r})}async handshake(){if(!this.socket)throw new Error("WebSocket not initialized");let e=new AbortController,{signal:t}=e;this._connectController.signal.addEventListener("abort",()=>{e.abort()},{once:!0,signal:t});let r=await h(new Promise((o,s)=>{this.socket?.addEventListener("message",async i=>{if(!("data"in i)||!i.data||typeof i.data!="string"){this.logger?.debug("Unknown message received",i);return}try{let a=JSON.parse(i.data);a&&"info"in a&&o(a)}catch(a){this.logger?.warn("Invalid JSON payload received",i.data),s(a)}},{signal:t})}),{timeout:5e3,message:"Handshake timeout exceeded",controller:e});if(!r||!("info"in r))throw new Error("Handshake failed (invalid payload)");if("request"in r&&r?.request==="Hello"&&r.authentication)return await this.authenticate(r);if(r.info&&!r.authentication){this.logger?.debug("Connected to Streamer.bot WebSocket server",r.info),this.info=r.info,this.version=r.info.version;return}throw new Error("Handshake failed (unknown)")}async authenticate(e){if(!this._authEnabled||!this.options.password){if(this.logger?.debug("No password provided for authentication. Checking if auth is enforced for all requests..."),(await this.getInfo()).status==="ok"){this._authenticated=!1,this.version=e.info.version,this.info=e.info;return}throw await this.disconnect(),new Error("Authentication required")}if(!e.authentication)throw this.logger?.debug("Missing authentication payload"),await this.disconnect(),new Error("Invalid authentication payload");this.logger?.debug("Authenticating with Streamer.bot WebSocket server...");let{salt:t,challenge:r}=e?.authentication,o=await S(`${this.options.password}${t}`),s=await S(`${o}${r}`);if((await this.request({request:"Authenticate",authentication:s})).status==="ok")this._authenticated=!0,this.version=e.info.version,this.info=e.info;else throw await this.disconnect(),new Error("Authentication failed")}async onOpen(){this._retried=0,this._reconnectTimeout&&clearTimeout(this._reconnectTimeout);try{this._authEnabled||this.getInfo().catch(()=>this.logger?.debug("Failed to fetch Streamer.bot instance info")),await this.handshake(),this.version&&this.info&&(this.logger?.debug(`Connected to Streamer.bot: v${this.version} (${this.info.name})`),await this.updateSupportedEvents(),this?.options?.onConnect?.(this.info))}catch(e){return this.logger?.warn("Failed handshake with Streamer.bot",e),this.options?.onError?.(e instanceof Error?e:new Error("Failed handshake with Streamer.bot")),await this.disconnect()}try{if(this.options.subscribe==="*"||typeof this.options.subscribe=="object"&&!Array.isArray(this.options.subscribe)&&Object.keys(this.options.subscribe??{}).length)this.logger?.debug("Subscribing to initial events from options:",this.options.subscribe),await this.subscribe(this.options.subscribe);else if(typeof this.options.subscribe=="string"||Array.isArray(this.options.subscribe)){this.logger?.debug("Subscribing to initial events from options:",this.options.subscribe);let e=await this.getSubscriptionsFromEventStrings(this.options.subscribe);this.logger?.debug("Parsed subscriptions from options:",e),e&&await this.subscribe(e)}if(this.listeners.length){let e=await this.getSubscriptionsFromListeners();await this.subscribe(e)}this.logger?.verbose("Subscribed to requested events",this.subscriptions,this.listeners)}catch(e){this.logger?.warn("Error subscribing to requested events",e)}}onClose(e){this._connectController.abort();try{(e.type==="error"||!e.wasClean)&&this.options.onError&&this?.options?.onError(new Error(k(e))),this?.options?.onDisconnect?.()}catch(t){this.logger?.warn("Error invoking user-provided onDisconnect handler",t)}if(this._explicitlyClosed||!this.options.autoReconnect)return this.logger?.debug("Cleaning up..."),this.cleanup();this._retried+=1,typeof this.options.retries=="number"&&(this.options.retries<0||this._retried{if(!(this.socket&&this.socket.readyState!==this.socket.CLOSED)){this.logger?.debug(`Reconnecting... (attempt ${this._retried})`);try{await this.connect(1e4)}catch(t){this._retried&&this.logger?.warn(`Failed to reconnect (attempt ${this._retried-1})`,t)}}},Math.min(3e4,this._retried*1e3))):(this.logger?.debug("Auto-reconnect limit reached. Cleaning up..."),this.cleanup())}async onMessage(e){if(!e.data||typeof e.data!="string"){this.logger?.debug("Unknown message received",e);return}let t;try{t=JSON.parse(e.data)}catch(r){this.logger?.warn("Invalid JSON payload received",e.data,r);return}this.logger?.verbose("RECV",t);try{this.options.onData&&this?.options?.onData(t)}catch(r){this.logger?.warn("Error occurred within user-provided onData callback",r)}if(t?.event?.source&&t?.event?.type){for(let r of this.listeners)if(r.events?.length&&r.events.find(o=>o==="*"||o===`${t?.event?.source}.${t?.event?.type}`||o.split(".",2)?.[1]==="*"&&o.split(".",2)?.[0]===t?.event?.source))try{r.callback(t)}catch(o){this.logger?.warn(`Error occurred within user-provided event callback (${r.events})`,o)}}}onError(e){this.logger?.debug("WebSocket onError",e),this.socket&&this.socket.readyState!==this.socket.OPEN&&this._connectController.abort();try{this?.options?.onError?.(new Error("WebSocket Error"))}catch(t){this.logger?.warn("Error occurred within user-provided onError callback",t)}}cleanup(){this.socket&&(this.socket.onopen=null,this.socket.onclose=null,this.socket.onerror=null,this.socket.onmessage=null,this.socket=void 0),this.listeners=[],this._retried=0,this._connectController.abort(),this._reconnectTimeout&&clearTimeout(this._reconnectTimeout)}send(e){this.socket?.send(JSON.stringify(e))}async request(e,t="",r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");t||(t=C());let o=new AbortController,s=o.signal;this._connectController.signal.addEventListener("abort",()=>{o.abort()},{once:!0,signal:s});let i=await h(new Promise((a,c)=>{this.socket?.addEventListener("message",d=>{if(!("data"in d)||!d.data||typeof d.data!="string"){this.logger?.debug("Unknown message received",d.data);return}try{let u=JSON.parse(d?.data);if(u?.id===t)return this.logger?.verbose(`RECV :: ${e.request}`,u),a(u)}catch(u){this.logger?.warn("Invalid JSON payload received",d.data),c(u)}},{signal:s}),this.logger?.verbose(`SEND :: ${e.request}`,{...e,id:t}),this.send({...e,id:t})}),{timeout:r,message:"Request timed out",controller:o,signal:s});if(i?.status==="ok"){try{this.options.onData&&this?.options?.onData(i)}catch(a){this.logger?.warn("Error invoking onData handler",a)}return{event:{source:"Request",type:e.request??"Unknown"},...i}}throw new Error("Request failed")}async on(e,t){try{if(!e)return;let r={events:[e],callback:t};if(this.listeners.push(r),this.ready){let o=await this.getSubscriptionsFromListeners([r]);await this.subscribe(o)}this.logger?.debug(`Added event listener for "${e}"`)}catch(r){this.logger?.warn(`Failed adding event listener for "${e}"`,r)}}async updateSupportedEvents(){if(this.ready)try{let e=await this.getEvents();if(e.status!=="ok"||!e.events)throw new Error(e.status);this.supportedEvents=e.events,this.logger?.debug(`Successfully fetched supported event types for Streamer.bot v${this.version}`)}catch(e){this.logger?.warn("Failed to fetch supported events from Streamer.bot, falling back to stored events type.",e),this.supportedEvents=g}}async getSupportedEvents(){return this.supportedEvents||(this.logger?.warn("Supported event types not yet initialized, fetching from Streamer.bot instance..."),await this.updateSupportedEvents()),this.supportedEvents??g}getEventsFromListeners(e){return(e??this.listeners).reduce((t,r)=>(r.events.forEach(o=>{t[o]||(t[o]=[]),t[o].push(r.callback)}),t),{})}async getSubscriptionsFromListeners(e){let t=this.getEventsFromListeners(e);return this.getSubscriptionsFromEventStrings(Object.keys(t))}async getSubscriptionsFromEventStrings(e){let t={};typeof e=="string"&&(e=[e]);for(let r of e){let o=await this.parseEventString(r);if(o)for(let s of o){let{source:i,eventTypes:a}=s,c=new Set([...t[i]??[],...a]);t[i]=[...c]}}return t}async parseEventString(e){let t=await this.getSupportedEvents();if(!e||typeof e!="string"){this.logger?.warn(`Invalid event subscription requested "${e}"`);return}if(e==="*")return Object.keys(t).map(r=>{let o=r,s=t[o]??[];return{source:o,eventTypes:s}});{let[r,o]=e.split(".",2);if(!r||!o||!(r in t)){this.logger?.warn(`Invalid event subscription requested "${e}"`);return}let s=r,i=o;if(i)return[{source:s,eventTypes:i==="*"?t[s]:[i]}];this.logger?.warn(`Invalid event type requested "${e}"`);return}}async subscribe(e){let t=await this.getSupportedEvents();e==="*"&&(e=t);for(let r in e){if(!r||r==="err")continue;if(!(r in t)){this.logger?.warn(`Attempted to subscribe to empty or unknown event source: "${r}"`,Object.keys(e));continue}let o=r,s=e[o]??[];if(s&&s.length){let i=new Set([...this.subscriptions[o]??[],...s]);this.subscriptions[o]=[...i]}}return Object.keys(this.subscriptions).length===0?(this.logger?.warn("No valid events to subscribe to. Please provide valid event sources and types."),{id:"invalid",status:"error",error:"No valid events to subscribe to"}):await this.request({request:"Subscribe",events:this.subscriptions})}async unsubscribe(e){let t=await this.getSupportedEvents();e==="*"&&(e=t);for(let r in e){if(r===void 0||!Object.keys(t).includes(r))continue;let o=r,s=e[o];if(s&&s.length)for(let i of s)i&&this.subscriptions[o]?.filter&&(this.subscriptions[o]=this.subscriptions[o])?.filter(a=>i!==a)}return await this.request({request:"UnSubscribe",events:e})}async getEvents(){return await this.request({request:"GetEvents"})}async getActions(){return await this.request({request:"GetActions"})}async doAction(e,t,r){if(r?.customEventResponse)return this.doActionWithCustomEventResponse(e,t);let o,s;return typeof e=="string"?o=e:(o=e.id,s=e.name),await this.request({request:"DoAction",action:{id:o,name:s},args:t})}async doActionWithCustomEventResponse(e,t={},r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");let{responseId:o,promise:s,controller:i}=v({timeout:r,addEventListener:(c,d)=>this.on(c,d),removeEventListener:c=>{this.listeners=this.listeners.filter(d=>!c(d))}}),a={...t,sbClientResponse:o};try{let c=await this.doAction(e,a),d=await s;return{...c,customEventResponseArgs:d}}catch(c){throw i.signal.aborted||i.abort(),c}}async getBroadcaster(){return await this.request({request:"GetBroadcaster"})}async getMonitoredYouTubeBroadcasts(){return await this.request({request:"GetMonitoredYouTubeBroadcasts"})}async getCredits(){return await this.request({request:"GetCredits"})}async testCredits(){return await this.request({request:"TestCredits"})}async clearCredits(){return await this.request({request:"ClearCredits"})}async getInfo(){return await this.request({request:"GetInfo"})}async getActiveViewers(){return await this.request({request:"GetActiveViewers"})}async executeCodeTrigger(e,t,r){return r?.customEventResponse?this.executeCodeTriggerWithCustomEventResponse(e,t):await this.request({request:"ExecuteCodeTrigger",triggerName:e,args:t})}async executeCodeTriggerWithCustomEventResponse(e,t={},r=1e4){if(!this.socket||this.socket.readyState!==this.socket.OPEN)throw new Error("WebSocket is not connected");let{responseId:o,promise:s,controller:i}=v({timeout:r,addEventListener:(c,d)=>this.on(c,d),removeEventListener:c=>{this.listeners=this.listeners.filter(d=>!c(d))}}),a={...t,sbClientResponse:o};try{let c=await this.executeCodeTrigger(e,a),d=await s;return{...c,customEventResponseArgs:d}}catch(c){throw i.signal.aborted||i.abort(),c}}async getCodeTriggers(){return await this.request({request:"GetCodeTriggers"})}async getCommands(){return await this.request({request:"GetCommands"})}async getEmotes(e){switch(e){case"twitch":return await this.request({request:"TwitchGetEmotes"});case"youtube":return await this.request({request:"YouTubeGetEmotes"});default:throw new Error("Invalid platform")}}async getGlobals(e=!0){return await this.request({request:"GetGlobals",persisted:e})}async getGlobal(e,t=!0){let r=await this.request({request:"GetGlobal",variable:e,persisted:t});return r.status==="ok"?r.variables[e]?{id:r.id,status:r.status,variable:r.variables[e]}:{status:"error",error:"Variable not found"}:r}async getUserGlobals(e,t=null,r=!0){let s={twitch:"TwitchGetUserGlobals",youtube:"YouTubeGetUserGlobals",trovo:"TrovoGetUserGlobals",kick:"KickGetUserGlobals"}[e];if(!s)throw new Error("Invalid platform");return await this.request({request:s,variable:t,persisted:r})}async getUserGlobal(e,t,r=null,o=!0){let i={twitch:"TwitchGetUserGlobal",youtube:"YouTubeGetUserGlobal",trovo:"TrovoGetUserGlobal",kick:"KickGetUserGlobal"}[e];if(!i)throw new Error("Invalid platform");let a=await this.request({request:i,userId:t,variable:r||null,persisted:o});if(a.status==="ok"&&t&&r){let c=a.variables.find(d=>d.name===r);return c?{id:a.id,status:a.status,variable:c}:{status:"error",error:"Variable not found"}}return a}async sendMessage(e,t,{bot:r=!1,internal:o=!0,...s}={}){if(!this._authenticated)return{status:"error",error:"Authentication required"};let i={platform:e,message:t,bot:r,internal:o};return["twitch","kick"].includes(e)&&s.replyId&&Object.assign(i,{replyId:s.replyId}),["youtube"].includes(e)&&s.broadcastId&&Object.assign(i,{broadcastId:s.broadcastId}),await this.request({...i,request:"SendMessage"})}async getUserPronouns(e,t){return await this.request({request:"GetUserPronouns",platform:e,userLogin:t})}};Object.assign(globalThis,{StreamerbotClient:l});return B(_);})();