Refine overlay chat previews and media

This commit is contained in:
Franz Rolfsvaag 2026-07-19 13:01:16 +02:00
parent 97e6615183
commit f335bbe24a
25 changed files with 348 additions and 69 deletions

View File

@ -1,5 +1,12 @@
# Lumi changelog # Lumi changelog
## 0.2.16
- Kept keyboard focus in source fields while server-normalized live previews rerender, preventing interrupted typing and other continuous input.
- Added cached Twitch profile-image lookup and Discord custom/animated emoji, GIF, GIFV, attachment, and sticker rendering for native chat overlays.
- Made chat alignment apply to complete message groups, made message backgrounds fit their content, and added directional entry/exit animations.
- Expanded the editor simulation to cycle realistic short, wrapped, avatar, badge, platform, arrival, and departure examples continuously.
## 0.2.15 ## 0.2.15
- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking. - Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking.

View File

@ -33,7 +33,10 @@ temporary website CSS), marks/reverts drafts, provides contrast and grid views,
and keeps save actions reachable. Alert documents also retain transparent page and keeps save actions reachable. Alert documents also retain transparent page
and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
platform messages, image-based platform/badge marks, and server-enforced platform platform messages, image-based platform/badge marks, and server-enforced platform
or linked-Lumi-account user blocking. or linked-Lumi-account user blocking. Live draft rerenders preserve form focus;
chat messages now use content-sized cards and whole-message alignment, cached
Twitch avatars, Discord emoji/GIF/sticker media, four-direction entry/exit
animations, and a continuously animated multi-message editor simulation.
Remaining work: Remaining work:

View File

