refresh, added emote support

This commit is contained in:
liyunze 2026-07-01 16:55:55 +10:00
parent 168ef05777
commit e6c58a1ec7
20 changed files with 1016 additions and 233 deletions

28
Code.cs
View file

@ -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;
}

View file

@ -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,
};
})();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script>

View file

@ -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(
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
"g",
);
this.#patternSize = this.#emotes.size;
}
parseText(text) {
if (!text || !this.#loaded || this.#emotes.size === 0) return text;
this.#buildPattern();
if (!this.#pattern) return text;
return text.replace(this.#pattern, (match) => {
const emote = this.#emotes.get(match);
if (!emote) return match;
console.log(emote);
return `<img src="${emote.url}" alt="${match}" title="${match} (${emote.provider})" class="emote-img" />`;
});
}
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;
}
}
}

View file

@ -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;
@ -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;
}

View file

@ -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}.`;

View file

@ -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;
}

View file

@ -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,
};
})();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script>

View file

@ -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(
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
"g",
);
this.#patternSize = this.#emotes.size;
}
parseText(text) {
if (!text || !this.#loaded || this.#emotes.size === 0) return text;
this.#buildPattern();
if (!this.#pattern) return text;
return text.replace(this.#pattern, (match) => {
const emote = this.#emotes.get(match);
if (!emote) return match;
console.log(emote);
return `<img src="${emote.url}" alt="${match}" title="${match} (${emote.provider})" class="emote-img" />`;
});
}
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;
}
}
}

View file

@ -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;
}

View file

@ -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}.`;

View file

@ -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 */
@ -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;
}

View file

@ -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.

View file

@ -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,
};
})();

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script>

View file

@ -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(
`(?<![\\w])(?:${escaped.join("|")})(?![\\w])`,
"g",
);
this.#patternSize = this.#emotes.size;
}
parseText(text) {
if (!text || !this.#loaded || this.#emotes.size === 0) return text;
this.#buildPattern();
if (!this.#pattern) return text;
return text.replace(this.#pattern, (match) => {
const emote = this.#emotes.get(match);
if (!emote) return match;
console.log(emote);
return `<img src="${emote.url}" alt="${match}" title="${match} (${emote.provider})" class="emote-img" />`;
});
}
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;
}
}
}

View file

@ -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;
@ -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;
}

View file

@ -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}.`;

View file

@ -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;
}