Add native multi-platform chat overlays

This commit is contained in:
Franz Rolfsvaag 2026-07-19 11:45:21 +02:00
parent a90f7071d9
commit ac6f364327
23 changed files with 710 additions and 41 deletions

View File

@ -1,5 +1,11 @@
# Lumi changelog # Lumi changelog
## 0.2.14
- Added a native Lumi chat overlay source that combines live Twitch, YouTube, and Discord messages with optional per-channel filters.
- Added visual controls for typography, colors and transparency, placement, message limits, timeout removal, message details, and entry/exit animations, plus isolated custom CSS.
- Added live avatars, badges, Twitch emotes, editor preview messages, and bounded replay after temporary Browser Source connection loss.
## 0.2.13 ## 0.2.13
- Changed external overlay Website CSS from frame-only styling to document-level injection, matching OBS Browser Source Custom CSS behavior. - Changed external overlay Website CSS from frame-only styling to document-level injection, matching OBS Browser Source Custom CSS behavior.

View File

@ -119,7 +119,8 @@ Developer and modding conventions are documented in
Admins can create live, token-protected OBS Browser Sources under **Admin → OBS Admins can create live, token-protected OBS Browser Sources under **Admin → OBS
overlays**, with server-controlled or fixed scenes and optional local OBS overlays**, with server-controlled or fixed scenes and optional local OBS
WebSocket or zero-credential OBS Browser Bridge synchronization. See WebSocket or zero-credential OBS Browser Bridge synchronization. Native sources
include a configurable combined Twitch, YouTube, and Discord chat overlay. See
[`docs/obs-overlays.md`](docs/obs-overlays.md). [`docs/obs-overlays.md`](docs/obs-overlays.md).
## Notes ## Notes

View File

@ -25,7 +25,10 @@ defaults, and explicit Browser Source dimensions so responsive third-party alert
render consistently in the preview and OBS. Website CSS is injected into the render consistently in the preview and OBS. Website CSS is injected into the
third-party document itself through scoped revocable render tickets, matching OBS third-party document itself through scoped revocable render tickets, matching OBS
Custom CSS behavior while keeping the page isolated from Lumi sessions and local Custom CSS behavior while keeping the page isolated from Lumi sessions and local
network targets. network targets. A native multi-platform Lumi chat source now combines connected
Twitch, YouTube, and Discord chats with channel filters, full visual controls,
animations, timeout removal, short reconnect replay, Twitch emotes, and isolated
custom CSS.
Remaining work: Remaining work:
@ -683,6 +686,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## Done
- 2026-07-19: Added the native Lumi chat overlay source with combined Twitch, YouTube, and Discord messages; optional platform/channel filters; avatars, badges, Twitch emotes, timestamps, typography, transparent colors, alignment and spacing; entry/exit animations; bounded timeout/capacity removal; isolated CSS; representative editor previews; and short-lived reconnect replay through the existing scoped overlay event stream.
- 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets. - 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets.
- 2026-07-18: Fixed OBS overlay transparency and preview fidelity by applying OBS's exact default Browser Source CSS, rendering the editor and clean output from the same full-resolution canvas coordinate system, keeping iframe surfaces transparent, and showing the required OBS width/height plus copyable CSS in setup. - 2026-07-18: Fixed OBS overlay transparency and preview fidelity by applying OBS's exact default Browser Source CSS, rendering the editor and clean output from the same full-resolution canvas coordinate system, keeping iframe surfaces transparent, and showing the required OBS width/height plus copyable CSS in setup.

View File

