599 lines
29 KiB
JavaScript
599 lines
29 KiB
JavaScript
(() => {
|
|
const chatStates = new Map();
|
|
const anchors = {
|
|
"top-left": [0, 0],
|
|
"top-center": [-50, 0],
|
|
"top-right": [-100, 0],
|
|
"center-left": [0, -50],
|
|
center: [-50, -50],
|
|
"center-right": [-100, -50],
|
|
"bottom-left": [0, -100],
|
|
"bottom-center": [-50, -100],
|
|
"bottom-right": [-100, -100]
|
|
};
|
|
|
|
function finite(value, fallback) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : fallback;
|
|
}
|
|
|
|
function applyBox(element, config) {
|
|
const [translateX, translateY] = anchors[config.anchor] || anchors["top-left"];
|
|
element.style.left = `${finite(config.x, 0)}%`;
|
|
element.style.top = `${finite(config.y, 0)}%`;
|
|
element.style.width = `${finite(config.width, 100)}%`;
|
|
element.style.height = `${finite(config.height, 100)}%`;
|
|
element.style.opacity = `${finite(config.opacity, 1)}`;
|
|
element.style.transform = `translate(${translateX}%, ${translateY}%) translateZ(0)`;
|
|
}
|
|
|
|
function shrinkText(box, content, preferredSize, minimumSize, scale) {
|
|
let size = preferredSize;
|
|
const fit = () => {
|
|
content.style.fontSize = `${size * scale}px`;
|
|
if (size <= minimumSize || (content.scrollWidth <= box.clientWidth && content.scrollHeight <= box.clientHeight)) return;
|
|
size = Math.max(minimumSize, size - Math.max(1, preferredSize / 24));
|
|
requestAnimationFrame(fit);
|
|
};
|
|
requestAnimationFrame(fit);
|
|
}
|
|
|
|
function restartMediaElement(media) {
|
|
if (!media) return false;
|
|
media.dispatchEvent(new CustomEvent("lumi-media-restart"));
|
|
const startAt = Math.max(0, finite(media.dataset.startAt, 0));
|
|
try { media.currentTime = startAt; } catch {}
|
|
const playback = media.play?.();
|
|
playback?.catch?.(() => {});
|
|
return true;
|
|
}
|
|
|
|
function buildMedia(module, values, options, renderType) {
|
|
const media = document.createElement(renderType);
|
|
media.className = `lumi-overlay-${renderType}`;
|
|
media.src = values.url;
|
|
media.dataset.originalUrl = values.url;
|
|
media.dataset.moduleId = module.id;
|
|
media.dataset.startAt = `${finite(values.startAt, 0)}`;
|
|
media.preload = "auto";
|
|
media.playsInline = true;
|
|
media.loop = values.playBehavior === "loop";
|
|
media.controls = false;
|
|
media.muted = values.muted === true;
|
|
media.volume = Math.min(1, Math.max(0, finite(values.volume, 1)));
|
|
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
|
|
media.autoplay = !options.editor && values.playBehavior !== "manual";
|
|
media.referrerPolicy = "no-referrer";
|
|
media.style.pointerEvents = "none";
|
|
if (renderType === "video") {
|
|
media.style.objectFit = values.fit || "contain";
|
|
media.hidden = true;
|
|
media.dataset.playbackState = "waiting";
|
|
const show = () => {
|
|
media.hidden = false;
|
|
media.dataset.playbackState = "playing";
|
|
};
|
|
const hide = () => {
|
|
media.hidden = true;
|
|
media.dataset.playbackState = "stopped";
|
|
};
|
|
media.addEventListener("playing", show);
|
|
for (const eventName of ["ended", "error", "abort", "emptied"]) media.addEventListener(eventName, hide);
|
|
media.addEventListener("lumi-media-stop", hide);
|
|
media.addEventListener("lumi-media-restart", hide);
|
|
} else {
|
|
media.hidden = true;
|
|
media.setAttribute("aria-hidden", "true");
|
|
}
|
|
media.addEventListener("loadedmetadata", () => {
|
|
const startAt = Math.max(0, finite(values.startAt, 0));
|
|
if (startAt && startAt < finite(media.duration, Infinity)) {
|
|
try { media.currentTime = startAt; } catch {}
|
|
}
|
|
media.playbackRate = Math.min(4, Math.max(0.25, finite(values.playbackRate, 1)));
|
|
if (media.autoplay) media.play().catch(() => {});
|
|
}, { once: true });
|
|
return media;
|
|
}
|
|
|
|
function webFrameLayout(values) {
|
|
const left = Math.min(95, Math.max(0, finite(values.cropLeft, 0)));
|
|
const right = Math.min(95, Math.max(0, finite(values.cropRight, 0)));
|
|
const top = Math.min(95, Math.max(0, finite(values.cropTop, 0)));
|
|
const bottom = Math.min(95, Math.max(0, finite(values.cropBottom, 0)));
|
|
const zoom = Math.min(5, Math.max(0.1, finite(values.zoom, 1)));
|
|
const visibleWidth = Math.max(5, 100 - left - right);
|
|
const visibleHeight = Math.max(5, 100 - top - bottom);
|
|
return {
|
|
width: (10000 * zoom) / visibleWidth,
|
|
height: (10000 * zoom) / visibleHeight,
|
|
left: (-100 * left * zoom) / visibleWidth,
|
|
top: (-100 * top * zoom) / visibleHeight
|
|
};
|
|
}
|
|
|
|
function buildWebsite(module, values, options) {
|
|
const frame = document.createElement("iframe");
|
|
const sourceUrl = values.renderUrl || values.url;
|
|
const injectedDocument = Boolean(values.renderUrl);
|
|
frame.className = "lumi-overlay-frame";
|
|
frame.src = sourceUrl;
|
|
frame.dataset.originalUrl = sourceUrl;
|
|
frame.dataset.moduleId = module.id;
|
|
let sandbox = "allow-scripts allow-forms allow-popups allow-presentation";
|
|
try {
|
|
if (!injectedDocument && new URL(sourceUrl, window.location.href).origin !== window.location.origin) sandbox += " allow-same-origin";
|
|
} catch {}
|
|
frame.sandbox = sandbox;
|
|
frame.referrerPolicy = "no-referrer";
|
|
frame.setAttribute("allow", "autoplay; fullscreen");
|
|
frame.setAttribute("allowtransparency", "true");
|
|
frame.style.pointerEvents = options.editor ? "none" : (values.allowPointerEvents ? "auto" : "none");
|
|
|
|
const layout = webFrameLayout(values);
|
|
const root = document.createElement("div");
|
|
root.className = "lumi-overlay-web-root";
|
|
const shadow = root.attachShadow({ mode: "open" });
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; background-color: rgba(0, 0, 0, 0); }
|
|
.lumi-overlay-frame {
|
|
position: absolute; display: block; border: 0; background-color: rgba(0, 0, 0, 0);
|
|
width: ${layout.width}%; height: ${layout.height}%;
|
|
left: ${layout.left}%; top: ${layout.top}%;
|
|
}
|
|
`;
|
|
shadow.append(style, frame);
|
|
return root;
|
|
}
|
|
|
|
function normalizedChannel(value) {
|
|
return String(value || "").trim().toLowerCase().replace(/^#/, "");
|
|
}
|
|
|
|
function chatAccepts(values, payload) {
|
|
if (!(values.platforms || []).includes(payload.platform) || chatUserBlocked(values, payload)) return false;
|
|
const filters = Array.isArray(values.channels) ? values.channels : [];
|
|
if (!filters.length) return true;
|
|
const aliases = [payload.channel?.id, payload.channel?.name, payload.channel?.key]
|
|
.map(normalizedChannel)
|
|
.filter(Boolean);
|
|
return filters.some((entry) => {
|
|
const raw = String(entry || "").trim();
|
|
const separator = raw.indexOf(":");
|
|
const platform = separator > 0 ? raw.slice(0, separator).trim().toLowerCase() : "";
|
|
const channel = normalizedChannel(separator > 0 ? raw.slice(separator + 1) : raw);
|
|
return (!platform || platform === payload.platform) && (channel === "*" || aliases.includes(channel));
|
|
});
|
|
}
|
|
|
|
function normalizedIdentity(value) {
|
|
return String(value || "").trim().toLowerCase().replace(/^@/, "");
|
|
}
|
|
|
|
function chatUserBlocked(values, payload) {
|
|
return (values.blockedUsers || []).some((entry) => {
|
|
const raw = String(entry || "").trim();
|
|
const separator = raw.indexOf(":");
|
|
if (separator < 1) return false;
|
|
const platform = raw.slice(0, separator).toLowerCase();
|
|
const blocked = raw.slice(separator + 1).split("|").map(normalizedIdentity).filter(Boolean);
|
|
const aliases = platform === "lumi"
|
|
? [payload.author?.lumi?.id, payload.author?.lumi?.username]
|
|
: platform === payload.platform
|
|
? [payload.author?.id, payload.author?.username, payload.author?.name]
|
|
: [];
|
|
const normalizedAliases = aliases.map(normalizedIdentity).filter(Boolean);
|
|
return blocked.some((value) => normalizedAliases.includes(value));
|
|
});
|
|
}
|
|
|
|
function chatMessageElement(payload, values, { animate = true } = {}) {
|
|
const row = document.createElement("article");
|
|
row.className = `message platform-${payload.platform}`;
|
|
row.dataset.messageId = payload.id;
|
|
row.dataset.platform = payload.platform;
|
|
|
|
if (values.showAvatars && payload.author?.avatar) {
|
|
const avatar = document.createElement("img");
|
|
avatar.className = "avatar";
|
|
avatar.src = payload.author.avatar;
|
|
avatar.alt = "";
|
|
avatar.referrerPolicy = "no-referrer";
|
|
avatar.addEventListener("error", () => avatar.remove(), { once: true });
|
|
row.appendChild(avatar);
|
|
}
|
|
|
|
const content = document.createElement("div");
|
|
content.className = "content";
|
|
const meta = document.createElement("div");
|
|
meta.className = "meta";
|
|
if (values.showPlatform) {
|
|
const platform = document.createElement("img");
|
|
platform.className = "platform";
|
|
platform.src = `/icons/platforms/${["twitch", "youtube", "discord"].includes(payload.platform) ? payload.platform : "lumi"}.svg`;
|
|
platform.alt = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1);
|
|
platform.title = platform.alt;
|
|
meta.appendChild(platform);
|
|
}
|
|
const author = document.createElement("span");
|
|
author.className = "author";
|
|
author.textContent = payload.author?.name || "Viewer";
|
|
author.style.color = payload.author?.color || values.usernameColor || "#67e8f9";
|
|
meta.appendChild(author);
|
|
if (values.showBadges) {
|
|
for (const badgeData of payload.author?.badges || []) {
|
|
const badge = document.createElement("img");
|
|
badge.className = "badge";
|
|
if (badgeData.image) {
|
|
badge.src = badgeData.image;
|
|
} else {
|
|
const label = String(badgeData.label || "").toLowerCase();
|
|
const kind = /owner|broadcaster/.test(label) ? "owner" : /moderator|mod|staff/.test(label) ? "moderator" : /member|subscriber|sub|vip/.test(label) ? "member" : "generic";
|
|
badge.src = `/icons/badges/${kind}.svg`;
|
|
}
|
|
badge.alt = badgeData.label || "Badge";
|
|
badge.title = badge.alt;
|
|
badge.referrerPolicy = "no-referrer";
|
|
meta.appendChild(badge);
|
|
}
|
|
}
|
|
if (values.showTimestamp) {
|
|
const timestamp = document.createElement("time");
|
|
timestamp.className = "timestamp";
|
|
timestamp.dateTime = new Date(payload.timestamp).toISOString();
|
|
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
meta.appendChild(timestamp);
|
|
}
|
|
const 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);
|
|
}
|
|
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) {
|
|
row.classList.add("enter", `enter-${values.inAnimation}`);
|
|
requestAnimationFrame(() => requestAnimationFrame(() => row.classList.add("enter-active")));
|
|
setTimeout(() => row.classList.remove("enter", "enter-active", `enter-${values.inAnimation}`), Number(values.animationDurationMs));
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function removeChatMessage(moduleId, messageId) {
|
|
const state = chatStates.get(moduleId);
|
|
if (!state) return;
|
|
state.messages = state.messages.filter((message) => message.id !== messageId);
|
|
const row = state.list?.querySelector?.(`[data-message-id="${CSS.escape(messageId)}"]`);
|
|
if (!row) return;
|
|
const values = state.values || {};
|
|
if (values.outAnimation !== "none" && Number(values.animationDurationMs) > 0) {
|
|
row.classList.add("leave", `leave-${values.outAnimation}`);
|
|
requestAnimationFrame(() => row.classList.add("leave-active"));
|
|
setTimeout(() => row.remove(), Number(values.animationDurationMs));
|
|
} else row.remove();
|
|
}
|
|
|
|
function scheduleChatRemoval(moduleId, payload, values) {
|
|
if (!(Number(values.messageTimeoutSeconds) > 0)) return;
|
|
const remaining = Math.max(0, payload.timestamp + Number(values.messageTimeoutSeconds) * 1000 - Date.now());
|
|
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
|
|
}
|
|
|
|
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";
|
|
const dock = Boolean(options.chatDock);
|
|
style.textContent = `
|
|
:host { display:block; width:100%; ${dock ? "min-height:100%; height:auto; overflow:visible;" : "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%; ${dock ? "min-height:100vh; height:auto; overflow:visible;" : "height:100%; overflow:hidden;"} 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; 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; 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; 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-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}
|
|
${dock ? ":host { width:100% !important; min-height:100% !important; height:auto !important; overflow:visible !important; } .chat { width:100% !important; min-height:100vh !important; height:auto !important; overflow:visible !important; } .message, .message * { animation:none !important; transition:none !important; } .message { transform:none !important; opacity:1 !important; }" : ""}
|
|
`;
|
|
const list = document.createElement("div");
|
|
list.className = "chat";
|
|
list.setAttribute("role", "log");
|
|
list.setAttribute("aria-live", "off");
|
|
shadow.append(style, list);
|
|
|
|
let state = chatStates.get(module.id);
|
|
if (!state) state = { messages: [], previewIndex: 0 };
|
|
state.values = values;
|
|
state.list = list;
|
|
state.editor = Boolean(options.editor);
|
|
if (options.editor && !state.messages.length) {
|
|
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 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 && !payload?.media?.length)) return 0;
|
|
let accepted = 0;
|
|
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
|
|
const moduleId = box.dataset.moduleId;
|
|
const state = chatStates.get(moduleId);
|
|
if (!state?.list || !chatAccepts(state.values, payload) || state.messages.some((message) => message.id === payload.id)) return;
|
|
state.messages.push(payload);
|
|
const row = chatMessageElement(payload, state.values);
|
|
if (state.values.newestAt === "top") state.list.prepend(row);
|
|
else state.list.appendChild(row);
|
|
while (state.messages.length > Number(state.values.maxMessages || 8)) removeChatMessage(moduleId, state.messages[0].id);
|
|
scheduleChatRemoval(moduleId, payload, state.values);
|
|
accepted += 1;
|
|
});
|
|
return accepted;
|
|
}
|
|
|
|
function findSourceContent(root, moduleId, selector) {
|
|
const box = root?.querySelector?.(`.lumi-overlay-module[data-module-id="${CSS.escape(moduleId)}"]`);
|
|
return box?.querySelector?.(selector)
|
|
|| box?.querySelector?.(".lumi-overlay-web-root")?.shadowRoot?.querySelector?.(selector)
|
|
|| root?.querySelector?.(`.lumi-overlay-managed-media[data-module-id="${CSS.escape(moduleId)}"]${selector === "video, audio" ? "" : selector}`)
|
|
|| null;
|
|
}
|
|
|
|
function buildModule(module, options = {}) {
|
|
const renderType = module.renderType || module.type;
|
|
const values = module.config || {};
|
|
if (renderType === "audio") return null;
|
|
const scale = finite(options.scale, 1);
|
|
const box = document.createElement("section");
|
|
box.className = `lumi-overlay-module lumi-overlay-module-${renderType}`;
|
|
if (options.editor) box.classList.add("is-editor-source");
|
|
box.dataset.moduleId = module.id;
|
|
box.dataset.moduleType = module.type;
|
|
box.dataset.renderType = renderType;
|
|
box.dataset.moduleName = module.name || "Source";
|
|
applyBox(box, values);
|
|
if (values.eventOnly && !options.editor) {
|
|
box.hidden = true;
|
|
box.dataset.eventOnly = "true";
|
|
}
|
|
|
|
if (renderType === "text") {
|
|
box.classList.add("lumi-overlay-text", `overflow-${values.overflow || "wrap"}`);
|
|
const content = document.createElement("span");
|
|
content.className = "lumi-overlay-text-content";
|
|
content.textContent = values.text || "";
|
|
const horizontal = values.horizontalAlign || values.align || "left";
|
|
const vertical = values.verticalAlign || "center";
|
|
box.style.textAlign = horizontal;
|
|
box.style.justifyContent = horizontal === "center" ? "center" : horizontal === "right" ? "flex-end" : "flex-start";
|
|
box.style.alignItems = vertical === "center" ? "center" : vertical === "bottom" ? "flex-end" : "flex-start";
|
|
box.style.color = values.color || "#ffffff";
|
|
box.style.background = values.background || "transparent";
|
|
box.style.padding = `${finite(values.padding, 0) * scale}px`;
|
|
content.style.fontWeight = `${finite(values.fontWeight, 700)}`;
|
|
content.style.fontSize = `${finite(values.fontSize, 48) * scale}px`;
|
|
box.appendChild(content);
|
|
if (values.overflow === "shrink") shrinkText(box, content, finite(values.fontSize, 48), 8, scale);
|
|
} else if (renderType === "chat") {
|
|
box.dataset.renderType = "chat";
|
|
box.appendChild(buildChat(module, values, options));
|
|
} else if (renderType === "image" && values.url) {
|
|
const image = document.createElement("img");
|
|
image.className = "lumi-overlay-image";
|
|
image.alt = "";
|
|
image.src = values.url;
|
|
image.referrerPolicy = "no-referrer";
|
|
image.style.objectFit = values.fit || "contain";
|
|
box.appendChild(image);
|
|
} else if (renderType === "video" && values.url) {
|
|
const media = buildMedia(module, values, options, renderType);
|
|
box.appendChild(media);
|
|
} else if (renderType === "web" && values.url) {
|
|
box.appendChild(buildWebsite(module, values, options));
|
|
}
|
|
|
|
if (options.editor) {
|
|
const tag = document.createElement("span");
|
|
tag.className = "overlay-canvas-source-label";
|
|
tag.textContent = module.name || "Source";
|
|
box.appendChild(tag);
|
|
for (const direction of ["nw", "n", "ne", "e", "se", "s", "sw", "w"]) {
|
|
const handle = document.createElement("button");
|
|
handle.type = "button";
|
|
handle.tabIndex = -1;
|
|
handle.className = `overlay-resize-handle handle-${direction}`;
|
|
handle.dataset.resizeHandle = direction;
|
|
handle.setAttribute("aria-label", `Resize ${module.name || "source"} from ${direction}`);
|
|
box.appendChild(handle);
|
|
}
|
|
}
|
|
return box;
|
|
}
|
|
|
|
function render(root, state, options = {}) {
|
|
const canvas = options.canvas || state?.canvas || { width: 1920, height: 1080 };
|
|
const layout = stageLayout(root, canvas, options.scale);
|
|
const stage = document.createElement("div");
|
|
stage.className = "lumi-overlay-stage";
|
|
stage.style.width = `${layout.width}px`;
|
|
stage.style.height = `${layout.height}px`;
|
|
stage.style.transform = `scale(${layout.scaleX}, ${layout.scaleY})`;
|
|
if (options.editor) {
|
|
const editorScale = Math.max(0.01, Math.min(layout.scaleX, layout.scaleY));
|
|
const handleSize = window.matchMedia?.("(max-width: 700px)").matches ? 14 : 10;
|
|
stage.style.setProperty("--lumi-editor-handle-size", `${handleSize / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-outline-size", `${2 / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-label-font-size", `${11.2 / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-label-max-width", `${192 / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-label-padding-y", `${3.2 / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-label-padding-x", `${6.4 / editorScale}px`);
|
|
stage.style.setProperty("--lumi-editor-label-radius", `${4 / editorScale}px`);
|
|
}
|
|
const elements = new Map();
|
|
if (state?.enabled !== false && state?.scene) {
|
|
for (const module of state.scene.modules || []) {
|
|
if (module.enabled === false) continue;
|
|
const renderType = module.renderType || module.type;
|
|
if (renderType === "audio") {
|
|
if (module.config?.url) {
|
|
const media = buildMedia(module, module.config, options, "audio");
|
|
media.classList.add("lumi-overlay-managed-media");
|
|
stage.appendChild(media);
|
|
}
|
|
continue;
|
|
}
|
|
const element = buildModule(module, { ...options, canvas, scale: 1 });
|
|
if (!element) continue;
|
|
elements.set(module.id, element);
|
|
stage.appendChild(element);
|
|
}
|
|
}
|
|
root.replaceChildren(stage);
|
|
return elements;
|
|
}
|
|
|
|
function renderChatDock(root, module) {
|
|
if (!root) return null;
|
|
if (!module || (module.renderType || module.type) !== "chat") {
|
|
root.replaceChildren();
|
|
return null;
|
|
}
|
|
const wrapper = document.createElement("section");
|
|
wrapper.className = "lumi-overlay-module lumi-overlay-chat-dock";
|
|
wrapper.dataset.moduleId = module.id;
|
|
wrapper.dataset.moduleType = module.type;
|
|
wrapper.dataset.renderType = "chat";
|
|
wrapper.appendChild(buildChat({
|
|
...module,
|
|
config: { ...(module.config || {}), inAnimation: "none", outAnimation: "none", animationDurationMs: 0 }
|
|
}, { chatDock: true }));
|
|
root.replaceChildren(wrapper);
|
|
return wrapper;
|
|
}
|
|
|
|
function stageLayout(root, canvas, scaleOverride) {
|
|
const width = Math.max(1, finite(canvas?.width, 1920));
|
|
const height = Math.max(1, finite(canvas?.height, 1080));
|
|
const override = finite(scaleOverride, 0);
|
|
return {
|
|
width,
|
|
height,
|
|
scaleX: override > 0 ? override : (root?.clientWidth ? root.clientWidth / width : 1),
|
|
scaleY: override > 0 ? override : (root?.clientHeight ? root.clientHeight / height : 1)
|
|
};
|
|
}
|
|
|
|
window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, renderChatDock, restartMediaElement, stageLayout };
|
|
})();
|