fixed sync issues

This commit is contained in:
liyunze 2026-07-20 23:27:37 +10:00
parent a4d75c930f
commit c57bfd75fb
21 changed files with 101 additions and 1622 deletions

37
Code.cs
View file

@ -464,7 +464,7 @@ public class TaskOperations
} }
int newIndex = taskData[key].Tasks.Count - 1; 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); return new Response<(int, string)>(true, (newIndex, taskName), null);
} }
@ -481,7 +481,7 @@ public class TaskOperations
string oldName = userTasks[index].Name; string oldName = userTasks[index].Name;
userTasks[index].Name = newTask; userTasks[index].Name = newTask;
SaveIntoTasks(userTasks); 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); return new Response<(string, string)>(true, (oldName, newTask), null);
} }
@ -502,7 +502,7 @@ public class TaskOperations
UnfocusAll(tasks); UnfocusAll(tasks);
tasks[n].Focused = true; tasks[n].Focused = true;
SaveIntoTasks(tasks); 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); return new Response<(int, string)>(true, (n, tasks[n].Name), null);
} }
else if (indexByName > -1) else if (indexByName > -1)
@ -513,7 +513,7 @@ public class TaskOperations
UnfocusAll(tasks); UnfocusAll(tasks);
tasks[n].Focused = true; tasks[n].Focused = true;
SaveIntoTasks(tasks); 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); return new Response<(int, string)>(true, (n, tasks[n].Name), null);
} }
else else
@ -521,7 +521,7 @@ public class TaskOperations
var response = AddTask(rawInput, false, true); var response = AddTask(rawInput, false, true);
if (response.Success) 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); return new Response<(int, string)>(true, response.Data, null);
} }
@ -846,9 +846,9 @@ public class CPHInline
string completedTaskName = userTasks[focusedTaskIndex].Name; string completedTaskName = userTasks[focusedTaskIndex].Name;
userTasks[focusedTaskIndex].Completed = true; userTasks[focusedTaskIndex].Completed = true;
userTasks[focusedTaskIndex].Focused = false; userTasks[focusedTaskIndex].Focused = false;
Broadcast(new { mode = "refresh", index = focusedTaskIndex }, null);
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "done", index = focusedTaskIndex }, null);
Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2)); Respond(BotResponses.NextSuccess(completedTaskName, focusResponse.Data.Item1 + 1, focusResponse.Data.Item2));
return true; return true;
} }
@ -997,12 +997,15 @@ public class CPHInline
foreach (int i in taskIndices.OrderByDescending(n => n)) foreach (int i in taskIndices.OrderByDescending(n => n))
{ {
userTasks.RemoveAt(i); userTasks.RemoveAt(i);
Broadcast(new { mode = "refresh", index = i }, null);
} }
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
operations.Cleanup(true); operations.Cleanup(true);
SaveTasks(); SaveTasks();
foreach (int i in taskIndices.OrderByDescending(n => n))
{
Broadcast(new { mode = "remove", index = i }, null);
}
Respond(MessageBuilder.BuildRemoveMessage(tasksRemoved, tasksFailedToRemove, allTasks)); Respond(MessageBuilder.BuildRemoveMessage(tasksRemoved, tasksFailedToRemove, allTasks));
return true; return true;
} }
@ -1024,7 +1027,7 @@ public class CPHInline
operations.RemoveUser(key); operations.RemoveUser(key);
SaveTasks(); SaveTasks();
Respond(BotResponses.AdminDeleteSuccess); Respond(BotResponses.AdminDeleteSuccess);
Broadcast(new { mode = "refresh", id = key }, null); Broadcast(new { mode = "admindelete", id = key }, null);
return true; return true;
} }
@ -1081,12 +1084,15 @@ public class CPHInline
{ {
userTasks[i].Completed = true; userTasks[i].Completed = true;
userTasks[i].Focused = false; userTasks[i].Focused = false;
Broadcast(new { mode = "refresh", index = i }, null);
} }
IncrementDoneCount(taskIndices.Count); IncrementDoneCount(taskIndices.Count);
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
SaveTasks(); SaveTasks();
foreach (int i in taskIndices)
{
Broadcast(new { mode = "done", index = i }, null);
}
Respond(MessageBuilder.BuildCompletedMessage(tasksCompleted, tasksFailedToComplete, allTasks)); Respond(MessageBuilder.BuildCompletedMessage(tasksCompleted, tasksFailedToComplete, allTasks));
return true; return true;
} }
@ -1096,7 +1102,7 @@ public class CPHInline
operations.Unfocus(); operations.Unfocus();
SaveTasks(); SaveTasks();
Respond(BotResponses.Unfocused); Respond(BotResponses.Unfocused);
Broadcast(new { mode = "refresh" }, null); Broadcast(new { mode = "unfocus" }, null);
return true; return true;
} }
@ -1144,11 +1150,14 @@ public class CPHInline
{ {
userTasks[i].Completed = false; userTasks[i].Completed = false;
userTasks[i].Focused = false; userTasks[i].Focused = false;
Broadcast(new { mode = "refresh", index = i }, null);
} }
operations.SaveIntoTasks(userTasks); operations.SaveIntoTasks(userTasks);
SaveTasks(); SaveTasks();
foreach (int i in taskIndices)
{
Broadcast(new { mode = "undone", index = i }, null);
}
Respond(MessageBuilder.BuildUndoneMessage(tasksCompleted, tasksFailedToComplete)); Respond(MessageBuilder.BuildUndoneMessage(tasksCompleted, tasksFailedToComplete));
return true; return true;
} }
@ -1169,7 +1178,7 @@ public class CPHInline
operations.ClearUserCompletedTasks(key); operations.ClearUserCompletedTasks(key);
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "refresh" }, null); Broadcast(new { mode = "clearmydone" }, null);
Respond(BotResponses.ClearMyDone); Respond(BotResponses.ClearMyDone);
return true; return true;
} }
@ -1179,7 +1188,7 @@ public class CPHInline
operations.ClearCompletedTasks(); operations.ClearCompletedTasks();
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "refresh" }, null); Broadcast(new { mode = "cleardone" }, null);
Respond(BotResponses.ClearDone); Respond(BotResponses.ClearDone);
return true; return true;
} }
@ -1189,7 +1198,7 @@ public class CPHInline
operations.FilterToStreamers(GetStreamerUsernames()); operations.FilterToStreamers(GetStreamerUsernames());
operations.Cleanup(false); operations.Cleanup(false);
SaveTasks(); SaveTasks();
Broadcast(new { mode = "refresh" }, null); Broadcast(new { mode = "clearns" }, null);
Respond(BotResponses.ClearNotStreamer); Respond(BotResponses.ClearNotStreamer);
return true; return true;
} }