@ -56,14 +56,38 @@ order, enable, activate, and delete scenes. Current built-in source types are:
- Image URL - Image URL
- Video / media URL - Video / media URL
- Audio URL - Audio URL
- Lumi chat
- Website or alert overlay - Website or alert overlay
Module instances can be created, edited, duplicated, ordered, enabled, and Module instances can be created, edited, duplicated, ordered, enabled, and
deleted. New module types can call `registerOverlayModuleType(...)` in deleted. New module types can call `registerOverlayModuleType(...)` in
`src/services/overlay-modules.js`; providers normalize their configuration and `src/services/overlay-modules.js`; providers normalize their configuration and
select the safe built-in `text`, `image`, `video`, `audio`, or `web` render type while the overlay select the safe built-in `text`, `image`, `video`, `audio`, `chat`, or `web` render type while the overlay
service retains the same instance model. service retains the same instance model.
### Lumi chat
The built-in **Lumi chat** source combines messages already received by Lumi
from Twitch, YouTube, and Discord. Administrators can select any combination of
those services and optionally filter by channel name or ID. Channel filters
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,
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.
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`.
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
content to the database.
### Video and audio ### Video and audio
Video sources support browser-playable media such as WebM and MP4; audio sources Video sources support browser-playable media such as WebM and MP4; audio sources

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.13 Version: 0.2.14
## 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.13", "version": "0.2.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.13", "version": "0.2.14",
"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.13", "version": "0.2.14",
"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.14",
"ref": "refs/tags/v0.2.14",
"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": "Adds the native multi-platform Lumi chat overlay source with scoped live events and short-lived in-memory reconnect replay. 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.13", "version": "0.2.13",
"ref": "refs/tags/v0.2.13", "ref": "refs/tags/v0.2.13",

View File

@ -18,7 +18,8 @@ for (const file of [
"overlay-modules.js", "overlay-modules.js",
"overlays.js", "overlays.js",
"overlay-permissions.js", "overlay-permissions.js",
"overlay-connectors.js" "overlay-connectors.js",
"overlay-chat.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));
} }
@ -62,6 +63,7 @@ try {
const sourceTypes = overlayModules.listOverlayModuleTypes(); const sourceTypes = overlayModules.listOverlayModuleTypes();
assert(sourceTypes.some((type) => type.id === "video")); assert(sourceTypes.some((type) => type.id === "video"));
assert(sourceTypes.some((type) => type.id === "audio")); assert(sourceTypes.some((type) => type.id === "audio"));
assert(sourceTypes.some((type) => type.id === "chat"));
const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" }); const created = overlays.createOverlay({ name: "Main Stream", sceneName: "Live" });
assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene"); assert.strictEqual(created.scenes.length, 1, "new overlays must receive a first scene");
@ -138,6 +140,25 @@ try {
assert.strictEqual(audioConfig.playBehavior, "manual"); assert.strictEqual(audioConfig.playBehavior, "manual");
assert.strictEqual(audioConfig.showControls, true); assert.strictEqual(audioConfig.showControls, true);
assert.strictEqual(audioConfig.volume, 1); assert.strictEqual(audioConfig.volume, 1);
const chatConfig = overlayModules.normalizeModuleConfig("chat", {
platforms: ["twitch", "discord", "unsupported"],
channels: "twitch:cozycarnage\ndiscord:community-chat",
maxMessages: 100,
messageTimeoutSeconds: -1,
background: "#0000",
messageBackground: "#0f172bcc",
inAnimation: "scale",
outAnimation: "slide-down",
customCss: ".message { border: 1px solid white; }"
});
assert.deepStrictEqual(chatConfig.platforms, ["twitch", "discord"]);
assert.deepStrictEqual(chatConfig.channels, ["twitch:cozycarnage", "discord:community-chat"]);
assert.strictEqual(chatConfig.maxMessages, 50);
assert.strictEqual(chatConfig.messageTimeoutSeconds, 0);
assert.strictEqual(chatConfig.background, "#0000");
assert.strictEqual(chatConfig.inAnimation, "scale");
assert(chatConfig.customCss.includes(".message"));
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",
cropLeft: 12.5, cropLeft: 12.5,
@ -164,6 +185,29 @@ try {
enabled: true, enabled: true,
config: { url: "https://alerts.example/widget" } config: { url: "https://alerts.example/widget" }
}); });
const chatId = overlays.addModule(created.scenes[0].id, {
name: "Combined chat",
type: "chat",
enabled: true,
config: { platforms: ["twitch", "youtube"], maxMessages: 6 }
});
const chatState = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === chatId);
assert.strictEqual(chatState.renderType, "chat");
assert.deepStrictEqual(chatState.config.platforms, ["twitch", "youtube"]);
const overlayChat = require(path.join(serviceDir, "overlay-chat.js"));
const normalizedChat = overlayChat.normalizeChatMessage({
id: "message-1",
platform: "twitch",
text: "Hello chat",
channel: { id: "123", name: "#cozycarnage" },
author: { id: "viewer-1", name: "Viewer", color: "#abcdef", badges: ["Subscriber"] }
});
assert.strictEqual(normalizedChat.text, "Hello chat");
assert.strictEqual(normalizedChat.author.badges[0].label, "Subscriber");
assert.strictEqual(overlayChat.messageMatchesConfig(normalizedChat, chatState.config), true);
assert.strictEqual(overlayChat.messageMatchesConfig({ ...normalizedChat, platform: "discord" }, chatState.config), false);
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); const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
const websiteRenderUrl = websiteState.scene.modules.find((module) => module.id === websiteId).config.renderUrl; const websiteRenderUrl = websiteState.scene.modules.find((module) => module.id === websiteId).config.renderUrl;
const websiteTicket = websiteRenderUrl.split("/").at(-1); const websiteTicket = websiteRenderUrl.split("/").at(-1);
@ -187,6 +231,7 @@ try {
assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken)); assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken));
assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets"); assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets");
overlays.deleteModule(websiteId); overlays.deleteModule(websiteId);
overlays.deleteModule(chatId);
overlays.deleteScene(second.id); overlays.deleteScene(second.id);
assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/); assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/);
@ -228,6 +273,7 @@ try {
assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas"); assert(editorView.includes("data-overlay-canvas"), "management page must include a visual canvas");
assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"')); assert(editorView.includes('data-module-fields="text"') && editorView.includes('data-module-fields="web"'));
assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"')); assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"'));
assert(editorView.includes('data-module-fields="chat"') && editorView.includes('name="chat_custom_css"'));
assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"')); assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"'));
assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website")); assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website"));
assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup")); assert(editorView.includes("obsBrowserDefaultCss") && editorView.includes("OBS Browser Source setup"));
@ -236,8 +282,10 @@ try {
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets")); assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
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("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command")); assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite")); assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
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"));

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.13"; const releaseVersion = "0.2.14";
const previousCoreVersion = "0.2.12"; const previousCoreVersion = "0.2.13";
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.13, Lumi AI 0.8.5, and synchronized package metadata."); console.log("Release metadata verification passed: core 0.2.14, 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.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.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.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 },
{ version: "0.2.12", ref: "refs/tags/v0.2.12", rollback_safe: true }, { version: "0.2.12", ref: "refs/tags/v0.2.12", rollback_safe: true },
{ version: "0.2.11", ref: "refs/tags/v0.2.11", rollback_safe: true }, { version: "0.2.11", ref: "refs/tags/v0.2.11", rollback_safe: true },
@ -73,7 +74,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.13"); assert.equal(corrected.safe_target_version, "0.2.14");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -8,6 +8,7 @@ const { getSetting, setSetting } = require("./settings");
const { incrementMessages } = require("./stats"); 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 discordLog = createLogger("platform:discord", { category: "integration" }); const discordLog = createLogger("platform:discord", { category: "integration" });
@ -51,9 +52,7 @@ async function startBot({ commandRouter } = {}) {
}); });
client.on("messageCreate", async (message) => { client.on("messageCreate", async (message) => {
if (!message.guild || message.author.bot) { if (!message.guild) return;
return;
}
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;
@ -64,6 +63,26 @@ async function startBot({ commandRouter } = {}) {
avatarUrl = message.author.displayAvatarURL(); avatarUrl = message.author.displayAvatarURL();
} }
} }
publishOverlayChatMessage({
id: message.id,
platform: "discord",
text: message.content,
timestamp: message.createdTimestamp,
channel: {
id: message.channel?.id,
name: message.channel?.name ? `#${message.channel.name}` : message.channel?.id,
key: `${message.guild.id}/${message.channel?.id || ""}`
},
author: {
id: message.author.id,
name: message.member?.displayName || displayName,
username: message.author.username,
avatar: avatarUrl,
color: message.member?.displayHexColor === "#000000" ? null : message.member?.displayHexColor,
badges: []
}
});
if (message.author.bot) return;
const profile = ensureUserForIdentity({ const profile = ensureUserForIdentity({
provider: "discord", provider: "discord",
providerUserId: message.author.id, providerUserId: message.author.id,

View File

@ -0,0 +1,142 @@
const crypto = require("crypto");
const { db } = require("./db");
const { publishWebEvent } = require("./web-events");
const { getOverlayModuleType } = require("./overlay-modules");
const recentByOverlay = new Map();
const MAX_RECENT_PER_OVERLAY = 100;
const MAX_RECENT_AGE_MS = 5 * 60 * 1000;
function cleanText(value, max = 500) {
return String(value === undefined || value === null ? "" : value).trim().slice(0, max);
}
function cleanMessageText(value, max = 2000) {
return String(value === undefined || value === null ? "" : value).slice(0, max);
}
function cleanUrl(value) {
const candidate = cleanText(value, 2048);
if (!candidate) return null;
try {
const parsed = new URL(candidate);
return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : null;
} catch {
return null;
}
}
function normalizeBadge(badge) {
if (typeof badge === "string") {
const label = cleanText(badge, 40);
return label ? { label, image: null } : null;
}
const label = cleanText(badge?.label || badge?.name, 40);
if (!label) return null;
return { label, image: cleanUrl(badge?.image || badge?.url) };
}
function normalizeEmote(emote) {
const parsedStart = Number(emote?.start);
const parsedEnd = Number(emote?.end);
if (!Number.isFinite(parsedStart) || !Number.isFinite(parsedEnd)) return null;
const start = Math.max(0, Math.round(parsedStart));
const end = Math.max(start, Math.round(parsedEnd));
const image = cleanUrl(emote?.image || emote?.url);
return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null;
}
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 channelId = cleanText(input.channel?.id, 256);
const channelName = cleanText(input.channel?.name, 256);
const channelKey = cleanText(input.channel?.key || channelName || channelId, 256);
return {
id: cleanText(input.id, 256) || crypto.randomUUID(),
platform,
text,
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),
author: {
id: authorId,
name: cleanText(input.author?.name || input.author?.username || "Viewer", 160),
username: cleanText(input.author?.username, 160),
avatar: cleanUrl(input.author?.avatar),
color: /^#[0-9a-f]{6}$/i.test(cleanText(input.author?.color, 7)) ? cleanText(input.author.color, 7) : null,
badges: (Array.isArray(input.author?.badges) ? input.author.badges : []).map(normalizeBadge).filter(Boolean).slice(0, 12)
}
};
}
function normalizedChannel(value) {
return String(value || "").trim().toLowerCase().replace(/^#/, "");
}
function messageMatchesConfig(message, config = {}) {
const platforms = Array.isArray(config.platforms) ? config.platforms : ["twitch", "youtube", "discord"];
if (!platforms.includes(message.platform)) return false;
const filters = Array.isArray(config.channels) ? config.channels : [];
if (!filters.length) return true;
const aliases = [message.channel?.id, message.channel?.name, message.channel?.key].map(normalizedChannel).filter(Boolean);
return filters.some((entry) => {
const raw = String(entry || "").trim();
const separator = raw.indexOf(":");
const platform = separator > 0 ? raw.slice(0, separator).trim().toLowerCase() : "";
const channel = normalizedChannel(separator > 0 ? raw.slice(separator + 1) : raw);
return (!platform || platform === message.platform) && (channel === "*" || aliases.includes(channel));
});
}
function overlayTargets(message) {
const rows = db.prepare(
`SELECT s.overlay_id, m.type, m.config_json
FROM overlay_modules m
JOIN overlay_scenes s ON s.id = m.scene_id
JOIN overlays o ON o.id = s.overlay_id
WHERE m.enabled = 1 AND s.enabled = 1 AND o.enabled = 1`
).all();
const targets = new Set();
for (const row of rows) {
if (getOverlayModuleType(row.type)?.renderType !== "chat") continue;
let config = {};
try { config = JSON.parse(row.config_json || "{}"); } catch {}
if (messageMatchesConfig(message, config)) targets.add(row.overlay_id);
}
return [...targets];
}
function remember(overlayId, message) {
const cutoff = Date.now() - MAX_RECENT_AGE_MS;
const recent = (recentByOverlay.get(overlayId) || []).filter((entry) => entry.timestamp >= cutoff && entry.id !== message.id);
recent.push(message);
recentByOverlay.set(overlayId, recent.slice(-MAX_RECENT_PER_OVERLAY));
}
function publishOverlayChatMessage(input = {}) {
const message = normalizeChatMessage(input);
if (!message) return 0;
let delivered = 0;
for (const overlayId of overlayTargets(message)) {
remember(overlayId, message);
delivered += publishWebEvent("overlay:chat-message", message, { scope: `overlay:${overlayId}` });
}
return delivered;
}
function recentOverlayChatMessages(overlayId) {
const cutoff = Date.now() - MAX_RECENT_AGE_MS;
const recent = (recentByOverlay.get(String(overlayId)) || []).filter((entry) => entry.timestamp >= cutoff);
recentByOverlay.set(String(overlayId), recent);
return recent.map((payload) => ({ event: "overlay:chat-message", payload }));
}
module.exports = {
messageMatchesConfig,
normalizeChatMessage,
publishOverlayChatMessage,
recentOverlayChatMessages
};

View File

@ -67,8 +67,8 @@ function registerOverlayModuleType(type) {
throw new Error("Overlay module types require an id and normalize function."); throw new Error("Overlay module types require an id and normalize function.");
} }
const renderType = type.renderType || type.id; const renderType = type.renderType || type.id;
if (!["text", "image", "video", "audio", "web"].includes(renderType)) { if (!["text", "image", "video", "audio", "web", "chat"].includes(renderType)) {
throw new Error("Overlay module renderType must be text, image, video, audio, or web."); throw new Error("Overlay module renderType must be text, image, video, audio, web, or chat.");
} }
moduleTypes.set(type.id, Object.freeze({ ...type, renderType })); moduleTypes.set(type.id, Object.freeze({ ...type, renderType }));
return () => moduleTypes.delete(type.id); return () => moduleTypes.delete(type.id);
@ -125,6 +125,50 @@ registerOverlayModuleType({
} }
}); });
registerOverlayModuleType({
id: "chat",
label: "Lumi chat",
description: "Show live messages from one or more connected chat platforms and channels.",
normalize(input) {
const platforms = (Array.isArray(input.platforms) ? input.platforms : String(input.platforms || "twitch,youtube,discord").split(","))
.map((entry) => text(entry, "", 24).trim().toLowerCase())
.filter((entry, index, values) => ["twitch", "youtube", "discord"].includes(entry) && values.indexOf(entry) === index);
const channels = (Array.isArray(input.channels) ? input.channels : String(input.channels || "").split(/[\n,]+/))
.map((entry) => text(entry, "", 256).trim())
.filter((entry, index, values) => entry && values.indexOf(entry) === index)
.slice(0, 50);
if (input.platforms !== undefined && !platforms.length) throw new Error("Choose at least one chat service.");
return {
...commonConfig(input),
platforms: input.platforms === undefined ? ["twitch", "youtube", "discord"] : platforms,
channels,
maxMessages: Math.round(number(input.maxMessages, 8, 1, 50)),
messageTimeoutSeconds: number(input.messageTimeoutSeconds, 30, 0, 3600),
newestAt: ["top", "bottom"].includes(input.newestAt) ? input.newestAt : "bottom",
horizontalAlign: ["left", "center", "right"].includes(input.horizontalAlign) ? input.horizontalAlign : "left",
fontFamily: text(input.fontFamily, "system-ui, sans-serif", 300).trim() || "system-ui, sans-serif",
fontSize: number(input.fontSize, 32, 8, 160),
fontWeight: number(input.fontWeight, 600, 100, 900),
lineHeight: number(input.lineHeight, 1.25, 0.8, 3),
color: color(input.color, "#ffffff"),
usernameColor: color(input.usernameColor, "#67e8f9"),
background: color(input.background, "transparent"),
messageBackground: color(input.messageBackground, "#0f172bcc"),
padding: number(input.padding, 16, 0, 100),
gap: number(input.gap, 10, 0, 100),
borderRadius: number(input.borderRadius, 12, 0, 100),
showAvatars: input.showAvatars !== false && input.showAvatars !== "false" && input.showAvatars !== "off",
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",
animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)),
customCss: text(input.customCss, "", 12000)
};
}
});
registerOverlayModuleType({ registerOverlayModuleType({
id: "video", id: "video",
label: "Video / media", label: "Video / media",

View File

@ -37,6 +37,7 @@ const {
} = require("./overlays"); } = require("./overlays");
const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults"); const { OBS_BROWSER_DEFAULT_CSS } = require("./obs-browser-defaults");
const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents"); const { fetchPublicOverlay, loadInjectedOverlayDocument } = require("./overlay-web-documents");
const { recentOverlayChatMessages } = require("./overlay-chat");
const { const {
listOverlayConnectorProviders, listOverlayConnectorProviders,
overlayConnectorManager overlayConnectorManager
@ -207,7 +208,10 @@ function registerPublicOverlayRoutes(app) {
if (!context) return res.status(404).end(); if (!context) return res.status(404).end();
return subscribeWebEvents(req, res, { return subscribeWebEvents(req, res, {
scopes: [`overlay:${context.overlay.id}`], scopes: [`overlay:${context.overlay.id}`],
initialEvents: [{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } }] initialEvents: [
{ event: "overlay:changed", payload: { revision: context.overlay.updated_at } },
...recentOverlayChatMessages(context.overlay.id)
]
}); });
}); });
@ -345,7 +349,25 @@ function moduleInput(body) {
cropBottom: body.crop_bottom, cropBottom: body.crop_bottom,
cropLeft: body.crop_left, cropLeft: body.crop_left,
zoom: body.zoom, zoom: body.zoom,
customCss: body.custom_css platforms: ["twitch", "youtube", "discord"].filter((platform) => booleanField(body, `chat_platform_${platform}`)),
channels: body.chat_channels,
maxMessages: body.chat_max_messages,
messageTimeoutSeconds: body.chat_timeout_seconds,
newestAt: body.chat_newest_at,
fontFamily: body.chat_font_family,
lineHeight: body.chat_line_height,
usernameColor: body.chat_username_color,
messageBackground: body.chat_message_background,
gap: body.chat_gap,
borderRadius: body.chat_border_radius,
showAvatars: booleanField(body, "chat_show_avatars"),
showBadges: booleanField(body, "chat_show_badges"),
showPlatform: booleanField(body, "chat_show_platform"),
showTimestamp: booleanField(body, "chat_show_timestamp"),
inAnimation: body.chat_in_animation,
outAnimation: body.chat_out_animation,
animationDurationMs: body.chat_animation_duration_ms,
customCss: body.type === "chat" ? body.chat_custom_css : body.custom_css
} }
}; };
} }