@ -74,16 +74,23 @@ accept one value per line and may be prefixed with the service, such as
`twitch:cozycarnage` or `discord:community-chat`. `twitch:cozycarnage` or `discord:community-chat`.
Chat sources support transparent source and message backgrounds, font family, Chat sources support transparent source and message backgrounds, font family,
size, weight and alignment, name/text colors, avatars, badges, platform labels, size, weight and whole-message alignment, name/text colors, avatars, badges, platform labels,
timestamps, message spacing, rounded corners, maximum visible messages, timestamps, message spacing, rounded corners, maximum visible messages,
top-or-bottom placement, timeout removal, and separate entry and exit top-or-bottom placement, timeout removal, and separate entry and exit
animations. Twitch emotes are displayed as images. The editor canvas supplies animations. Message cards fit their contents up to the source boundary rather
sample messages so appearance can be configured before the stream is live. than filling its width. Entry and exit slides can travel left, right, up, or
down. Twitch emotes and Discord custom or animated emoji are displayed as
images. Discord GIF links, GIF attachments, GIFV embeds, and stickers are shown
as bounded inline media at roughly two text lines tall. The editor continuously
cycles several short and wrapped sample messages so both static appearance and
entry/exit behavior can be configured before the stream is live.
Lumi includes messages sent by its own connected bot accounts. Platform marks Lumi includes messages sent by its own connected bot accounts. Platform marks
are shown as icons, and chat badges are rendered as images. Twitch badge artwork are shown as icons, and chat badges are rendered as images. Twitch badge artwork
is resolved from Twitch's global and channel badge catalogs; a local image is resolved from Twitch's global and channel badge catalogs; a local image
fallback is used when a provider does not expose badge artwork. fallback is used when a provider does not expose badge artwork. Twitch profile
images are resolved through the Twitch user catalog and cached to avoid a lookup
for every message.
The optional hidden-user list accepts one rule per line in the form The optional hidden-user list accepts one rule per line in the form
`platform:username|user-id`. Twitch, YouTube, and Discord rules match only that `platform:username|user-id`. Twitch, YouTube, and Discord rules match only that
@ -94,7 +101,8 @@ and is also checked by the renderer.
Advanced CSS is isolated to that chat source and is appended after the visual Advanced CSS is isolated to that chat source and is appended after the visual
settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`, settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`,
`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, and `.emote`. `.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, `.emote`,
`.media-row`, and `.media`.
Messages arrive through the same token-scoped overlay event connection as scene Messages arrive through the same token-scoped overlay event connection as scene
updates. Lumi keeps a small, short-lived in-memory replay window so a temporary updates. Lumi keeps a small, short-lived in-memory replay window so a temporary
Browser Source reconnection can restore recent messages without writing chat Browser Source reconnection can restore recent messages without writing chat

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.15 Version: 0.2.16
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.15", "version": "0.2.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.15", "version": "0.2.16",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.15", "version": "0.2.16",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.2.16",
"ref": "refs/tags/v0.2.16",
"released_at": "2026-07-19",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Improves native chat preview focus, avatars, compact alignment and sizing, directional animations, and Discord emoji/GIF media. Existing overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{ {
"version": "0.2.15", "version": "0.2.15",
"ref": "refs/tags/v0.2.15", "ref": "refs/tags/v0.2.15",

View File

@ -19,7 +19,8 @@ for (const file of [
"overlays.js", "overlays.js",
"overlay-permissions.js", "overlay-permissions.js",
"overlay-connectors.js", "overlay-connectors.js",
"overlay-chat.js" "overlay-chat.js",
"discord-chat-content.js"
]) { ]) {
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file)); fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
} }
@ -161,6 +162,8 @@ try {
assert.strictEqual(chatConfig.background, "#0000"); assert.strictEqual(chatConfig.background, "#0000");
assert.strictEqual(chatConfig.inAnimation, "scale"); assert.strictEqual(chatConfig.inAnimation, "scale");
assert(chatConfig.customCss.includes(".message")); assert(chatConfig.customCss.includes(".message"));
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).inAnimation, "slide-right");
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).outAnimation, "slide-up");
assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]); assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]);
const styledWebConfig = overlayModules.normalizeModuleConfig("web", { const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
url: "https://example.com/widget", url: "https://example.com/widget",
@ -212,6 +215,25 @@ try {
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true); assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true); assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false); assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
const mediaOnlyChat = overlayChat.normalizeChatMessage({
id: "message-gif",
platform: "discord",
text: "",
media: [{ url: "https://cdn.example/dance.gif", type: "image", alt: "Dance" }],
author: { id: "discord-viewer", name: "Discord viewer" }
});
assert.strictEqual(mediaOnlyChat.media[0].url, "https://cdn.example/dance.gif");
assert.strictEqual(mediaOnlyChat.text, "");
const { discordOverlayContent } = require(path.join(serviceDir, "discord-chat-content.js"));
const discordContent = discordOverlayContent({
content: "Hello <a:party:123456> https://cdn.example/dance.gif",
attachments: new Map(),
embeds: [],
stickers: new Map()
});
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
overlayChat.publishOverlayChatMessage(normalizedChat); overlayChat.publishOverlayChatMessage(normalizedChat);
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1")); assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id); const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
@ -291,6 +313,7 @@ try {
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow"); assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets")); assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved")); assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position"); assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource")); assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage")); assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
@ -299,6 +322,10 @@ try {
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts")); assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/")); assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
assert(rendererScript.includes("/icons/badges/")); assert(rendererScript.includes("/icons/badges/"));
assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat"));
assert(rendererScript.includes("width:fit-content") && rendererScript.includes("align-self:${alignment}"));
assert(rendererScript.includes("enter-slide-right") && rendererScript.includes("leave-slide-up"));
assert(rendererScript.includes('mediaData.type === "video"') && rendererScript.includes('className = "media-row"'));
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")')); assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")')); assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
assert(rendererScript.includes("values.renderUrl || values.url")); assert(rendererScript.includes("values.renderUrl || values.url"));
@ -323,7 +350,8 @@ try {
assert.strictEqual(obsLayout.scaleX, 1); assert.strictEqual(obsLayout.scaleX, 1);
assert.strictEqual(obsLayout.scaleY, 1); assert.strictEqual(obsLayout.scaleY, 1);
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report")); assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
assert(twitchBadgeScript.includes("api.twitch.tv/helix/chat/badges") && twitchBadgeScript.includes("image_url_2x")); assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x"));
assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}"));
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument")); assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket")); assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.15"; const releaseVersion = "0.2.16";
const previousCoreVersion = "0.2.14"; const previousCoreVersion = "0.2.15";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.15, Lumi AI 0.8.5, and synchronized package metadata."); console.log("Release metadata verification passed: core 0.2.16, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ available_versions: [
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
{ version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true }, { version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true },
{ version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true }, { version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true },
{ version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true }, { version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true },
@ -75,7 +76,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.15"); assert.equal(corrected.safe_target_version, "0.2.16");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -0,0 +1,59 @@
function collectionValues(collection) {
if (!collection) return [];
if (Array.isArray(collection)) return collection;
if (typeof collection.values === "function") return [...collection.values()];
return Object.values(collection);
}
function discordOverlayContent(message) {
let text = String(message?.content || "");
const media = [];
const mediaUrls = new Set();
const removedLinks = new Set();
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => {
const clean = String(url || "").trim();
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
mediaUrls.add(clean);
media.push({ url: clean, type: type === "video" ? "video" : "image", alt });
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
};
for (const attachment of collectionValues(message?.attachments)) {
const url = attachment?.url || attachment?.proxyURL;
const contentType = String(attachment?.contentType || attachment?.content_type || "").toLowerCase();
if (contentType === "image/gif" || /\.gif(?:$|\?)/i.test(String(url || ""))) {
addMedia(url, "image", attachment?.name || "Animated image");
}
}
for (const embed of collectionValues(message?.embeds)) {
const sourceUrl = String(embed?.url || "");
const embedType = String(embed?.type || "").toLowerCase();
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl);
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
}
for (const sticker of collectionValues(message?.stickers)) {
addMedia(sticker?.url, "image", sticker?.name || "Sticker");
}
for (const match of text.matchAll(/https?:\/\/[^\s<>]+\.gif(?:\?[^\s<>]*)?/gi)) {
addMedia(match[0], "image", "Animated image", match[0]);
}
for (const link of removedLinks) text = text.split(link).join("");
text = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
const emotes = [];
for (const match of text.matchAll(/<(a?):([a-zA-Z0-9_]+):(\d+)>/g)) {
const animated = match[1] === "a";
emotes.push({
start: match.index,
end: match.index + match[0].length - 1,
image: `https://cdn.discordapp.com/emojis/${match[3]}.${animated ? "gif" : "webp"}?size=96&quality=lossless`,
label: `:${match[2]}:`
});
}
return { text, emotes, media };
}
module.exports = { discordOverlayContent };

