Refine overlay chat previews and media
This commit is contained in:
parent
97e6615183
commit
f335bbe24a
@ -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.
|
||||
|
||||
5
TODO.md
5
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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.2.15
|
||||
Version: 0.2.16
|
||||
## Routes
|
||||
- GET /api/events
|
||||
- POST /api/destructive-confirmations
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.16",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -19,7 +19,8 @@ for (const file of [
|
||||
"overlays.js",
|
||||
"overlay-permissions.js",
|
||||
"overlay-connectors.js",
|
||||
"overlay-chat.js"
|
||||
"overlay-chat.js",
|
||||
"discord-chat-content.js"
|
||||
]) {
|
||||
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
||||
}
|
||||
@ -161,6 +162,8 @@ try {
|
||||
assert.strictEqual(chatConfig.background, "#0000");
|
||||
assert.strictEqual(chatConfig.inAnimation, "scale");
|
||||
assert(chatConfig.customCss.includes(".message"));
|
||||
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).inAnimation, "slide-right");
|
||||
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).outAnimation, "slide-up");
|
||||
assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]);
|
||||
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
|
||||
url: "https://example.com/widget",
|
||||
@ -212,6 +215,25 @@ try {
|
||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
|
||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
|
||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
|
||||
const mediaOnlyChat = overlayChat.normalizeChatMessage({
|
||||
id: "message-gif",
|
||||
platform: "discord",
|
||||
text: "",
|
||||
media: [{ url: "https://cdn.example/dance.gif", type: "image", alt: "Dance" }],
|
||||
author: { id: "discord-viewer", name: "Discord viewer" }
|
||||
});
|
||||
assert.strictEqual(mediaOnlyChat.media[0].url, "https://cdn.example/dance.gif");
|
||||
assert.strictEqual(mediaOnlyChat.text, "");
|
||||
const { discordOverlayContent } = require(path.join(serviceDir, "discord-chat-content.js"));
|
||||
const discordContent = discordOverlayContent({
|
||||
content: "Hello <a:party:123456> https://cdn.example/dance.gif",
|
||||
attachments: new Map(),
|
||||
embeds: [],
|
||||
stickers: new Map()
|
||||
});
|
||||
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
||||
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
||||
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
|
||||
overlayChat.publishOverlayChatMessage(normalizedChat);
|
||||
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
|
||||
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
|
||||
@ -291,6 +313,7 @@ try {
|
||||
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
||||
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
||||
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
|
||||
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
|
||||
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
||||
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
||||
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
||||
@ -299,6 +322,10 @@ try {
|
||||
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
||||
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
||||
assert(rendererScript.includes("/icons/badges/"));
|
||||
assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat"));
|
||||
assert(rendererScript.includes("width:fit-content") && rendererScript.includes("align-self:${alignment}"));
|
||||
assert(rendererScript.includes("enter-slide-right") && rendererScript.includes("leave-slide-up"));
|
||||
assert(rendererScript.includes('mediaData.type === "video"') && rendererScript.includes('className = "media-row"'));
|
||||
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
|
||||
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
|
||||
assert(rendererScript.includes("values.renderUrl || values.url"));
|
||||
@ -323,7 +350,8 @@ try {
|
||||
assert.strictEqual(obsLayout.scaleX, 1);
|
||||
assert.strictEqual(obsLayout.scaleY, 1);
|
||||
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
|
||||
assert(twitchBadgeScript.includes("api.twitch.tv/helix/chat/badges") && twitchBadgeScript.includes("image_url_2x"));
|
||||
assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x"));
|
||||
assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}"));
|
||||
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
||||
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
||||
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
59
src/services/discord-chat-content.js
Normal file
59
src/services/discord-chat-content.js
Normal file
@ -0,0 +1,59 @@
|
||||
function collectionValues(collection) {
|
||||
if (!collection) return [];
|
||||
if (Array.isArray(collection)) return collection;
|
||||
if (typeof collection.values === "function") return [...collection.values()];
|
||||
return Object.values(collection);
|
||||
}
|
||||
|
||||
function discordOverlayContent(message) {
|
||||
let text = String(message?.content || "");
|
||||
const media = [];
|
||||
const mediaUrls = new Set();
|
||||
const removedLinks = new Set();
|
||||
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => {
|
||||
const clean = String(url || "").trim();
|
||||
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
||||
mediaUrls.add(clean);
|
||||
media.push({ url: clean, type: type === "video" ? "video" : "image", alt });
|
||||
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
|
||||
};
|
||||
|
||||
for (const attachment of collectionValues(message?.attachments)) {
|
||||
const url = attachment?.url || attachment?.proxyURL;
|
||||
const contentType = String(attachment?.contentType || attachment?.content_type || "").toLowerCase();
|
||||
if (contentType === "image/gif" || /\.gif(?:$|\?)/i.test(String(url || ""))) {
|
||||
addMedia(url, "image", attachment?.name || "Animated image");
|
||||
}
|
||||
}
|
||||
for (const embed of collectionValues(message?.embeds)) {
|
||||
const sourceUrl = String(embed?.url || "");
|
||||
const embedType = String(embed?.type || "").toLowerCase();
|
||||
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
||||
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
|
||||
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
|
||||
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl);
|
||||
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
|
||||
}
|
||||
for (const sticker of collectionValues(message?.stickers)) {
|
||||
addMedia(sticker?.url, "image", sticker?.name || "Sticker");
|
||||
}
|
||||
for (const match of text.matchAll(/https?:\/\/[^\s<>]+\.gif(?:\?[^\s<>]*)?/gi)) {
|
||||
addMedia(match[0], "image", "Animated image", match[0]);
|
||||
}
|
||||
for (const link of removedLinks) text = text.split(link).join("");
|
||||
text = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
|
||||
|
||||
const emotes = [];
|
||||
for (const match of text.matchAll(/<(a?):([a-zA-Z0-9_]+):(\d+)>/g)) {
|
||||
const animated = match[1] === "a";
|
||||
emotes.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length - 1,
|
||||
image: `https://cdn.discordapp.com/emojis/${match[3]}.${animated ? "gif" : "webp"}?size=96&quality=lossless`,
|
||||
label: `:${match[2]}:`
|
||||
});
|
||||
}
|
||||
return { text, emotes, media };
|
||||
}
|
||||
|
||||
module.exports = { discordOverlayContent };
|
||||
@ -9,6 +9,7 @@ const { incrementMessages } = require("./stats");
|
||||
const { ensureUserForIdentity } = require("./users");
|
||||
const { createLogger } = require("./logger");
|
||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||
const { discordOverlayContent } = require("./discord-chat-content");
|
||||
|
||||
const discordLog = createLogger("platform:discord", { category: "integration" });
|
||||
|
||||
@ -53,6 +54,7 @@ async function startBot({ commandRouter } = {}) {
|
||||
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (!message.guild) return;
|
||||
const overlayContent = discordOverlayContent(message);
|
||||
const displayName =
|
||||
message.author.globalName || message.author.username || message.author.tag;
|
||||
let avatarUrl = null;
|
||||
@ -72,7 +74,9 @@ async function startBot({ commandRouter } = {}) {
|
||||
publishOverlayChatMessage({
|
||||
id: message.id,
|
||||
platform: "discord",
|
||||
text: message.content,
|
||||
text: overlayContent.text,
|
||||
emotes: overlayContent.emotes,
|
||||
media: overlayContent.media,
|
||||
timestamp: message.createdTimestamp,
|
||||
channel: {
|
||||
id: message.channel?.id,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
1
src/web/public/icons/avatars/preview-cyan.svg
Normal file
1
src/web/public/icons/avatars/preview-cyan.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#164e63"/><circle cx="32" cy="24" r="12" fill="#cffafe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#22d3ee"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-gold.svg
Normal file
1
src/web/public/icons/avatars/preview-gold.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#713f12"/><circle cx="32" cy="24" r="12" fill="#fef3c7"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fbbf24"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-pink.svg
Normal file
1
src/web/public/icons/avatars/preview-pink.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#831843"/><circle cx="32" cy="24" r="12" fill="#f9a8d4"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#f472b6"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-purple.svg
Normal file
1
src/web/public/icons/avatars/preview-purple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#4c1d95"/><circle cx="32" cy="24" r="12" fill="#ddd6fe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#a78bfa"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-red.svg
Normal file
1
src/web/public/icons/avatars/preview-red.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#881337"/><circle cx="32" cy="24" r="12" fill="#fecdd3"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fb7185"/></svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -225,23 +225,47 @@
|
||||
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
meta.appendChild(timestamp);
|
||||
}
|
||||
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 >= payload.text.length) continue;
|
||||
text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start)));
|
||||
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 = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote";
|
||||
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(payload.text.length, emoteData.end + 1);
|
||||
cursor = Math.min(messageText.length, emoteData.end + 1);
|
||||
}
|
||||
text.append(document.createTextNode(payload.text.slice(cursor)));
|
||||
content.append(meta, text);
|
||||
text.append(document.createTextNode(messageText.slice(cursor)));
|
||||
content.appendChild(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;
|
||||
|
||||
@ -122,7 +122,7 @@
|
||||
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
|
||||
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
|
||||
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>Text alignment<select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>Alignment <button type="button" class="overlay-help" data-tooltip="Aligns each complete message, including its avatar, service icon, badges, name, and text.">?</button><select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label class="full">Font<input name="chat_font_family" value="<%= value(config,"fontFamily","system-ui, sans-serif") %>" maxlength="300" placeholder="system-ui, sans-serif" /></label>
|
||||
<label>Text size<input type="number" name="font_size" min="8" max="160" value="<%= value(config,"fontSize",32) %>" /></label>
|
||||
<label>Weight<select name="font_weight"><% [[400,"Regular"],[600,"Semi-bold"],[700,"Bold"],[900,"Heavy"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"fontWeight",600)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
@ -140,8 +140,8 @@
|
||||
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label>
|
||||
<label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
|
||||
</div></fieldset>
|
||||
<label>New-message animation<select name="chat_in_animation"><% [["slide-up","Slide up"],["slide-left","Slide in"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>Old-message animation<select name="chat_out_animation"><% [["fade","Fade out"],["slide-left","Slide out"],["slide-down","Slide down"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>New-message animation<select name="chat_in_animation"><% [["slide-left","Slide in from left"],["slide-right","Slide in from right"],["slide-up","Slide in from above"],["slide-down","Slide in from below"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>Old-message animation<select name="chat_out_animation"><% [["slide-left","Slide out to left"],["slide-right","Slide out to right"],["slide-up","Slide out upward"],["slide-down","Slide out downward"],["fade","Fade out"],["scale","Shrink out"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||
<label>Animation speed <span class="hint">(milliseconds)</span><input type="number" name="chat_animation_duration_ms" min="0" max="3000" step="50" value="<%= value(config,"animationDurationMs",350) %>" /></label>
|
||||
<label class="full overlay-css-field">Custom chat CSS <button type="button" class="overlay-help" data-tooltip="Advanced: style .chat, .message, .avatar, .content, .meta, .platform, .author, .badge, .timestamp, and .text.">?</button><textarea name="chat_custom_css" rows="6" maxlength="12000" placeholder=".message { border: 1px solid rgba(255,255,255,.2); }"><%= value(config,"customCss","") %></textarea><small class="hint">Your CSS is isolated to this chat source and is applied after the visual settings above.</small></label>
|
||||
</div>
|
||||
|
||||
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user