View File

@ -3,6 +3,7 @@ const { getSetting } = require("./settings");
const { incrementMessages } = require("./stats"); 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 twitchLog = createLogger("platform:twitch", { category: "integration" }); const twitchLog = createLogger("platform:twitch", { category: "integration" });
@ -39,14 +40,30 @@ async function startTwitchBot({ commandRouter } = {}) {
}); });
client.on("message", async (channel, tags, message, self) => { client.on("message", async (channel, tags, message, self) => {
if (self) {
return;
}
const userId = tags["user-id"]; const userId = tags["user-id"];
if (!userId) { if (!userId) {
return; return;
} }
const displayName = tags["display-name"] || tags.username; const displayName = tags["display-name"] || tags.username;
publishOverlayChatMessage({
id: tags.id,
platform: "twitch",
text: message,
timestamp: Number(tags["tmi-sent-ts"]) || Date.now(),
channel: { id: String(tags["room-id"] || ""), name: channel, key: channel },
author: {
id: userId,
name: displayName,
username: tags.username,
color: tags.color,
badges: Object.keys(tags.badges || {})
},
emotes: Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => {
const [start, end] = String(position).split("-").map(Number);
return { start, end, image: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/3.0` };
}))
});
if (self) return;
const profile = ensureUserForIdentity({ const profile = ensureUserForIdentity({
provider: "twitch", provider: "twitch",
providerUserId: userId, providerUserId: userId,

View File

@ -2,6 +2,7 @@ const { getSetting, setSetting } = require("./settings");
const { incrementMessages } = require("./stats"); 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 youtubeLog = createLogger("platform:youtube", { category: "integration" }); const youtubeLog = createLogger("platform:youtube", { category: "integration" });
@ -120,12 +121,27 @@ async function handleChatItem(state, liveChatId, item) {
if (!messageText) { if (!messageText) {
return; return;
} }
if (state.channelId && author.channelId === state.channelId) {
return;
}
const displayName = author.displayName || "YouTube User"; const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null; const avatar = author.profileImageUrl || null;
publishOverlayChatMessage({
id: item.id,
platform: "youtube",
text: messageText,
timestamp: Date.parse(snippet.publishedAt) || Date.now(),
channel: {
id: liveChatId,
name: state.channelName || state.activeVideoId || "Live chat",
key: state.activeVideoId || liveChatId
},
author: {
id: author.channelId,
name: displayName,
username: displayName,
avatar,
badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean)
}
});
if (state.channelId && author.channelId === state.channelId) return;
const profile = ensureUserForIdentity({ const profile = ensureUserForIdentity({
provider: "youtube", provider: "youtube",
providerUserId: author.channelId, providerUserId: author.channelId,

View File

@ -289,6 +289,37 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.overlay-chat-fields > .full,
.overlay-fieldset.full {
grid-column: 1 / -1;
}
.overlay-fieldset {
min-width: 0;
margin: 0;
padding: var(--lumi-space-3);
border: 1px solid var(--lumi-border);
border-radius: var(--lumi-radius-sm);
}
.overlay-fieldset legend {
padding-inline: var(--lumi-space-1);
font-weight: 800;
}
.overlay-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: var(--lumi-space-2) var(--lumi-space-3);
}
.overlay-check-grid label {
display: flex;
align-items: center;
gap: var(--lumi-space-2);
min-height: var(--lumi-control-height);
}
.overlay-conditional-fields[hidden] { .overlay-conditional-fields[hidden] {
display: none !important; display: none !important;
} }

View File

@ -1,4 +1,5 @@
(() => { (() => {
const chatStates = new Map();
const anchors = { const anchors = {
"top-left": [0, 0], "top-left": [0, 0],
"top-center": [-50, 0], "top-center": [-50, 0],
@ -126,6 +127,197 @@
return root; return root;
} }
function normalizedChannel(value) {
return String(value || "").trim().toLowerCase().replace(/^#/, "");
}
function chatAccepts(values, payload) {
if (!(values.platforms || []).includes(payload.platform)) return false;
const filters = Array.isArray(values.channels) ? values.channels : [];
if (!filters.length) return true;
const aliases = [payload.channel?.id, payload.channel?.name, payload.channel?.key]
.map(normalizedChannel)
.filter(Boolean);
return filters.some((entry) => {
const raw = String(entry || "").trim();
const separator = raw.indexOf(":");
const platform = separator > 0 ? raw.slice(0, separator).trim().toLowerCase() : "";
const channel = normalizedChannel(separator > 0 ? raw.slice(separator + 1) : raw);
return (!platform || platform === payload.platform) && (channel === "*" || aliases.includes(channel));
});
}
function chatMessageElement(payload, values, { animate = true } = {}) {
const row = document.createElement("article");
row.className = `message platform-${payload.platform}`;
row.dataset.messageId = payload.id;
row.dataset.platform = payload.platform;
if (values.showAvatars && payload.author?.avatar) {
const avatar = document.createElement("img");
avatar.className = "avatar";
avatar.src = payload.author.avatar;
avatar.alt = "";
avatar.referrerPolicy = "no-referrer";
avatar.addEventListener("error", () => avatar.remove(), { once: true });
row.appendChild(avatar);
}
const content = document.createElement("div");
content.className = "content";
const meta = document.createElement("div");
meta.className = "meta";
if (values.showPlatform) {
const platform = document.createElement("span");
platform.className = "platform";
platform.textContent = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1);
meta.appendChild(platform);
}
const author = document.createElement("span");
author.className = "author";
author.textContent = payload.author?.name || "Viewer";
author.style.color = payload.author?.color || values.usernameColor || "#67e8f9";
meta.appendChild(author);
if (values.showBadges) {
for (const badgeData of payload.author?.badges || []) {
const badge = document.createElement(badgeData.image ? "img" : "span");
badge.className = "badge";
if (badgeData.image) {
badge.src = badgeData.image;
badge.alt = badgeData.label || "Badge";
} else {
badge.textContent = badgeData.label;
}
meta.appendChild(badge);
}
}
if (values.showTimestamp) {
const timestamp = document.createElement("time");
timestamp.className = "timestamp";
timestamp.dateTime = new Date(payload.timestamp).toISOString();
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);
}
text.append(document.createTextNode(payload.text.slice(cursor)));
content.append(meta, text);
row.appendChild(content);
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
row.classList.add("enter", `enter-${values.inAnimation}`);
requestAnimationFrame(() => requestAnimationFrame(() => row.classList.add("enter-active")));
setTimeout(() => row.classList.remove("enter", "enter-active", `enter-${values.inAnimation}`), Number(values.animationDurationMs));
}
return row;
}
function removeChatMessage(moduleId, messageId) {
const state = chatStates.get(moduleId);
if (!state) return;
state.messages = state.messages.filter((message) => message.id !== messageId);
const row = state.list?.querySelector?.(`[data-message-id="${CSS.escape(messageId)}"]`);
if (!row) return;
const values = state.values || {};
if (values.outAnimation !== "none" && Number(values.animationDurationMs) > 0) {
row.classList.add("leave", `leave-${values.outAnimation}`);
requestAnimationFrame(() => row.classList.add("leave-active"));
setTimeout(() => row.remove(), Number(values.animationDurationMs));
} else row.remove();
}
function scheduleChatRemoval(moduleId, payload, values) {
if (!(Number(values.messageTimeoutSeconds) > 0)) return;
const remaining = Math.max(0, payload.timestamp + Number(values.messageTimeoutSeconds) * 1000 - Date.now());
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
}
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 || "";
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; }
.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; }
.author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; }
.platform { flex:0 0 auto; padding:.12em .38em; border-radius:.3em; background:rgba(255,255,255,.14); font-size:.72em; text-transform:uppercase; letter-spacing:.035em; }
.platform-twitch .platform { background:#9146ff; color:#fff; }
.platform-youtube .platform { background:#ff0033; color:#fff; }
.platform-discord .platform { background:#5865f2; color:#fff; }
.badge { flex:0 0 auto; font-size:.68em; line-height:1; padding:.18em .32em; border-radius:.25em; background:rgba(255,255,255,.14); }
img.badge { width:1.1em; height:1.1em; padding:0; object-fit:contain; background:transparent; }
.timestamp { flex:0 0 auto; margin-left: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; }
.enter-fade, .leave-active { opacity:0; }
.enter-slide-up { opacity:0; transform:translateY(1em); }
.enter-slide-left { opacity:0; transform:translateX(1.5em); }
.enter-scale { opacity:0; transform:scale(.92); }
.enter-active { opacity:1; transform:none; }
.leave-slide-left.leave-active { transform:translateX(-1.5em); }
.leave-slide-down.leave-active { transform:translateY(1em); }
${chatCustomCss}
`;
const list = document.createElement("div");
list.className = "chat";
list.setAttribute("role", "log");
list.setAttribute("aria-live", "off");
shadow.append(style, list);
let state = chatStates.get(module.id);
if (!state) state = { messages: [] };
state.values = values;
state.list = list;
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: [] } }
];
}
state.messages = state.messages.slice(-Number(values.maxMessages || 8));
const display = values.newestAt === "top" ? [...state.messages].reverse() : state.messages;
for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false }));
chatStates.set(module.id, state);
return root;
}
function handleChatMessage(root, payload) {
if (!payload?.id || !payload?.platform || !payload?.text) return 0;
let accepted = 0;
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
const moduleId = box.dataset.moduleId;
const state = chatStates.get(moduleId);
if (!state?.list || !chatAccepts(state.values, payload) || state.messages.some((message) => message.id === payload.id)) return;
state.messages.push(payload);
const row = chatMessageElement(payload, state.values);
if (state.values.newestAt === "top") state.list.prepend(row);
else state.list.appendChild(row);
while (state.messages.length > Number(state.values.maxMessages || 8)) removeChatMessage(moduleId, state.messages[0].id);
scheduleChatRemoval(moduleId, payload, state.values);
accepted += 1;
});
return accepted;
}
function findSourceContent(root, moduleId, selector) { function findSourceContent(root, moduleId, selector) {
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`); const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
return box?.querySelector?.(selector) || box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector) || null; return box?.querySelector?.(selector) || box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector) || null;
@ -161,6 +353,9 @@
content.style.fontSize = `${finite(values.fontSize, 48) * scale}px`; content.style.fontSize = `${finite(values.fontSize, 48) * scale}px`;
box.appendChild(content); box.appendChild(content);
if (values.overflow === "shrink") shrinkText(box, content, finite(values.fontSize, 48), 8, scale); if (values.overflow === "shrink") shrinkText(box, content, finite(values.fontSize, 48), 8, scale);
} else if (renderType === "chat") {
box.dataset.renderType = "chat";
box.appendChild(buildChat(module, values, options));
} else if (renderType === "image" && values.url) { } else if (renderType === "image" && values.url) {
const image = document.createElement("img"); const image = document.createElement("img");
image.className = "lumi-overlay-image"; image.className = "lumi-overlay-image";
@ -243,5 +438,5 @@
}; };
} }
window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, render, restartMediaElement, stageLayout }; window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, restartMediaElement, stageLayout };
})(); })();

View File

@ -73,7 +73,8 @@ body {
.lumi-overlay-image, .lumi-overlay-image,
.lumi-overlay-video, .lumi-overlay-video,
.lumi-overlay-web-root { .lumi-overlay-web-root,
.lumi-overlay-chat-root {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;

View File

@ -224,6 +224,10 @@
if (payload.module_id) restartSource(payload.module_id); if (payload.module_id) restartSource(payload.module_id);
} catch {} } catch {}
}); });
events.addEventListener("overlay:chat-message", (event) => {
try { renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); }
catch {}
});
events.addEventListener("overlay:obs-browser-command", (event) => { events.addEventListener("overlay:obs-browser-command", (event) => {
if (!obsBridge) return; if (!obsBridge) return;
try { try {

View File

@ -113,6 +113,38 @@
<label>Inner spacing<input type="number" name="padding" min="0" max="200" value="<%= value(config,"padding",0) %>" /></label> <label>Inner spacing<input type="number" name="padding" min="0" max="200" value="<%= value(config,"padding",0) %>" /></label>
</div> </div>
<div class="field full overlay-conditional-fields overlay-chat-fields" data-module-fields="chat">
<fieldset class="overlay-fieldset full"><legend>Chats to include <button type="button" class="overlay-help" data-tooltip="Choose any combination. Lumi combines messages from the connected chat services into this one source.">?</button></legend><div class="overlay-check-grid">
<% [["twitch","Twitch"],["youtube","YouTube"],["discord","Discord"]].forEach(([id,label]) => { %><label><input type="checkbox" name="chat_platform_<%= id %>" <%= (value(config,"platforms",["twitch","youtube","discord"])).includes(id) ? "checked" : "" %> /> <%= label %></label><% }) %>
</div></fieldset>
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="3" placeholder="twitch:cozycarnage&#10;discord:community-chat"><%= (value(config,"channels",[])).join("\n") %></textarea><small class="hint">Leave empty to include every channel Lumi receives from the selected services. Use one channel name or ID per line; add the service name when needed, such as <code>twitch:channel</code>.</small></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>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 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>
<label>Line spacing<input type="number" name="chat_line_height" min="0.8" max="3" step="0.05" value="<%= value(config,"lineHeight",1.25) %>" /></label>
<div class="overlay-color-field" data-color-field><span>Text color</span><div class="overlay-color-inputs"><input name="color" value="<%= value(config,"color","#ffffff") %>" aria-label="Chat text color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"color","#ffffff")) %>" aria-label="Choose chat text color" data-color-picker /></div></div>
<div class="overlay-color-field" data-color-field><span>Name color</span><div class="overlay-color-inputs"><input name="chat_username_color" value="<%= value(config,"usernameColor","#67e8f9") %>" aria-label="Default username color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"usernameColor","#67e8f9")) %>" aria-label="Choose default username color" data-color-picker /></div></div>
<div class="overlay-color-field" data-color-field><span>Source background</span><div class="overlay-color-inputs"><input name="background" value="<%= value(config,"background","transparent") %>" aria-label="Chat background color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"background","transparent"),"#000000") %>" aria-label="Choose chat background" data-color-picker /></div></div>
<div class="overlay-color-field" data-color-field><span>Message background</span><div class="overlay-color-inputs"><input name="chat_message_background" value="<%= value(config,"messageBackground","#0f172bcc") %>" aria-label="Message background color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"messageBackground","#0f172bcc"),"#0f172b") %>" aria-label="Choose message background" data-color-picker /></div></div>
<label>Outer spacing<input type="number" name="padding" min="0" max="100" value="<%= value(config,"padding",16) %>" /></label>
<label>Between messages<input type="number" name="chat_gap" min="0" max="100" value="<%= value(config,"gap",10) %>" /></label>
<label>Rounded corners<input type="number" name="chat_border_radius" min="0" max="100" value="<%= value(config,"borderRadius",12) %>" /></label>
<fieldset class="overlay-fieldset full"><legend>Message details</legend><div class="overlay-check-grid">
<label><input type="checkbox" name="chat_show_avatars" <%= value(config,"showAvatars",true) ? "checked" : "" %> /> Avatars</label>
<label><input type="checkbox" name="chat_show_badges" <%= value(config,"showBadges",true) ? "checked" : "" %> /> Badges</label>
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service name</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>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>
<div class="field full overlay-conditional-fields" data-module-fields="image"> <div class="field full overlay-conditional-fields" data-module-fields="image">
<label>Image address<input name="url" value="<%= module.type === "image" ? value(config, "url") : "" %>" placeholder="https://example.com/image.png" /></label> <label>Image address<input name="url" value="<%= module.type === "image" ? value(config, "url") : "" %>" placeholder="https://example.com/image.png" /></label>
<label>Fit inside box<select name="fit"><% [["contain","Show the whole image"],["cover","Fill the box and crop"],["fill","Stretch to fill"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"fit","contain") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label> <label>Fit inside box<select name="fit"><% [["contain","Show the whole image"],["cover","Fill the box and crop"],["fill","Stretch to fill"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"fit","contain") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
@ -170,7 +202,26 @@
<% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %> <% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %>
<details class="overlay-add-source"><summary>Add a source</summary><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules" class="form-grid overlay-source-form" data-module-editor><div class="field"><label>Name</label><input name="name" required placeholder="Headline" /></div><div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>"><%= type.label %></option><% }) %></select></div><input type="hidden" name="enabled" value="on" /><input type="hidden" name="anchor" value="top-left" /><input type="hidden" name="x" value="0" /><input type="hidden" name="y" value="0" /><input type="hidden" name="width" value="50" /><input type="hidden" name="height" value="25" /><div class="field full overlay-conditional-fields" data-module-fields="text"><label>Text<textarea name="text" rows="2">New text</textarea></label></div><div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div><div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div><div class="form-actions"><button class="button" type="submit">Add source</button></div></form></details> <details class="overlay-add-source">
<summary>Add a source</summary>
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules" class="form-grid overlay-source-form" data-module-editor>
<div class="field"><label>Name</label><input name="name" required placeholder="Headline" /></div>
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>"><%= type.label %></option><% }) %></select></div>
<input type="hidden" name="enabled" value="on" /><input type="hidden" name="anchor" value="top-left" /><input type="hidden" name="x" value="0" /><input type="hidden" name="y" value="0" /><input type="hidden" name="width" value="50" /><input type="hidden" name="height" value="25" />
<div class="field full overlay-conditional-fields" data-module-fields="text"><label>Text<textarea name="text" rows="2">New text</textarea></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="chat">
<p class="hint full">Lumi combines live messages from the services you select. You can adjust the appearance, animations, timeout, and CSS after adding it.</p>
<fieldset class="overlay-fieldset full"><legend>Chats to include</legend><div class="overlay-check-grid"><label><input type="checkbox" name="chat_platform_twitch" checked /> Twitch</label><label><input type="checkbox" name="chat_platform_youtube" checked /> YouTube</label><label><input type="checkbox" name="chat_platform_discord" checked /> Discord</label></div></fieldset>
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="2" placeholder="Leave empty for all connected channels"></textarea></label>
<input type="hidden" name="chat_show_avatars" value="on" /><input type="hidden" name="chat_show_badges" value="on" /><input type="hidden" name="chat_show_platform" value="on" />
</div>
<div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
<div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div>
<div class="form-actions"><button class="button" type="submit">Add source</button></div>
</form>
</details>
</div> </div>
</details> </details>
<% }) %> <% }) %>

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.13", "version": "0.2.14",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-18", "released_at": "2026-07-19",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "patch", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Includes the 1.2.0 version correction, production diagnostics, Windows/network-share-safe updates, Node.js 24-compatible dependency startup, structured redacted logging, OBS overlay layout parity, and OBS-style website document CSS injection. Lumi preserves existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.", "migration_notes": "Adds the native multi-platform Lumi chat overlay source and preserves existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -181,6 +181,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Adds OBS-style website document CSS injection with secure scoped render tickets; existing overlay sources, URLs, CSS, tokens, and all other local data remain preserved." "migration_notes": "Adds OBS-style website document CSS injection with secure scoped render tickets; existing overlay sources, URLs, CSS, tokens, and all other local data remain preserved."
},
{
"version": "0.2.14",
"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": "Adds the native multi-platform Lumi chat overlay source; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
} }
] ]
} }