View file

@ -6,7 +6,7 @@
Websocket server running is required for browser source to work. 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 1. Streamer.Bot -> Servers/Clients -> Websocket Server
2. Auto Start: `ON`; Click on `Start Server` 2. Auto Start: `ON`; Click on `Start Server`

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="../shared/lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script> <script defer src="../shared/scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="../shared/scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="../shared/scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>
<link rel="stylesheet" href="./styles/style.css" /> <link rel="stylesheet" href="./styles/style.css" />
<link rel="stylesheet" href="./styles/structure.css" /> <link rel="stylesheet" href="./styles/structure.css" />

View file

@ -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(
`(?<![\\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

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

View file

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

View file

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -9,17 +9,6 @@ const client = new StreamerbotClient({
onError: onError, 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)); client.on("General.Custom", (data) => onCustom(data));
if (configs.userColorSettings.autoUserColor) { if (configs.userColorSettings.autoUserColor) {
client.on("Twitch.ChatMessage", (data) => onChatMessage(data)); client.on("Twitch.ChatMessage", (data) => onChatMessage(data));
@ -29,49 +18,35 @@ if (configs.userColorSettings.autoUserColor) {
let taskList; let taskList;
let userColors = {}; let userColors = {};
function parseHexColor(hex) { function getUserColor(sectionId) {
if (typeof hex !== "string") return null; return (
const s = hex.trim(); userColors[`${sectionId}-color`] ??
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s); localStorage.getItem(`${sectionId}-color`)
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 }) { function taskColorFromSettings(sectionId) {
const toLinear = (v) => { const usernameColor = getUserColor(sectionId);
const s = v / 255; if (
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); configs.userColorSettings.autoUserColor &&
}; usernameColor != undefined &&
const R = toLinear(r); usernameColor != null
const G = toLinear(g); ) {
const B = toLinear(b); return usernameColor;
return 0.2126 * R + 0.7152 * G + 0.0722 * B; }
return undefined;
} }
async function onChatMessage(data) { async function onChatMessage(data) {
let userColor = data.data.user.color; // hex colour #FF69B4 const userColor = data.data.user.color;
if (userColor != undefined || userColor != "undefined") { if (userColor != undefined && userColor != "undefined") {
// update localstorage const userId = data.data.user.id;
// get id: platform-userID const key = `twitch-${userId}`;
let userId = data.data.user.id;
let key = `twitch-${userId}`;
localStorage.setItem(`${key}-color`, userColor); localStorage.setItem(`${key}-color`, userColor);
userColors[`${key}-color`] = userColor; userColors[`${key}-color`] = userColor;
} }
return;
} }
function onDisconnect() { function onDisconnect() {
@ -85,7 +60,6 @@ function onError(err) {
} }
function showConnectionError(message) { function showConnectionError(message) {
// Remove existing popup if any
const existing = document.getElementById("connection-error"); const existing = document.getElementById("connection-error");
if (existing) existing.remove(); if (existing) existing.remove();
@ -110,7 +84,6 @@ function showConnectionError(message) {
}); });
document.body.appendChild(popup); document.body.appendChild(popup);
// Auto-dismiss after 5 seconds
setTimeout(() => { setTimeout(() => {
popup.animate([{ opacity: 1 }, { opacity: 0 }], { popup.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 300, duration: 300,
@ -121,7 +94,6 @@ function showConnectionError(message) {
async function refresh() { async function refresh() {
const response = await client.getGlobal("rython-task-bot", true); const response = await client.getGlobal("rython-task-bot", true);
console.log(response);
if (response.status !== "ok" || !response.variable?.value) return; if (response.status !== "ok" || !response.variable?.value) return;
@ -131,19 +103,18 @@ async function refresh() {
taskList.load(sections); taskList.load(sections);
} }
// LOAD TASK LIST
async function onConnect() { async function onConnect() {
taskList = new TaskList(".task-panel"); taskList = new TaskList(".task-panel");
if (configs.emoteSettings.enabled) { if (configs.emoteSettings.enabled) {
let broadcaster = await client.getBroadcaster(); const broadcaster = await client.getBroadcaster();
let broadcasterName = const broadcasterName =
broadcaster.platforms.twitch?.broadcastUser ?? broadcaster.platforms.twitch?.broadcastUser ??
broadcaster.platforms.youtube?.broadcastUser ?? broadcaster.platforms.youtube?.broadcastUser ??
broadcaster.platforms.kick?.broadcastUser; broadcaster.platforms.kick?.broadcastUser;
let broadcasterId = const broadcasterId =
broadcaster.platforms.twitch?.broadcastUserId ?? broadcaster.platforms.twitch?.broadcastUserId ??
broadcaster.platforms.youtube?.broadcastUserId ?? broadcaster.platforms.youtube?.broadcastUserId ??
broadcaster.platforms.kick?.broadcastUserId; broadcaster.platforms.kick?.broadcastUserId;
@ -177,19 +148,58 @@ function transformToSections(users) {
})); }));
} }
// Update task list action by action
function onCustom(payload) { function onCustom(payload) {
const data = payload.data; const data = payload.data;
if (!data.source && data.source != "rython-task-bot") { if (data.source !== "rython-task-bot") {
return; return;
} }
if (!taskList) return; if (!taskList) return;
const body = data.body; const body = data.body;
const id = data.id; const id = data.id;
const username = data.username; const username = data.username;
switch (body.mode) { 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(); refresh();
break; break;
default: default:

View file

@ -6,7 +6,7 @@
Websocket server running is required for browser source to work. 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 1. Streamer.Bot -> Servers/Clients -> Websocket Server
2. Auto Start: `ON`; Click on `Start Server` 2. Auto Start: `ON`; Click on `Start Server`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="../shared/lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script> <script defer src="../shared/scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="./scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="../shared/scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>
<link rel="stylesheet" href="./styles/style.css" /> <link rel="stylesheet" href="./styles/style.css" />
<link rel="stylesheet" href="./styles/structure.css" /> <link rel="stylesheet" href="./styles/structure.css" />

File diff suppressed because one or more lines are too long

View file

@ -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(
`(?<![\\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

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

View file

@ -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 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 1. Streamer.Bot -> Servers/Clients -> Websocket Server
2. Auto Start: `ON`; Click on `Start Server` 2. Auto Start: `ON`; Click on `Start Server`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./lib/streamerbot-client.js"></script> <script src="../shared/lib/streamerbot-client.js"></script>
<script defer src="./config.js"></script> <script defer src="./config.js"></script>
<script defer src="./scripts/emotes.js"></script> <script defer src="../shared/scripts/emotes.js"></script>
<script defer src="./scripts/tasklist-view.js"></script> <script defer src="../shared/scripts/tasklist-view.js"></script>
<script defer src="./scripts/streamerbot.js"></script> <script defer src="../shared/scripts/streamerbot.js"></script>
<script defer src="./scripts/header-animation.js"></script> <script defer src="./scripts/header-animation.js"></script>
<link rel="stylesheet" href="./styles/style.css" /> <link rel="stylesheet" href="./styles/style.css" />
<link rel="stylesheet" href="./styles/structure.css" /> <link rel="stylesheet" href="./styles/structure.css" />

File diff suppressed because one or more lines are too long