View File

@ -9,6 +9,7 @@ const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users"); const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger"); const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat"); const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content");
const discordLog = createLogger("platform:discord", { category: "integration" }); const discordLog = createLogger("platform:discord", { category: "integration" });
@ -53,6 +54,7 @@ async function startBot({ commandRouter } = {}) {
client.on("messageCreate", async (message) => { client.on("messageCreate", async (message) => {
if (!message.guild) return; if (!message.guild) return;
const overlayContent = discordOverlayContent(message);
const displayName = const displayName =
message.author.globalName || message.author.username || message.author.tag; message.author.globalName || message.author.username || message.author.tag;
let avatarUrl = null; let avatarUrl = null;
@ -72,7 +74,9 @@ async function startBot({ commandRouter } = {}) {
publishOverlayChatMessage({ publishOverlayChatMessage({
id: message.id, id: message.id,
platform: "discord", platform: "discord",
text: message.content, text: overlayContent.text,
emotes: overlayContent.emotes,
media: overlayContent.media,
timestamp: message.createdTimestamp, timestamp: message.createdTimestamp,
channel: { channel: {
id: message.channel?.id, id: message.channel?.id,

View File

@ -46,11 +46,22 @@ function normalizeEmote(emote) {
return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null; return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null;
} }
function normalizeMedia(media) {
const url = cleanUrl(media?.url);
if (!url) return null;
return {
url,
type: media?.type === "video" ? "video" : "image",
alt: cleanText(media?.alt || "Animated image", 160)
};
}
function normalizeChatMessage(input = {}) { function normalizeChatMessage(input = {}) {
const platform = cleanText(input.platform, 24).toLowerCase(); const platform = cleanText(input.platform, 24).toLowerCase();
const text = cleanMessageText(input.text, 2000); const text = cleanMessageText(input.text, 2000);
const authorId = cleanText(input.author?.id, 160); const authorId = cleanText(input.author?.id, 160);
if (!platform || !text.trim() || !authorId) return null; const media = (Array.isArray(input.media) ? input.media : []).map(normalizeMedia).filter(Boolean).slice(0, 4);
if (!platform || (!text.trim() && !media.length) || !authorId) return null;
const channelId = cleanText(input.channel?.id, 256); const channelId = cleanText(input.channel?.id, 256);
const channelName = cleanText(input.channel?.name, 256); const channelName = cleanText(input.channel?.name, 256);
const channelKey = cleanText(input.channel?.key || channelName || channelId, 256); const channelKey = cleanText(input.channel?.key || channelName || channelId, 256);
@ -61,6 +72,7 @@ function normalizeChatMessage(input = {}) {
timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(), timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(),
channel: { id: channelId, name: channelName, key: channelKey }, channel: { id: channelId, name: channelName, key: channelKey },
emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100), emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100),
media,
author: { author: {
id: authorId, id: authorId,
name: cleanText(input.author?.name || input.author?.username || "Viewer", 160), name: cleanText(input.author?.name || input.author?.username || "Viewer", 160),

View File

@ -166,8 +166,8 @@ registerOverlayModuleType({
showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off", showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off",
showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off", showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off",
showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true", showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true",
inAnimation: ["none", "fade", "slide-up", "slide-left", "scale"].includes(input.inAnimation) ? input.inAnimation : "slide-up", inAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.inAnimation) ? input.inAnimation : "slide-up",
outAnimation: ["none", "fade", "slide-left", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade", outAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade",
animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)), animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)),
customCss: text(input.customCss, "", 12000) customCss: text(input.customCss, "", 12000)
}; };

View File

@ -1,20 +1,23 @@
const { getSetting } = require("./settings"); const { getSetting } = require("./settings");
const badgeCatalogs = new Map(); const badgeCatalogs = new Map();
const userProfiles = new Map();
const CACHE_MS = 30 * 60 * 1000; const CACHE_MS = 30 * 60 * 1000;
const USER_CACHE_MS = 6 * 60 * 60 * 1000;
const USER_FAILURE_CACHE_MS = 5 * 60 * 1000;
function fallbackBadges(badges = {}) { function fallbackBadges(badges = {}) {
return Object.entries(badges).map(([label]) => ({ label, image: null })); return Object.entries(badges).map(([label]) => ({ label, image: null }));
} }
async function badgeRequest(path) { async function helixRequest(path) {
const clientId = String(getSetting("twitch_client_id", "") || "").trim(); const clientId = String(getSetting("twitch_client_id", "") || "").trim();
const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, ""); const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, "");
if (!clientId || !token) throw new Error("Twitch badge credentials are unavailable."); if (!clientId || !token) throw new Error("Twitch API credentials are unavailable.");
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500); const timeout = setTimeout(() => controller.abort(), 2500);
try { try {
const response = await fetch(`https://api.twitch.tv/helix/chat/badges${path}`, { const response = await fetch(`https://api.twitch.tv/helix/${path}`, {
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` }, headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` },
signal: controller.signal signal: controller.signal
}); });
@ -25,6 +28,10 @@ async function badgeRequest(path) {
} }
} }
function badgeRequest(path) {
return helixRequest(`chat/badges${path}`);
}
function indexCatalog(payload) { function indexCatalog(payload) {
const catalog = new Map(); const catalog = new Map();
for (const set of payload?.data || []) { for (const set of payload?.data || []) {
@ -68,4 +75,23 @@ async function resolveTwitchBadges(roomId, badges = {}) {
} }
} }
module.exports = { resolveTwitchBadges }; async function resolveTwitchAvatar(userId, username) {
const id = String(userId || "").trim();
const login = String(username || "").trim().toLowerCase();
if (!id && !login) return null;
const key = id ? `id:${id}` : `login:${login}`;
const cached = userProfiles.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.promise;
const query = id ? `id=${encodeURIComponent(id)}` : `login=${encodeURIComponent(login)}`;
const promise = helixRequest(`users?${query}`)
.then((payload) => payload?.data?.[0]?.profile_image_url || null)
.catch(() => null);
const result = { expiresAt: Date.now() + USER_CACHE_MS, promise };
userProfiles.set(key, result);
promise.then((avatar) => {
if (!avatar) result.expiresAt = Date.now() + USER_FAILURE_CACHE_MS;
});
return promise;
}
module.exports = { resolveTwitchAvatar, resolveTwitchBadges };

View File

@ -4,7 +4,7 @@ const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users"); const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger"); const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat"); const { publishOverlayChatMessage } = require("./overlay-chat");
const { resolveTwitchBadges } = require("./twitch-chat-assets"); const { resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
const twitchLog = createLogger("platform:twitch", { category: "integration" }); const twitchLog = createLogger("platform:twitch", { category: "integration" });
@ -46,12 +46,16 @@ async function startTwitchBot({ commandRouter } = {}) {
return; return;
} }
const displayName = tags["display-name"] || tags.username || username; const displayName = tags["display-name"] || tags.username || username;
const [badges, avatar] = await Promise.all([
resolveTwitchBadges(tags["room-id"], tags.badges || {}),
resolveTwitchAvatar(tags["user-id"], tags.username || username)
]);
const profile = self ? null : ensureUserForIdentity({ const profile = self ? null : ensureUserForIdentity({
provider: "twitch", provider: "twitch",
providerUserId: userId, providerUserId: userId,
displayName displayName,
avatar
}); });
const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {});
publishOverlayChatMessage({ publishOverlayChatMessage({
id: tags.id, id: tags.id,
platform: "twitch", platform: "twitch",
@ -62,6 +66,7 @@ async function startTwitchBot({ commandRouter } = {}) {
id: userId, id: userId,
name: displayName, name: displayName,
username: tags.username, username: tags.username,
avatar,
color: tags.color, color: tags.color,
badges, badges,
lumi: profile ? { id: profile.id, username: profile.internal_username } : null lumi: profile ? { id: profile.id, username: profile.internal_username } : null

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#164e63"/><circle cx="32" cy="24" r="12" fill="#cffafe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#22d3ee"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#713f12"/><circle cx="32" cy="24" r="12" fill="#fef3c7"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fbbf24"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#831843"/><circle cx="32" cy="24" r="12" fill="#f9a8d4"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#f472b6"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#4c1d95"/><circle cx="32" cy="24" r="12" fill="#ddd6fe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#a78bfa"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#881337"/><circle cx="32" cy="24" r="12" fill="#fecdd3"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fb7185"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -273,7 +273,7 @@
}); });
}); });
function selectSource(moduleId, { openSettings = false } = {}) { function selectSource(moduleId, { openSettings = false, focusCanvas = true } = {}) {
selectedId = moduleId; selectedId = moduleId;
layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId)); layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId));
sourceList.querySelectorAll("[data-canvas-select]").forEach((button) => button.classList.toggle("is-selected", button.dataset.canvasSelect === moduleId)); sourceList.querySelectorAll("[data-canvas-select]").forEach((button) => button.classList.toggle("is-selected", button.dataset.canvasSelect === moduleId));
@ -282,7 +282,7 @@
const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`); const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`);
if (card) card.open = true; if (card) card.open = true;
} }
canvas.focus({ preventScroll: true }); if (focusCanvas) canvas.focus({ preventScroll: true });
} }
function renderCanvas() { function renderCanvas() {
@ -300,7 +300,7 @@
sourceList.appendChild(button); sourceList.appendChild(button);
} }
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null; if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
if (selectedId) selectSource(selectedId); if (selectedId) selectSource(selectedId, { focusCanvas: false });
else updateReadout(); else updateReadout();
} }

