From f335bbe24a18f0e64e0ae81a518922b5785a5f8e Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 19 Jul 2026 13:01:16 +0200 Subject: [PATCH] Refine overlay chat previews and media --- CHANGELOG.md | 7 + TODO.md | 5 +- docs/obs-overlays.md | 18 ++- knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +- package.json | 2 +- release-index.json | 30 ++++ scripts/verify-overlays.js | 32 +++- scripts/verify-release-metadata.js | 6 +- scripts/verify-update-system.js | 5 +- src/services/discord-chat-content.js | 59 ++++++++ src/services/discord.js | 12 +- src/services/overlay-chat.js | 14 +- src/services/overlay-modules.js | 4 +- src/services/twitch-chat-assets.js | 34 ++++- src/services/twitch.js | 11 +- src/web/public/icons/avatars/preview-cyan.svg | 1 + src/web/public/icons/avatars/preview-gold.svg | 1 + src/web/public/icons/avatars/preview-pink.svg | 1 + .../public/icons/avatars/preview-purple.svg | 1 + src/web/public/icons/avatars/preview-red.svg | 1 + src/web/public/overlay-management.js | 6 +- src/web/public/overlay-renderer.js | 139 ++++++++++++++---- src/web/views/admin-overlay-detail.ejs | 6 +- update-manifest.json | 16 +- 25 files changed, 348 insertions(+), 69 deletions(-) create mode 100644 src/services/discord-chat-content.js create mode 100644 src/web/public/icons/avatars/preview-cyan.svg create mode 100644 src/web/public/icons/avatars/preview-gold.svg create mode 100644 src/web/public/icons/avatars/preview-pink.svg create mode 100644 src/web/public/icons/avatars/preview-purple.svg create mode 100644 src/web/public/icons/avatars/preview-red.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 881467e..75cd189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi changelog +## 0.2.16 + +- Kept keyboard focus in source fields while server-normalized live previews rerender, preventing interrupted typing and other continuous input. +- Added cached Twitch profile-image lookup and Discord custom/animated emoji, GIF, GIFV, attachment, and sticker rendering for native chat overlays. +- Made chat alignment apply to complete message groups, made message backgrounds fit their content, and added directional entry/exit animations. +- Expanded the editor simulation to cycle realistic short, wrapped, avatar, badge, platform, arrival, and departure examples continuously. + ## 0.2.15 - Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking. diff --git a/TODO.md b/TODO.md index 9bbfecc..b067a2c 100644 --- a/TODO.md +++ b/TODO.md @@ -33,7 +33,10 @@ temporary website CSS), marks/reverts drafts, provides contrast and grid views, and keeps save actions reachable. Alert documents also retain transparent page and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own platform messages, image-based platform/badge marks, and server-enforced platform -or linked-Lumi-account user blocking. +or linked-Lumi-account user blocking. Live draft rerenders preserve form focus; +chat messages now use content-sized cards and whole-message alignment, cached +Twitch avatars, Discord emoji/GIF/sticker media, four-direction entry/exit +animations, and a continuously animated multi-message editor simulation. Remaining work: diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index b417bbd..5e40ff6 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -74,16 +74,23 @@ accept one value per line and may be prefixed with the service, such as `twitch:cozycarnage` or `discord:community-chat`. Chat sources support transparent source and message backgrounds, font family, -size, weight and alignment, name/text colors, avatars, badges, platform labels, +size, weight and whole-message alignment, name/text colors, avatars, badges, platform labels, timestamps, message spacing, rounded corners, maximum visible messages, top-or-bottom placement, timeout removal, and separate entry and exit -animations. Twitch emotes are displayed as images. The editor canvas supplies -sample messages so appearance can be configured before the stream is live. +animations. Message cards fit their contents up to the source boundary rather +than filling its width. Entry and exit slides can travel left, right, up, or +down. Twitch emotes and Discord custom or animated emoji are displayed as +images. Discord GIF links, GIF attachments, GIFV embeds, and stickers are shown +as bounded inline media at roughly two text lines tall. The editor continuously +cycles several short and wrapped sample messages so both static appearance and +entry/exit behavior can be configured before the stream is live. Lumi includes messages sent by its own connected bot accounts. Platform marks are shown as icons, and chat badges are rendered as images. Twitch badge artwork is resolved from Twitch's global and channel badge catalogs; a local image -fallback is used when a provider does not expose badge artwork. +fallback is used when a provider does not expose badge artwork. Twitch profile +images are resolved through the Twitch user catalog and cached to avoid a lookup +for every message. The optional hidden-user list accepts one rule per line in the form `platform:username|user-id`. Twitch, YouTube, and Discord rules match only that @@ -94,7 +101,8 @@ and is also checked by the renderer. Advanced CSS is isolated to that chat source and is appended after the visual settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`, -`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, and `.emote`. +`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, `.emote`, +`.media-row`, and `.media`. Messages arrive through the same token-scoped overlay event connection as scene updates. Lumi keeps a small, short-lived in-memory replay window so a temporary Browser Source reconnection can restore recent messages without writing chat diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 353799f..3be97d7 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.15 +Version: 0.2.16 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 801b709..70dee3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.15", + "version": "0.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.15", + "version": "0.2.16", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 93ee219..973f6b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.15", + "version": "0.2.16", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index ba9ce01..69689a4 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.16", + "ref": "refs/tags/v0.2.16", + "released_at": "2026-07-19", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Improves native chat preview focus, avatars, compact alignment and sizing, directional animations, and Discord emoji/GIF media. Existing overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.15", "ref": "refs/tags/v0.2.15", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index 960c2ac..be82508 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -19,7 +19,8 @@ for (const file of [ "overlays.js", "overlay-permissions.js", "overlay-connectors.js", - "overlay-chat.js" + "overlay-chat.js", + "discord-chat-content.js" ]) { fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file)); } @@ -161,6 +162,8 @@ try { assert.strictEqual(chatConfig.background, "#0000"); assert.strictEqual(chatConfig.inAnimation, "scale"); assert(chatConfig.customCss.includes(".message")); + assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).inAnimation, "slide-right"); + assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).outAnimation, "slide-up"); assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]); const styledWebConfig = overlayModules.normalizeModuleConfig("web", { url: "https://example.com/widget", @@ -212,6 +215,25 @@ try { assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true); assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true); assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false); + const mediaOnlyChat = overlayChat.normalizeChatMessage({ + id: "message-gif", + platform: "discord", + text: "", + media: [{ url: "https://cdn.example/dance.gif", type: "image", alt: "Dance" }], + author: { id: "discord-viewer", name: "Discord viewer" } + }); + assert.strictEqual(mediaOnlyChat.media[0].url, "https://cdn.example/dance.gif"); + assert.strictEqual(mediaOnlyChat.text, ""); + const { discordOverlayContent } = require(path.join(serviceDir, "discord-chat-content.js")); + const discordContent = discordOverlayContent({ + content: "Hello https://cdn.example/dance.gif", + attachments: new Map(), + embeds: [], + stickers: new Map() + }); + assert.strictEqual(discordContent.text, "Hello "); + assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless"); + assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif"); overlayChat.publishOverlayChatMessage(normalizedChat); assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1")); const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id); @@ -291,6 +313,7 @@ try { assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow"); assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets")); assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview ยท not saved")); + assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus"); assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position"); assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource")); assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage")); @@ -299,6 +322,10 @@ try { assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts")); assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/")); assert(rendererScript.includes("/icons/badges/")); + assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat")); + assert(rendererScript.includes("width:fit-content") && rendererScript.includes("align-self:${alignment}")); + assert(rendererScript.includes("enter-slide-right") && rendererScript.includes("leave-slide-up")); + assert(rendererScript.includes('mediaData.type === "video"') && rendererScript.includes('className = "media-row"')); assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")')); assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")')); assert(rendererScript.includes("values.renderUrl || values.url")); @@ -323,7 +350,8 @@ try { assert.strictEqual(obsLayout.scaleX, 1); assert.strictEqual(obsLayout.scaleY, 1); assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report")); - assert(twitchBadgeScript.includes("api.twitch.tv/helix/chat/badges") && twitchBadgeScript.includes("image_url_2x")); + assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x")); + assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}")); assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument")); assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index bfa8a38..cb1407c 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.15"; -const previousCoreVersion = "0.2.14"; +const releaseVersion = "0.2.16"; +const previousCoreVersion = "0.2.15"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.15, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.16, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index baa6c53..c5b9f01 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true }, { version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true }, { version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true }, { version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true }, @@ -75,7 +76,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.15"); +assert.equal(corrected.safe_target_version, "0.2.16"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/discord-chat-content.js b/src/services/discord-chat-content.js new file mode 100644 index 0000000..a05c85f --- /dev/null +++ b/src/services/discord-chat-content.js @@ -0,0 +1,59 @@ +function collectionValues(collection) { + if (!collection) return []; + if (Array.isArray(collection)) return collection; + if (typeof collection.values === "function") return [...collection.values()]; + return Object.values(collection); +} + +function discordOverlayContent(message) { + let text = String(message?.content || ""); + const media = []; + const mediaUrls = new Set(); + const removedLinks = new Set(); + const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => { + const clean = String(url || "").trim(); + if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return; + mediaUrls.add(clean); + media.push({ url: clean, type: type === "video" ? "video" : "image", alt }); + if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl); + }; + + for (const attachment of collectionValues(message?.attachments)) { + const url = attachment?.url || attachment?.proxyURL; + const contentType = String(attachment?.contentType || attachment?.content_type || "").toLowerCase(); + if (contentType === "image/gif" || /\.gif(?:$|\?)/i.test(String(url || ""))) { + addMedia(url, "image", attachment?.name || "Animated image"); + } + } + for (const embed of collectionValues(message?.embeds)) { + const sourceUrl = String(embed?.url || ""); + const embedType = String(embed?.type || "").toLowerCase(); + if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue; + const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url; + const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url; + if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl); + else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl); + } + for (const sticker of collectionValues(message?.stickers)) { + addMedia(sticker?.url, "image", sticker?.name || "Sticker"); + } + for (const match of text.matchAll(/https?:\/\/[^\s<>]+\.gif(?:\?[^\s<>]*)?/gi)) { + addMedia(match[0], "image", "Animated image", match[0]); + } + for (const link of removedLinks) text = text.split(link).join(""); + text = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim(); + + const emotes = []; + for (const match of text.matchAll(/<(a?):([a-zA-Z0-9_]+):(\d+)>/g)) { + const animated = match[1] === "a"; + emotes.push({ + start: match.index, + end: match.index + match[0].length - 1, + image: `https://cdn.discordapp.com/emojis/${match[3]}.${animated ? "gif" : "webp"}?size=96&quality=lossless`, + label: `:${match[2]}:` + }); + } + return { text, emotes, media }; +} + +module.exports = { discordOverlayContent }; diff --git a/src/services/discord.js b/src/services/discord.js index d139be2..efc872c 100644 --- a/src/services/discord.js +++ b/src/services/discord.js @@ -9,10 +9,11 @@ const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); const { publishOverlayChatMessage } = require("./overlay-chat"); +const { discordOverlayContent } = require("./discord-chat-content"); const discordLog = createLogger("platform:discord", { category: "integration" }); -let client = null; +let client = null; async function startBot({ commandRouter } = {}) { const token = getSetting("discord_bot_token"); @@ -53,6 +54,7 @@ async function startBot({ commandRouter } = {}) { client.on("messageCreate", async (message) => { if (!message.guild) return; + const overlayContent = discordOverlayContent(message); const displayName = message.author.globalName || message.author.username || message.author.tag; let avatarUrl = null; @@ -72,7 +74,9 @@ async function startBot({ commandRouter } = {}) { publishOverlayChatMessage({ id: message.id, platform: "discord", - text: message.content, + text: overlayContent.text, + emotes: overlayContent.emotes, + media: overlayContent.media, timestamp: message.createdTimestamp, channel: { id: message.channel?.id, @@ -161,8 +165,8 @@ function getClient() { return client; } -module.exports = { - startBot, +module.exports = { + startBot, stopBot, getClient }; diff --git a/src/services/overlay-chat.js b/src/services/overlay-chat.js index 1b58ee5..9bf0ae4 100644 --- a/src/services/overlay-chat.js +++ b/src/services/overlay-chat.js @@ -46,11 +46,22 @@ function normalizeEmote(emote) { return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null; } +function normalizeMedia(media) { + const url = cleanUrl(media?.url); + if (!url) return null; + return { + url, + type: media?.type === "video" ? "video" : "image", + alt: cleanText(media?.alt || "Animated image", 160) + }; +} + function normalizeChatMessage(input = {}) { const platform = cleanText(input.platform, 24).toLowerCase(); const text = cleanMessageText(input.text, 2000); const authorId = cleanText(input.author?.id, 160); - if (!platform || !text.trim() || !authorId) return null; + const media = (Array.isArray(input.media) ? input.media : []).map(normalizeMedia).filter(Boolean).slice(0, 4); + if (!platform || (!text.trim() && !media.length) || !authorId) return null; const channelId = cleanText(input.channel?.id, 256); const channelName = cleanText(input.channel?.name, 256); const channelKey = cleanText(input.channel?.key || channelName || channelId, 256); @@ -61,6 +72,7 @@ function normalizeChatMessage(input = {}) { timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(), channel: { id: channelId, name: channelName, key: channelKey }, emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100), + media, author: { id: authorId, name: cleanText(input.author?.name || input.author?.username || "Viewer", 160), diff --git a/src/services/overlay-modules.js b/src/services/overlay-modules.js index 9d6a05f..e8dfb1d 100644 --- a/src/services/overlay-modules.js +++ b/src/services/overlay-modules.js @@ -166,8 +166,8 @@ registerOverlayModuleType({ showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off", showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off", showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true", - inAnimation: ["none", "fade", "slide-up", "slide-left", "scale"].includes(input.inAnimation) ? input.inAnimation : "slide-up", - outAnimation: ["none", "fade", "slide-left", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade", + inAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.inAnimation) ? input.inAnimation : "slide-up", + outAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade", animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)), customCss: text(input.customCss, "", 12000) }; diff --git a/src/services/twitch-chat-assets.js b/src/services/twitch-chat-assets.js index 1cbed02..70ecc28 100644 --- a/src/services/twitch-chat-assets.js +++ b/src/services/twitch-chat-assets.js @@ -1,20 +1,23 @@ const { getSetting } = require("./settings"); const badgeCatalogs = new Map(); +const userProfiles = new Map(); const CACHE_MS = 30 * 60 * 1000; +const USER_CACHE_MS = 6 * 60 * 60 * 1000; +const USER_FAILURE_CACHE_MS = 5 * 60 * 1000; function fallbackBadges(badges = {}) { return Object.entries(badges).map(([label]) => ({ label, image: null })); } -async function badgeRequest(path) { +async function helixRequest(path) { const clientId = String(getSetting("twitch_client_id", "") || "").trim(); const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, ""); - if (!clientId || !token) throw new Error("Twitch badge credentials are unavailable."); + if (!clientId || !token) throw new Error("Twitch API credentials are unavailable."); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2500); try { - const response = await fetch(`https://api.twitch.tv/helix/chat/badges${path}`, { + const response = await fetch(`https://api.twitch.tv/helix/${path}`, { headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` }, signal: controller.signal }); @@ -25,6 +28,10 @@ async function badgeRequest(path) { } } +function badgeRequest(path) { + return helixRequest(`chat/badges${path}`); +} + function indexCatalog(payload) { const catalog = new Map(); for (const set of payload?.data || []) { @@ -68,4 +75,23 @@ async function resolveTwitchBadges(roomId, badges = {}) { } } -module.exports = { resolveTwitchBadges }; +async function resolveTwitchAvatar(userId, username) { + const id = String(userId || "").trim(); + const login = String(username || "").trim().toLowerCase(); + if (!id && !login) return null; + const key = id ? `id:${id}` : `login:${login}`; + const cached = userProfiles.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const query = id ? `id=${encodeURIComponent(id)}` : `login=${encodeURIComponent(login)}`; + const promise = helixRequest(`users?${query}`) + .then((payload) => payload?.data?.[0]?.profile_image_url || null) + .catch(() => null); + const result = { expiresAt: Date.now() + USER_CACHE_MS, promise }; + userProfiles.set(key, result); + promise.then((avatar) => { + if (!avatar) result.expiresAt = Date.now() + USER_FAILURE_CACHE_MS; + }); + return promise; +} + +module.exports = { resolveTwitchAvatar, resolveTwitchBadges }; diff --git a/src/services/twitch.js b/src/services/twitch.js index 754b2fb..d1d20e7 100644 --- a/src/services/twitch.js +++ b/src/services/twitch.js @@ -4,7 +4,7 @@ const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); const { publishOverlayChatMessage } = require("./overlay-chat"); -const { resolveTwitchBadges } = require("./twitch-chat-assets"); +const { resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets"); const twitchLog = createLogger("platform:twitch", { category: "integration" }); @@ -46,12 +46,16 @@ async function startTwitchBot({ commandRouter } = {}) { return; } const displayName = tags["display-name"] || tags.username || username; + const [badges, avatar] = await Promise.all([ + resolveTwitchBadges(tags["room-id"], tags.badges || {}), + resolveTwitchAvatar(tags["user-id"], tags.username || username) + ]); const profile = self ? null : ensureUserForIdentity({ provider: "twitch", providerUserId: userId, - displayName + displayName, + avatar }); - const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {}); publishOverlayChatMessage({ id: tags.id, platform: "twitch", @@ -62,6 +66,7 @@ async function startTwitchBot({ commandRouter } = {}) { id: userId, name: displayName, username: tags.username, + avatar, color: tags.color, badges, lumi: profile ? { id: profile.id, username: profile.internal_username } : null diff --git a/src/web/public/icons/avatars/preview-cyan.svg b/src/web/public/icons/avatars/preview-cyan.svg new file mode 100644 index 0000000..568a504 --- /dev/null +++ b/src/web/public/icons/avatars/preview-cyan.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/avatars/preview-gold.svg b/src/web/public/icons/avatars/preview-gold.svg new file mode 100644 index 0000000..8bffd12 --- /dev/null +++ b/src/web/public/icons/avatars/preview-gold.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/avatars/preview-pink.svg b/src/web/public/icons/avatars/preview-pink.svg new file mode 100644 index 0000000..e5a4fe6 --- /dev/null +++ b/src/web/public/icons/avatars/preview-pink.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/avatars/preview-purple.svg b/src/web/public/icons/avatars/preview-purple.svg new file mode 100644 index 0000000..62c639a --- /dev/null +++ b/src/web/public/icons/avatars/preview-purple.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/avatars/preview-red.svg b/src/web/public/icons/avatars/preview-red.svg new file mode 100644 index 0000000..0ba2b1a --- /dev/null +++ b/src/web/public/icons/avatars/preview-red.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/overlay-management.js b/src/web/public/overlay-management.js index e547aff..3dacc52 100644 --- a/src/web/public/overlay-management.js +++ b/src/web/public/overlay-management.js @@ -273,7 +273,7 @@ }); }); - function selectSource(moduleId, { openSettings = false } = {}) { + function selectSource(moduleId, { openSettings = false, focusCanvas = true } = {}) { selectedId = moduleId; layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId)); sourceList.querySelectorAll("[data-canvas-select]").forEach((button) => button.classList.toggle("is-selected", button.dataset.canvasSelect === moduleId)); @@ -282,7 +282,7 @@ const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`); if (card) card.open = true; } - canvas.focus({ preventScroll: true }); + if (focusCanvas) canvas.focus({ preventScroll: true }); } function renderCanvas() { @@ -300,7 +300,7 @@ sourceList.appendChild(button); } if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null; - if (selectedId) selectSource(selectedId); + if (selectedId) selectSource(selectedId, { focusCanvas: false }); else updateReadout(); } diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 1e086e4..8ef4bfe 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -225,23 +225,47 @@ timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); meta.appendChild(timestamp); } - const text = document.createElement("div"); - text.className = "text"; - let cursor = 0; - const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start); - for (const emoteData of emotes) { - if (emoteData.start < cursor || emoteData.start >= payload.text.length) continue; - text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start))); - const emote = document.createElement("img"); - emote.className = "emote"; - emote.src = emoteData.image; - emote.alt = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote"; - emote.referrerPolicy = "no-referrer"; - text.appendChild(emote); - cursor = Math.min(payload.text.length, emoteData.end + 1); + const messageText = String(payload.text || ""); + if (messageText) { + const text = document.createElement("div"); + text.className = "text"; + let cursor = 0; + const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start); + for (const emoteData of emotes) { + if (emoteData.start < cursor || emoteData.start >= messageText.length) continue; + text.append(document.createTextNode(messageText.slice(cursor, emoteData.start))); + const emote = document.createElement("img"); + emote.className = "emote"; + emote.src = emoteData.image; + emote.alt = emoteData.label || messageText.slice(emoteData.start, Math.min(messageText.length, emoteData.end + 1)) || "Emote"; + emote.referrerPolicy = "no-referrer"; + text.appendChild(emote); + cursor = Math.min(messageText.length, emoteData.end + 1); + } + text.append(document.createTextNode(messageText.slice(cursor))); + content.appendChild(text); } - text.append(document.createTextNode(payload.text.slice(cursor))); - content.append(meta, text); + if (payload.media?.length) { + const mediaRow = document.createElement("div"); + mediaRow.className = "media-row"; + for (const mediaData of payload.media) { + const media = document.createElement(mediaData.type === "video" ? "video" : "img"); + media.className = "media"; + media.src = mediaData.url; + media.setAttribute("aria-label", mediaData.alt || "Animated image"); + media.referrerPolicy = "no-referrer"; + if (media.tagName === "VIDEO") { + media.autoplay = true; + media.loop = true; + media.muted = true; + media.playsInline = true; + } else media.alt = mediaData.alt || "Animated image"; + media.addEventListener("error", () => media.remove(), { once: true }); + mediaRow.appendChild(media); + } + content.appendChild(mediaRow); + } + content.prepend(meta); row.appendChild(content); if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) { @@ -272,32 +296,83 @@ setTimeout(() => removeChatMessage(moduleId, payload.id), remaining); } + const previewChatMessages = [ + { platform: "twitch", text: "Hi chat!", author: { name: "CozyViewer", color: "#f472b6", avatar: "/icons/avatars/preview-pink.svg", badges: [{ label: "Subscriber", image: null }] } }, + { platform: "discord", text: "The badges, avatar, and service icon move together.", author: { name: "PackMember", color: "#a78bfa", avatar: "/icons/avatars/preview-purple.svg", badges: [{ label: "Moderator", image: null }] } }, + { platform: "youtube", text: "Short message", author: { name: "Lumi Fan", color: "#fb7185", avatar: "/icons/avatars/preview-red.svg", badges: [{ label: "Member", image: null }] } }, + { platform: "twitch", text: "This longer sample wraps naturally so you can compare it with compact one-line messages.", author: { name: "StreamFriend", color: "#22d3ee", avatar: "/icons/avatars/preview-cyan.svg", badges: [] } }, + { platform: "discord", text: "Animated arrivals and departures repeat in this preview.", author: { name: "Lumi", color: "#67e8f9", avatar: "/icons/platforms/lumi.svg", badges: [{ label: "Owner", image: null }] } }, + { platform: "youtube", text: "Looks good!", author: { name: "ChatRegular", color: "#fbbf24", avatar: "/icons/avatars/preview-gold.svg", badges: [] } } + ]; + + function nextPreviewMessage(moduleId, state) { + const allowed = previewChatMessages.filter((message) => (state.values.platforms || []).includes(message.platform)); + const samples = allowed.length ? allowed : previewChatMessages; + const sample = samples[state.previewIndex % samples.length]; + state.previewIndex += 1; + return { + ...sample, + id: `${moduleId}-preview-${state.previewIndex}-${Date.now()}`, + preview: true, + timestamp: Date.now(), + channel: { name: sample.platform === "discord" ? "#community" : "Live chat" }, + author: { ...sample.author } + }; + } + + function schedulePreviewChat(moduleId, state) { + clearTimeout(state.previewTimer); + const delay = Math.max(1400, Number(state.values.animationDurationMs || 0) * 2 + 500); + state.previewTimer = setTimeout(() => { + if (!state.editor || !state.list?.isConnected) { + state.previewTimer = null; + return; + } + const payload = nextPreviewMessage(moduleId, state); + state.messages.push(payload); + const row = chatMessageElement(payload, state.values); + if (state.values.newestAt === "top") state.list.prepend(row); + else state.list.appendChild(row); + const visibleLimit = Math.max(1, Math.min(Number(state.values.maxMessages || 8), 5)); + while (state.messages.length > visibleLimit) removeChatMessage(moduleId, state.messages[0].id); + schedulePreviewChat(moduleId, state); + }, delay); + } + function buildChat(module, values, options) { const root = document.createElement("div"); root.className = "lumi-overlay-chat-root"; const shadow = root.attachShadow({ mode: "open" }); const style = document.createElement("style"); const chatCustomCss = values.customCss || ""; + const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"; style.textContent = ` :host { display:block; width:100%; height:100%; overflow:hidden; box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; } - .chat { width:100%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; } - .message { display:flex; align-items:flex-start; gap:.55em; box-sizing:border-box; width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; } + .chat { width:100%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:${alignment}; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; } + .message { display:inline-flex; align-items:flex-start; align-self:${alignment}; gap:.55em; box-sizing:border-box; width:fit-content; max-width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; } .avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; } - .content { min-width:0; flex:1; } - .meta { display:flex; align-items:center; justify-content:${values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; } + .content { min-width:0; max-width:100%; flex:0 1 auto; } + .meta { display:flex; align-items:center; justify-content:${alignment}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; } .author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; } .platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; } .badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; } - .timestamp { flex:0 0 auto; margin-left:auto; opacity:.65; font-size:.85em; } + .timestamp { flex:0 0 auto; opacity:.65; font-size:.85em; } .text { overflow-wrap:anywhere; white-space:pre-wrap; } .emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; } + .media-row { display:flex; flex-wrap:wrap; justify-content:${alignment}; gap:.3em; margin-top:.2em; max-width:100%; } + .media { display:block; width:auto; height:${Math.max(1.6, Number(values.lineHeight || 1.25) * 2)}em; max-width:100%; border-radius:.25em; object-fit:contain; } .enter-fade, .leave-active { opacity:0; } - .enter-slide-up { opacity:0; transform:translateY(1em); } - .enter-slide-left { opacity:0; transform:translateX(1.5em); } + .enter-slide-left { opacity:0; transform:translateX(-1.5em); } + .enter-slide-right { opacity:0; transform:translateX(1.5em); } + .enter-slide-up { opacity:0; transform:translateY(-1em); } + .enter-slide-down { opacity:0; transform:translateY(1em); } .enter-scale { opacity:0; transform:scale(.92); } .enter-active { opacity:1; transform:none; } .leave-slide-left.leave-active { transform:translateX(-1.5em); } + .leave-slide-right.leave-active { transform:translateX(1.5em); } + .leave-slide-up.leave-active { transform:translateY(-1em); } .leave-slide-down.leave-active { transform:translateY(1em); } + .leave-scale.leave-active { transform:scale(.92); } ${chatCustomCss} `; const list = document.createElement("div"); @@ -307,24 +382,28 @@ shadow.append(style, list); let state = chatStates.get(module.id); - if (!state) state = { messages: [] }; + if (!state) state = { messages: [], previewIndex: 0 }; state.values = values; state.list = list; + state.editor = Boolean(options.editor); if (options.editor && !state.messages.length) { - state.messages = [ - { id: `${module.id}-preview-1`, platform: "twitch", text: "This is how a live chat message will look.", timestamp: Date.now() - 60000, channel: { name: "#stream" }, author: { name: "CozyViewer", color: "#f472b6", badges: [{ label: "Sub", image: null }] } }, - { id: `${module.id}-preview-2`, platform: "youtube", text: "Messages from your selected platforms appear together!", timestamp: Date.now(), channel: { name: "Live chat" }, author: { name: "Lumi Fan", color: null, badges: [] } } - ]; + const initialCount = Math.max(1, Math.min(Number(values.maxMessages || 8), 5)); + for (let index = 0; index < initialCount; index += 1) state.messages.push(nextPreviewMessage(module.id, state)); } state.messages = state.messages.slice(-Number(values.maxMessages || 8)); - const display = values.newestAt === "top" ? [...state.messages].reverse() : state.messages; + const acceptedMessages = state.messages.filter((message) => message.preview + ? (values.platforms || []).includes(message.platform) + : chatAccepts(values, message)); + const display = values.newestAt === "top" ? [...acceptedMessages].reverse() : acceptedMessages; for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false })); chatStates.set(module.id, state); + if (options.editor) schedulePreviewChat(module.id, state); + else clearTimeout(state.previewTimer); return root; } function handleChatMessage(root, payload) { - if (!payload?.id || !payload?.platform || !payload?.text) return 0; + if (!payload?.id || !payload?.platform || (!payload?.text && !payload?.media?.length)) return 0; let accepted = 0; root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => { const moduleId = box.dataset.moduleId; diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index 234d969..57e7d5e 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -122,7 +122,7 @@ - + @@ -140,8 +140,8 @@ - - + + diff --git a/update-manifest.json b/update-manifest.json index 880e920..66da0f5 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.15", + "version": "0.2.16", "channel": "stable", "released_at": "2026-07-19", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", + "migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -205,6 +205,18 @@ ], "rollback_safe": true, "migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." + }, + { + "version": "0.2.16", + "channel": "stable", + "released_at": "2026-07-19", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." } ] }