Add native multi-platform chat overlays
This commit is contained in:
parent
a90f7071d9
commit
ac6f364327
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
142
src/services/overlay-chat.js
Normal file
142
src/services/overlay-chat.js
Normal 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
|
||||||
|
};
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 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>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user