View File

@ -225,23 +225,47 @@
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
meta.appendChild(timestamp); meta.appendChild(timestamp);
} }
const text = document.createElement("div"); const messageText = String(payload.text || "");
text.className = "text"; if (messageText) {
let cursor = 0; const text = document.createElement("div");
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start); text.className = "text";
for (const emoteData of emotes) { let cursor = 0;
if (emoteData.start < cursor || emoteData.start >= payload.text.length) continue; const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start))); for (const emoteData of emotes) {
const emote = document.createElement("img"); if (emoteData.start < cursor || emoteData.start >= messageText.length) continue;
emote.className = "emote"; text.append(document.createTextNode(messageText.slice(cursor, emoteData.start)));
emote.src = emoteData.image; const emote = document.createElement("img");
emote.alt = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote"; emote.className = "emote";
emote.referrerPolicy = "no-referrer"; emote.src = emoteData.image;
text.appendChild(emote); emote.alt = emoteData.label || messageText.slice(emoteData.start, Math.min(messageText.length, emoteData.end + 1)) || "Emote";
cursor = Math.min(payload.text.length, emoteData.end + 1); emote.referrerPolicy = "no-referrer";
text.appendChild(emote);
cursor = Math.min(messageText.length, emoteData.end + 1);
}
text.append(document.createTextNode(messageText.slice(cursor)));
content.appendChild(text);
} }
text.append(document.createTextNode(payload.text.slice(cursor))); if (payload.media?.length) {
content.append(meta, text); const mediaRow = document.createElement("div");
mediaRow.className = "media-row";
for (const mediaData of payload.media) {
const media = document.createElement(mediaData.type === "video" ? "video" : "img");
media.className = "media";
media.src = mediaData.url;
media.setAttribute("aria-label", mediaData.alt || "Animated image");
media.referrerPolicy = "no-referrer";
if (media.tagName === "VIDEO") {
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
} else media.alt = mediaData.alt || "Animated image";
media.addEventListener("error", () => media.remove(), { once: true });
mediaRow.appendChild(media);
}
content.appendChild(mediaRow);
}
content.prepend(meta);
row.appendChild(content); row.appendChild(content);
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) { if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
@ -272,32 +296,83 @@
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining); setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
} }
const previewChatMessages = [
{ platform: "twitch", text: "Hi chat!", author: { name: "CozyViewer", color: "#f472b6", avatar: "/icons/avatars/preview-pink.svg", badges: [{ label: "Subscriber", image: null }] } },
{ platform: "discord", text: "The badges, avatar, and service icon move together.", author: { name: "PackMember", color: "#a78bfa", avatar: "/icons/avatars/preview-purple.svg", badges: [{ label: "Moderator", image: null }] } },
{ platform: "youtube", text: "Short message", author: { name: "Lumi Fan", color: "#fb7185", avatar: "/icons/avatars/preview-red.svg", badges: [{ label: "Member", image: null }] } },
{ platform: "twitch", text: "This longer sample wraps naturally so you can compare it with compact one-line messages.", author: { name: "StreamFriend", color: "#22d3ee", avatar: "/icons/avatars/preview-cyan.svg", badges: [] } },
{ platform: "discord", text: "Animated arrivals and departures repeat in this preview.", author: { name: "Lumi", color: "#67e8f9", avatar: "/icons/platforms/lumi.svg", badges: [{ label: "Owner", image: null }] } },
{ platform: "youtube", text: "Looks good!", author: { name: "ChatRegular", color: "#fbbf24", avatar: "/icons/avatars/preview-gold.svg", badges: [] } }
];
function nextPreviewMessage(moduleId, state) {
const allowed = previewChatMessages.filter((message) => (state.values.platforms || []).includes(message.platform));
const samples = allowed.length ? allowed : previewChatMessages;
const sample = samples[state.previewIndex % samples.length];
state.previewIndex += 1;
return {
...sample,
id: `${moduleId}-preview-${state.previewIndex}-${Date.now()}`,
preview: true,
timestamp: Date.now(),
channel: { name: sample.platform === "discord" ? "#community" : "Live chat" },
author: { ...sample.author }
};
}
function schedulePreviewChat(moduleId, state) {
clearTimeout(state.previewTimer);
const delay = Math.max(1400, Number(state.values.animationDurationMs || 0) * 2 + 500);
state.previewTimer = setTimeout(() => {
if (!state.editor || !state.list?.isConnected) {
state.previewTimer = null;
return;
}
const payload = nextPreviewMessage(moduleId, state);
state.messages.push(payload);
const row = chatMessageElement(payload, state.values);
if (state.values.newestAt === "top") state.list.prepend(row);
else state.list.appendChild(row);
const visibleLimit = Math.max(1, Math.min(Number(state.values.maxMessages || 8), 5));
while (state.messages.length > visibleLimit) removeChatMessage(moduleId, state.messages[0].id);
schedulePreviewChat(moduleId, state);
}, delay);
}
function buildChat(module, values, options) { function buildChat(module, values, options) {
const root = document.createElement("div"); const root = document.createElement("div");
root.className = "lumi-overlay-chat-root"; root.className = "lumi-overlay-chat-root";
const shadow = root.attachShadow({ mode: "open" }); const shadow = root.attachShadow({ mode: "open" });
const style = document.createElement("style"); const style = document.createElement("style");
const chatCustomCss = values.customCss || ""; const chatCustomCss = values.customCss || "";
const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start";
style.textContent = ` style.textContent = `
:host { display:block; width:100%; height:100%; overflow:hidden; box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; } :host { display:block; width:100%; height:100%; overflow:hidden; box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; }
.chat { width:100%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; } .chat { width:100%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:${alignment}; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; }
.message { display:flex; align-items:flex-start; gap:.55em; box-sizing:border-box; width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; } .message { display:inline-flex; align-items:flex-start; align-self:${alignment}; gap:.55em; box-sizing:border-box; width:fit-content; max-width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; }
.avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; } .avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; }
.content { min-width:0; flex:1; } .content { min-width:0; max-width:100%; flex:0 1 auto; }
.meta { display:flex; align-items:center; justify-content:${values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; } .meta { display:flex; align-items:center; justify-content:${alignment}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; }
.author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; } .author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; }
.platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; } .platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; }
.badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; } .badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; }
.timestamp { flex:0 0 auto; margin-left:auto; opacity:.65; font-size:.85em; } .timestamp { flex:0 0 auto; opacity:.65; font-size:.85em; }
.text { overflow-wrap:anywhere; white-space:pre-wrap; } .text { overflow-wrap:anywhere; white-space:pre-wrap; }
.emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; } .emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; }
.media-row { display:flex; flex-wrap:wrap; justify-content:${alignment}; gap:.3em; margin-top:.2em; max-width:100%; }
.media { display:block; width:auto; height:${Math.max(1.6, Number(values.lineHeight || 1.25) * 2)}em; max-width:100%; border-radius:.25em; object-fit:contain; }
.enter-fade, .leave-active { opacity:0; } .enter-fade, .leave-active { opacity:0; }
.enter-slide-up { opacity:0; transform:translateY(1em); } .enter-slide-left { opacity:0; transform:translateX(-1.5em); }
.enter-slide-left { opacity:0; transform:translateX(1.5em); } .enter-slide-right { opacity:0; transform:translateX(1.5em); }
.enter-slide-up { opacity:0; transform:translateY(-1em); }
.enter-slide-down { opacity:0; transform:translateY(1em); }
.enter-scale { opacity:0; transform:scale(.92); } .enter-scale { opacity:0; transform:scale(.92); }
.enter-active { opacity:1; transform:none; } .enter-active { opacity:1; transform:none; }
.leave-slide-left.leave-active { transform:translateX(-1.5em); } .leave-slide-left.leave-active { transform:translateX(-1.5em); }
.leave-slide-right.leave-active { transform:translateX(1.5em); }
.leave-slide-up.leave-active { transform:translateY(-1em); }
.leave-slide-down.leave-active { transform:translateY(1em); } .leave-slide-down.leave-active { transform:translateY(1em); }
.leave-scale.leave-active { transform:scale(.92); }
${chatCustomCss} ${chatCustomCss}
`; `;
const list = document.createElement("div"); const list = document.createElement("div");
@ -307,24 +382,28 @@
shadow.append(style, list); shadow.append(style, list);
let state = chatStates.get(module.id); let state = chatStates.get(module.id);
if (!state) state = { messages: [] }; if (!state) state = { messages: [], previewIndex: 0 };
state.values = values; state.values = values;
state.list = list; state.list = list;
state.editor = Boolean(options.editor);
if (options.editor && !state.messages.length) { if (options.editor && !state.messages.length) {
state.messages = [ const initialCount = Math.max(1, Math.min(Number(values.maxMessages || 8), 5));
{ id: `${module.id}-preview-1`, platform: "twitch", text: "This is how a live chat message will look.", timestamp: Date.now() - 60000, channel: { name: "#stream" }, author: { name: "CozyViewer", color: "#f472b6", badges: [{ label: "Sub", image: null }] } }, for (let index = 0; index < initialCount; index += 1) state.messages.push(nextPreviewMessage(module.id, state));
{ id: `${module.id}-preview-2`, platform: "youtube", text: "Messages from your selected platforms appear together!", timestamp: Date.now(), channel: { name: "Live chat" }, author: { name: "Lumi Fan", color: null, badges: [] } }
];
} }
state.messages = state.messages.slice(-Number(values.maxMessages || 8)); state.messages = state.messages.slice(-Number(values.maxMessages || 8));
const display = values.newestAt === "top" ? [...state.messages].reverse() : state.messages; const acceptedMessages = state.messages.filter((message) => message.preview
? (values.platforms || []).includes(message.platform)
: chatAccepts(values, message));
const display = values.newestAt === "top" ? [...acceptedMessages].reverse() : acceptedMessages;
for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false })); for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false }));
chatStates.set(module.id, state); chatStates.set(module.id, state);
if (options.editor) schedulePreviewChat(module.id, state);
else clearTimeout(state.previewTimer);
return root; return root;
} }
function handleChatMessage(root, payload) { function handleChatMessage(root, payload) {
if (!payload?.id || !payload?.platform || !payload?.text) return 0; if (!payload?.id || !payload?.platform || (!payload?.text && !payload?.media?.length)) return 0;
let accepted = 0; let accepted = 0;
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => { root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
const moduleId = box.dataset.moduleId; const moduleId = box.dataset.moduleId;

View File

@ -122,7 +122,7 @@
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label> <label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label> <label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
<label>Text alignment<select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>Alignment <button type="button" class="overlay-help" data-tooltip="Aligns each complete message, including its avatar, service icon, badges, name, and text.">?</button><select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
<label class="full">Font<input name="chat_font_family" value="<%= value(config,"fontFamily","system-ui, sans-serif") %>" maxlength="300" placeholder="system-ui, sans-serif" /></label> <label class="full">Font<input name="chat_font_family" value="<%= value(config,"fontFamily","system-ui, sans-serif") %>" maxlength="300" placeholder="system-ui, sans-serif" /></label>
<label>Text size<input type="number" name="font_size" min="8" max="160" value="<%= value(config,"fontSize",32) %>" /></label> <label>Text size<input type="number" name="font_size" min="8" max="160" value="<%= value(config,"fontSize",32) %>" /></label>
<label>Weight<select name="font_weight"><% [[400,"Regular"],[600,"Semi-bold"],[700,"Bold"],[900,"Heavy"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"fontWeight",600)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>Weight<select name="font_weight"><% [[400,"Regular"],[600,"Semi-bold"],[700,"Bold"],[900,"Heavy"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"fontWeight",600)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -140,8 +140,8 @@
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label> <label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label>
<label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label> <label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
</div></fieldset> </div></fieldset>
<label>New-message animation<select name="chat_in_animation"><% [["slide-up","Slide up"],["slide-left","Slide in"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>New-message animation<select name="chat_in_animation"><% [["slide-left","Slide in from left"],["slide-right","Slide in from right"],["slide-up","Slide in from above"],["slide-down","Slide in from below"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
<label>Old-message animation<select name="chat_out_animation"><% [["fade","Fade out"],["slide-left","Slide out"],["slide-down","Slide down"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>Old-message animation<select name="chat_out_animation"><% [["slide-left","Slide out to left"],["slide-right","Slide out to right"],["slide-up","Slide out upward"],["slide-down","Slide out downward"],["fade","Fade out"],["scale","Shrink out"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
<label>Animation speed <span class="hint">(milliseconds)</span><input type="number" name="chat_animation_duration_ms" min="0" max="3000" step="50" value="<%= value(config,"animationDurationMs",350) %>" /></label> <label>Animation speed <span class="hint">(milliseconds)</span><input type="number" name="chat_animation_duration_ms" min="0" max="3000" step="50" value="<%= value(config,"animationDurationMs",350) %>" /></label>
<label class="full overlay-css-field">Custom chat CSS <button type="button" class="overlay-help" data-tooltip="Advanced: style .chat, .message, .avatar, .content, .meta, .platform, .author, .badge, .timestamp, and .text.">?</button><textarea name="chat_custom_css" rows="6" maxlength="12000" placeholder=".message { border: 1px solid rgba(255,255,255,.2); }"><%= value(config,"customCss","") %></textarea><small class="hint">Your CSS is isolated to this chat source and is applied after the visual settings above.</small></label> <label class="full overlay-css-field">Custom chat CSS <button type="button" class="overlay-help" data-tooltip="Advanced: style .chat, .message, .avatar, .content, .meta, .platform, .author, .badge, .timestamp, and .text.">?</button><textarea name="chat_custom_css" rows="6" maxlength="12000" placeholder=".message { border: 1px solid rgba(255,255,255,.2); }"><%= value(config,"customCss","") %></textarea><small class="hint">Your CSS is isolated to this chat source and is applied after the visual settings above.</small></label>
</div> </div>

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.15", "version": "0.2.16",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-19", "released_at": "2026-07-19",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -205,6 +205,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." "migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
},
{
"version": "0.2.16",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
} }
] ]
} }