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
## 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
- 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 app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
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:

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`.
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,
top-or-bottom placement, timeout removal, and separate entry and exit
animations. Twitch emotes are displayed as images. The editor canvas supplies
sample messages so appearance can be configured before the stream is live.
animations. Message cards fit their contents up to the source boundary rather
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
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
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
`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
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
updates. Lumi keeps a small, short-lived in-memory replay window so a temporary
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.
## Runtime
Package: lumi-bot
Version: 0.2.15
Version: 0.2.16
## Routes
- GET /api/events
- POST /api/destructive-confirmations

4
package-lock.json generated
View File

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

View File

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

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"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",
"ref": "refs/tags/v0.2.15",

View File

@ -19,7 +19,8 @@ for (const file of [
"overlays.js",
"overlay-permissions.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));
}
@ -161,6 +162,8 @@ try {
assert.strictEqual(chatConfig.background, "#0000");
assert.strictEqual(chatConfig.inAnimation, "scale");
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"]);
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
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: ["lumi:linked-user|other-id"] }), true);
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);
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
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(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
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(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
@ -299,6 +322,10 @@ try {
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
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("allowtransparency", "true")'));
assert(rendererScript.includes("values.renderUrl || values.url"));
@ -323,7 +350,8 @@ try {
assert.strictEqual(obsLayout.scaleX, 1);
assert.strictEqual(obsLayout.scaleY, 1);
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('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
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 root = path.join(__dirname, "..");
const releaseVersion = "0.2.15";
const previousCoreVersion = "0.2.14";
const releaseVersion = "0.2.16";
const previousCoreVersion = "0.2.15";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"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(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 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");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
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.14", ref: "refs/tags/v0.2.14", 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"
});
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.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 { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const { discordOverlayContent } = require("./discord-chat-content");
const discordLog = createLogger("platform:discord", { category: "integration" });
@ -53,6 +54,7 @@ async function startBot({ commandRouter } = {}) {
client.on("messageCreate", async (message) => {
if (!message.guild) return;
const overlayContent = discordOverlayContent(message);
const displayName =
message.author.globalName || message.author.username || message.author.tag;
let avatarUrl = null;
@ -72,7 +74,9 @@ async function startBot({ commandRouter } = {}) {
publishOverlayChatMessage({
id: message.id,
platform: "discord",
text: message.content,
text: overlayContent.text,
emotes: overlayContent.emotes,
media: overlayContent.media,
timestamp: message.createdTimestamp,
channel: {
id: message.channel?.id,

View File

@ -46,11 +46,22 @@ function normalizeEmote(emote) {
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 = {}) {
const platform = cleanText(input.platform, 24).toLowerCase();
const text = cleanMessageText(input.text, 2000);
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 channelName = cleanText(input.channel?.name, 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(),
channel: { id: channelId, name: channelName, key: channelKey },
emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100),
media,
author: {
id: authorId,
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",
showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off",
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",
outAnimation: ["none", "fade", "slide-left", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade",
inAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.inAnimation) ? input.inAnimation : "slide-up",
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)),
customCss: text(input.customCss, "", 12000)
};

View File

@ -1,20 +1,23 @@
const { getSetting } = require("./settings");
const badgeCatalogs = new Map();
const userProfiles = new Map();
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 = {}) {
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 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 timeout = setTimeout(() => controller.abort(), 2500);
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}` },
signal: controller.signal
});
@ -25,6 +28,10 @@ async function badgeRequest(path) {
}
}
function badgeRequest(path) {
return helixRequest(`chat/badges${path}`);
}
function indexCatalog(payload) {
const catalog = new Map();
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 { createLogger } = require("./logger");
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" });
@ -46,12 +46,16 @@ async function startTwitchBot({ commandRouter } = {}) {
return;
}
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({
provider: "twitch",
providerUserId: userId,
displayName
displayName,
avatar
});
const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {});
publishOverlayChatMessage({
id: tags.id,
platform: "twitch",
@ -62,6 +66,7 @@ async function startTwitchBot({ commandRouter } = {}) {
id: userId,
name: displayName,
username: tags.username,
avatar,
color: tags.color,
badges,
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;
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));
@ -282,7 +282,7 @@
const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`);
if (card) card.open = true;
}
canvas.focus({ preventScroll: true });
if (focusCanvas) canvas.focus({ preventScroll: true });
}
function renderCanvas() {
@ -300,7 +300,7 @@
sourceList.appendChild(button);
}
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
if (selectedId) selectSource(selectedId);
if (selectedId) selectSource(selectedId, { focusCanvas: false });
else updateReadout();
}

View File

@ -225,23 +225,47 @@
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
meta.appendChild(timestamp);
}
const text = document.createElement("div");
text.className = "text";
let cursor = 0;
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
for (const emoteData of emotes) {
if (emoteData.start < cursor || emoteData.start >= payload.text.length) continue;
text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start)));
const emote = document.createElement("img");
emote.className = "emote";
emote.src = emoteData.image;
emote.alt = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote";
emote.referrerPolicy = "no-referrer";
text.appendChild(emote);
cursor = Math.min(payload.text.length, emoteData.end + 1);
const messageText = String(payload.text || "");
if (messageText) {
const text = document.createElement("div");
text.className = "text";
let cursor = 0;
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
for (const emoteData of emotes) {
if (emoteData.start < cursor || emoteData.start >= messageText.length) continue;
text.append(document.createTextNode(messageText.slice(cursor, emoteData.start)));
const emote = document.createElement("img");
emote.className = "emote";
emote.src = emoteData.image;
emote.alt = emoteData.label || messageText.slice(emoteData.start, Math.min(messageText.length, emoteData.end + 1)) || "Emote";
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)));
content.append(meta, text);
if (payload.media?.length) {
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);
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
@ -272,32 +296,83 @@
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) {
const root = document.createElement("div");
root.className = "lumi-overlay-chat-root";
const shadow = root.attachShadow({ mode: "open" });
const style = document.createElement("style");
const chatCustomCss = values.customCss || "";
const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start";
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}; }
.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}; }
.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; }
.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: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; }
.content { min-width:0; flex:1; }
.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; }
.content { min-width:0; max-width:100%; flex:0 1 auto; }
.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; }
.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; }
.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; }
.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-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-active { opacity:1; transform:none; }
.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-scale.leave-active { transform:scale(.92); }
${chatCustomCss}
`;
const list = document.createElement("div");
@ -307,24 +382,28 @@
shadow.append(style, list);
let state = chatStates.get(module.id);
if (!state) state = { messages: [] };
if (!state) state = { messages: [], previewIndex: 0 };
state.values = values;
state.list = list;
state.editor = Boolean(options.editor);
if (options.editor && !state.messages.length) {
state.messages = [
{ 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 }] } },
{ 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: [] } }
];
const initialCount = Math.max(1, Math.min(Number(values.maxMessages || 8), 5));
for (let index = 0; index < initialCount; index += 1) state.messages.push(nextPreviewMessage(module.id, state));
}
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 }));
chatStates.set(module.id, state);
if (options.editor) schedulePreviewChat(module.id, state);
else clearTimeout(state.previewTimer);
return root;
}
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;
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
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>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>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>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>
@ -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_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
</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>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>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"><% [["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 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>

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.15",
"version": "0.2.16",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"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,
"requirements": [
"Node.js 18 or newer"
@ -205,6 +205,18 @@
],
"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."
},
{
"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."
}
]
}