269 lines
49 KiB
Plaintext
269 lines
49 KiB
Plaintext
<%
|
||
const controlledUrl = canViewSecrets ? `${baseUrl}/overlay/${overlay.public_token}` : "";
|
||
const value = (config, key, fallback = "") => config && config[key] !== undefined ? config[key] : fallback;
|
||
const pickerColor = (input, fallback = "#ffffff") => {
|
||
const normalized = String(input || "").trim();
|
||
if (/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(normalized)) return normalized.slice(0, 7);
|
||
if (/^#[0-9a-f]{3,4}$/i.test(normalized)) return `#${normalized.slice(1, 4).split("").map((digit) => digit + digit).join("")}`;
|
||
return fallback;
|
||
};
|
||
const anchors = [
|
||
["top-left", "Top left"], ["top-center", "Top middle"], ["top-right", "Top right"],
|
||
["center-left", "Middle left"], ["center", "Center"], ["center-right", "Middle right"],
|
||
["bottom-left", "Bottom left"], ["bottom-center", "Bottom middle"], ["bottom-right", "Bottom right"]
|
||
];
|
||
%>
|
||
<%- include("partials/layout-top", { title }) %>
|
||
<link rel="stylesheet" href="/overlay-runtime.css?v=<%= assetVersion %>" />
|
||
<link rel="stylesheet" href="/overlay-management.css?v=<%= assetVersion %>" />
|
||
|
||
<section class="card">
|
||
<%- include("partials/page-header", {
|
||
eyebrow: "Stream overlay",
|
||
pageTitle: overlay.name,
|
||
description: "Arrange what viewers see, switch scenes, and copy the finished overlay into OBS.",
|
||
actions: '<a class="button subtle" href="/admin/overlays">Back to overlays</a>'
|
||
}) %>
|
||
<details class="overlay-settings-disclosure">
|
||
<summary>Overlay name and canvas size</summary>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>" class="form-grid">
|
||
<div class="field"><label>Name</label><input name="name" value="<%= overlay.name %>" required maxlength="120" /></div>
|
||
<div class="field"><label>Shown in OBS</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= overlay.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= overlay.enabled ? "On" : "Off" %></span></label></div>
|
||
<div class="field"><label>Canvas width <button type="button" class="overlay-help" data-tooltip="Use the same base width as OBS, usually 1920.">?</button></label><input type="number" min="240" max="7680" name="canvas_width" value="<%= overlay.canvas_width || 1920 %>" /></div>
|
||
<div class="field"><label>Canvas height <button type="button" class="overlay-help" data-tooltip="Use the same base height as OBS, usually 1080.">?</button></label><input type="number" min="240" max="7680" name="canvas_height" value="<%= overlay.canvas_height || 1080 %>" /></div>
|
||
<div class="field full"><label>Notes</label><textarea name="description" rows="2" maxlength="1000"><%= overlay.description %></textarea></div>
|
||
<div class="form-actions"><button class="button" type="submit">Save overlay settings</button></div>
|
||
</form>
|
||
<div class="overlay-list-actions">
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Make a copy</button></form>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete overlay" data-confirm-text="Permanently delete this overlay, all of its scenes and sources, and its OBS settings?" data-confirm-label="Delete overlay"><button class="button danger" type="submit">Delete overlay</button></form>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<div class="overlay-workspace">
|
||
<div class="overlay-settings-column">
|
||
<section class="card">
|
||
<h2>Use this overlay in OBS</h2>
|
||
<% if (!canViewSecrets) { %>
|
||
<div class="callout">Only administrators can view or copy the private OBS links.</div>
|
||
<% } else { %>
|
||
<p class="hint">Add this as a Browser Source in OBS. It follows the live scene selected below. Keep the link private. If the overlay uses video, audio, or alert sounds, enable <strong>Control audio via OBS</strong> in the Browser Source properties.</p>
|
||
<div class="overlay-url-row"><input value="<%= controlledUrl %>" readonly aria-label="Private OBS link" /><button type="button" class="button subtle" data-copy-value="<%= controlledUrl %>">Copy OBS link</button></div>
|
||
<details class="overlay-browser-source-setup">
|
||
<summary>OBS Browser Source setup</summary>
|
||
<p class="hint">Use Lumi's canvas size for the Browser Source so websites, text, and alerts use the same layout in OBS as they do in this preview.</p>
|
||
<div class="overlay-browser-dimensions" aria-label="OBS Browser Source dimensions"><div><span>Width</span><strong><%= overlay.canvas_width || 1920 %></strong></div><div><span>Height</span><strong><%= overlay.canvas_height || 1080 %></strong></div></div>
|
||
<label class="overlay-copy-label">Custom CSS <button type="button" class="overlay-help" data-tooltip="This is OBS's standard transparent Browser Source CSS. Lumi also applies it to the clean render page automatically.">?</button></label>
|
||
<div class="overlay-url-row"><input value="<%= obsBrowserDefaultCss %>" readonly aria-label="OBS default Custom CSS" /><button type="button" class="button subtle" data-copy-value="<%= obsBrowserDefaultCss %>">Copy CSS</button></div>
|
||
</details>
|
||
<div class="overlay-list-actions">
|
||
<a class="button subtle" href="<%= controlledUrl %>" target="_blank" rel="noreferrer">Open clean preview</a>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/token/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Replace private links" data-confirm-text="Replace every current OBS link for this overlay? You will need to paste the new links into OBS." data-confirm-label="Replace links"><button class="button danger" type="submit">Replace private links</button></form>
|
||
</div>
|
||
<% } %>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/active-scene" class="input-action-form overlay-live-scene-form">
|
||
<div class="field"><label>Live scene <button type="button" class="overlay-help" data-tooltip="The main OBS link shows whichever scene is live here.">?</button></label><select name="scene_id"><% overlay.scenes.forEach((scene) => { %><option value="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "selected" : "" %> <%= !scene.enabled ? "disabled" : "" %>><%= scene.name %><%= scene.enabled ? "" : " (off)" %></option><% }) %></select></div>
|
||
<button class="button" type="submit">Show this scene</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="section-header"><div><h2>Scenes and sources</h2><p class="hint">Scenes are layouts. Sources are the text, images, and websites inside them.</p></div><button type="button" class="button" data-scene-create-open>Add scene</button></div>
|
||
<% if (overlay.scenes.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Scene order</label><div class="overlay-reorder-list" data-reorder-list><% overlay.scenes.forEach((scene) => { %><button type="button" class="button subtle" data-reorder-id="<%= scene.id %>"><%= scene.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /><p class="hint">Select a scene to move it one place to the right.</p></div><div class="form-actions"><button type="submit" class="button subtle">Save scene order</button></div></form><% } %>
|
||
<% overlay.scenes.forEach((scene) => { const fixedUrl = canViewSecrets ? `${controlledUrl}/scene/${scene.public_token}` : ""; %>
|
||
<details class="overlay-scene-card" data-scene-card="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "open" : "" %>>
|
||
<summary><%= scene.name %> · <%= scene.enabled ? "On" : "Off" %><%= scene.id === overlay.active_scene_id ? " · Live" : "" %></summary>
|
||
<div class="overlay-scene-body">
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>" class="form-grid">
|
||
<div class="field"><label>Scene name</label><input name="name" value="<%= scene.name %>" required /></div>
|
||
<div class="field"><label>Matching OBS scene <button type="button" class="overlay-help" data-tooltip="Optional. Enter the exact OBS scene name if you want scene syncing.">?</button></label><input name="obs_scene_name" value="<%= scene.obs_scene_name || '' %>" placeholder="Starting Soon" /></div>
|
||
<div class="field"><label>Available</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= scene.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= scene.enabled ? "On" : "Off" %></span></label></div>
|
||
<div class="form-actions"><button class="button" type="submit">Save scene</button></div>
|
||
</form>
|
||
<% if (canViewSecrets) { %>
|
||
<details class="overlay-private-link"><summary>Scene-only OBS link</summary><p class="hint">This link always shows <strong><%= scene.name %></strong>, even when another scene is live.</p><div class="overlay-url-row"><input value="<%= fixedUrl %>" readonly /><button type="button" class="button subtle" data-copy-value="<%= fixedUrl %>">Copy link</button></div><div class="overlay-list-actions"><a class="button subtle" href="<%= fixedUrl %>" target="_blank" rel="noreferrer">Open clean preview</a><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/token/revoke" class="inline-form" data-confirm-mode="modal" data-confirm-title="Replace scene link" data-confirm-text="Replace this scene-only OBS link? You will need to paste the new link into OBS." data-confirm-label="Replace link"><button class="button danger" type="submit">Replace scene link</button></form></div></details>
|
||
<% } %>
|
||
<div class="overlay-scene-actions">
|
||
<button type="button" class="button subtle" data-edit-scene="<%= scene.id %>">Show on editor canvas</button>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy scene</button></form>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-title="Delete scene" data-confirm-text="Delete this scene and everything inside it? Every overlay must keep at least one scene." data-confirm-label="Delete scene"><button class="button danger" type="submit">Delete scene</button></form>
|
||
</div>
|
||
|
||
<h3 class="lumi-subsection-title">Sources</h3>
|
||
<% if (!scene.modules.length) { %><div class="empty-state">Nothing has been added to this scene yet.</div><% } %>
|
||
<% scene.modules.forEach((module) => { const config = module.config || {}; %>
|
||
<details class="overlay-source-card" data-module-card="<%= module.id %>">
|
||
<summary><span><strong><%= module.name %></strong><small><%= moduleTypes.find((item) => item.id === module.type)?.label || module.type %> · <%= module.enabled ? "Visible" : "Hidden" %></small></span></summary>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>" class="form-grid overlay-source-form" data-module-editor data-module-id="<%= module.id %>">
|
||
<div class="field"><label>Name</label><input name="name" value="<%= module.name %>" required /></div>
|
||
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>" data-render-type="<%= type.renderType || type.id %>" <%= type.id === module.type ? "selected" : "" %>><%= type.label %></option><% }) %></select></div>
|
||
<div class="field"><label>Visible</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= module.enabled ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text"><%= module.enabled ? "Shown" : "Hidden" %></span></label></div>
|
||
<div class="field"><label>Anchor point <button type="button" class="overlay-help" data-tooltip="The anchor is the point held in place when the source is positioned or resized.">?</button></label><select name="anchor"><% anchors.forEach(([id, label]) => { %><option value="<%= id %>" <%= value(config, "anchor", "top-left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></div>
|
||
|
||
<div class="field full overlay-conditional-fields" data-module-fields="text">
|
||
<label>Text<textarea name="text" rows="3"><%= value(config, "text") %></textarea></label>
|
||
<label>Text size<input type="number" name="font_size" min="8" max="400" value="<%= value(config, "fontSize", 48) %>" /></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",700)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Horizontal alignment<select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign",value(config,"align","left")) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Vertical alignment<select name="vertical_align"><% [["top","Top"],["center","Center"],["bottom","Bottom"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"verticalAlign","center") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>When text is too large<select name="text_overflow"><% [["wrap","Wrap onto more lines"],["shrink","Shrink until it fits"],["clip","Keep size and cut off extra"],["single-line","Keep on one line"],["scroll","Scroll across like a ticker"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"overflow","wrap") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<div class="overlay-color-field" data-color-field><span>Text color</span><div class="overlay-color-inputs"><input name="color" value="<%= value(config, "color", "#ffffff") %>" pattern="#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})" title="Use #RGB, #RGBA, #RRGGBB, or #RRGGBBAA" aria-label="Text color value" data-color-text /><input type="color" value="<%= pickerColor(value(config, "color", "#ffffff")) %>" aria-label="Choose text color" data-color-picker /></div></div>
|
||
<div class="overlay-color-field" data-color-field><span>Background</span><div class="overlay-color-inputs"><input name="background" value="<%= value(config, "background", "transparent") %>" pattern="(?:transparent|#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8})" title="Use transparent, #RGB, #RGBA, #RRGGBB, or #RRGGBBAA" aria-label="Background color value" data-color-text /><input type="color" value="<%= pickerColor(value(config, "background", "transparent"), "#000000") %>" aria-label="Choose background color" data-color-picker /></div></div>
|
||
<label>Inner spacing<input type="number" name="padding" min="0" max="200" value="<%= value(config,"padding",0) %>" /></label>
|
||
</div>
|
||
|
||
<div class="field full overlay-conditional-fields overlay-chat-fields" data-module-fields="chat">
|
||
<fieldset class="overlay-fieldset full"><legend>Chats to include <button type="button" class="overlay-help" data-tooltip="Choose any combination. Lumi combines messages from the connected chat services into this one source.">?</button></legend><div class="overlay-check-grid">
|
||
<% [["twitch","Twitch"],["youtube","YouTube"],["discord","Discord"]].forEach(([id,label]) => { %><label><input type="checkbox" name="chat_platform_<%= id %>" <%= (value(config,"platforms",["twitch","youtube","discord"])).includes(id) ? "checked" : "" %> /> <%= label %></label><% }) %>
|
||
</div></fieldset>
|
||
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="3" placeholder="twitch:cozycarnage discord:community-chat"><%= (value(config,"channels",[])).join("\n") %></textarea><small class="hint">Leave empty to include every channel Lumi receives from the selected services. Use one channel name or ID per line; add the service name when needed, such as <code>twitch:channel</code>.</small></label>
|
||
<label class="full">Hidden users (optional)<textarea name="chat_blocked_users" rows="3" placeholder="twitch:some_user|123456 lumi:internal_name|user-id"><%= (value(config,"blockedUsers",[])).join("\n") %></textarea><small class="hint">One rule per line. Use <code>platform:username|user ID</code>. Use <code>lumi:</code> with an internal username or Lumi user ID to hide every linked account belonging to that person.</small></label>
|
||
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
|
||
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
|
||
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>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>
|
||
<label>Line spacing<input type="number" name="chat_line_height" min="0.8" max="3" step="0.05" value="<%= value(config,"lineHeight",1.25) %>" /></label>
|
||
<div class="overlay-color-field" data-color-field><span>Text color</span><div class="overlay-color-inputs"><input name="color" value="<%= value(config,"color","#ffffff") %>" aria-label="Chat text color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"color","#ffffff")) %>" aria-label="Choose chat text color" data-color-picker /></div></div>
|
||
<div class="overlay-color-field" data-color-field><span>Name color</span><div class="overlay-color-inputs"><input name="chat_username_color" value="<%= value(config,"usernameColor","#67e8f9") %>" aria-label="Default username color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"usernameColor","#67e8f9")) %>" aria-label="Choose default username color" data-color-picker /></div></div>
|
||
<div class="overlay-color-field" data-color-field><span>Source background</span><div class="overlay-color-inputs"><input name="background" value="<%= value(config,"background","transparent") %>" aria-label="Chat background color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"background","transparent"),"#000000") %>" aria-label="Choose chat background" data-color-picker /></div></div>
|
||
<div class="overlay-color-field" data-color-field><span>Message background</span><div class="overlay-color-inputs"><input name="chat_message_background" value="<%= value(config,"messageBackground","#0f172bcc") %>" aria-label="Message background color value" data-color-text /><input type="color" value="<%= pickerColor(value(config,"messageBackground","#0f172bcc"),"#0f172b") %>" aria-label="Choose message background" data-color-picker /></div></div>
|
||
<label>Outer spacing<input type="number" name="padding" min="0" max="100" value="<%= value(config,"padding",16) %>" /></label>
|
||
<label>Between messages<input type="number" name="chat_gap" min="0" max="100" value="<%= value(config,"gap",10) %>" /></label>
|
||
<label>Rounded corners<input type="number" name="chat_border_radius" min="0" max="100" value="<%= value(config,"borderRadius",12) %>" /></label>
|
||
<fieldset class="overlay-fieldset full"><legend>Message details</legend><div class="overlay-check-grid">
|
||
<label><input type="checkbox" name="chat_show_avatars" <%= value(config,"showAvatars",true) ? "checked" : "" %> /> Avatars</label>
|
||
<label><input type="checkbox" name="chat_show_badges" <%= value(config,"showBadges",true) ? "checked" : "" %> /> Badges</label>
|
||
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service 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-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>
|
||
|
||
<div class="field full overlay-conditional-fields" data-module-fields="image">
|
||
<label>Image address<input name="url" value="<%= module.type === "image" ? value(config, "url") : "" %>" placeholder="https://example.com/image.png" /></label>
|
||
<label>Fit inside box<select name="fit"><% [["contain","Show the whole image"],["cover","Fill the box and crop"],["fill","Stretch to fill"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"fit","contain") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
</div>
|
||
|
||
<div class="field full overlay-conditional-fields" data-module-fields="video">
|
||
<label>Video address<input name="url" value="<%= module.type === "video" ? value(config, "url") : "" %>" placeholder="https://example.com/clip.webm" /></label>
|
||
<label>Playback<select name="play_behavior"><% [["once","Play once when the scene appears"],["loop","Keep playing on a loop"],["manual","Wait for manual playback"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"playBehavior","once") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Fit inside box<select name="fit"><% [["contain","Show the whole video"],["cover","Fill the box and crop"],["fill","Stretch to fill"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"fit","contain") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="<%= value(config,"volume",1) %>" /></label>
|
||
<label>Start at (seconds)<input type="number" name="start_at" min="0" max="86400" step="0.1" value="<%= value(config,"startAt",0) %>" /></label>
|
||
<label>Playback speed<input type="number" name="playback_rate" min="0.25" max="4" step="0.25" value="<%= value(config,"playbackRate",1) %>" /></label>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Sound</span><label class="switch"><input class="switch-input" type="checkbox" name="muted" <%= value(config,"muted",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Mute this video</span></label></div>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Player controls</span><label class="switch"><input class="switch-input" type="checkbox" name="show_controls" <%= value(config,"showControls",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Show playback controls</span></label></div>
|
||
<div><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Restart playback</button><p class="hint">Enable “Control audio via OBS” on the Lumi Browser Source to mix this sound separately in OBS.</p></div>
|
||
</div>
|
||
|
||
<div class="field full overlay-conditional-fields" data-module-fields="audio">
|
||
<label>Audio address<input name="url" value="<%= module.type === "audio" ? value(config, "url") : "" %>" placeholder="https://example.com/alert.ogg" /></label>
|
||
<label>Playback<select name="play_behavior"><% [["once","Play once when the scene appears"],["loop","Keep playing on a loop"],["manual","Wait for manual playback"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"playBehavior","once") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="<%= value(config,"volume",1) %>" /></label>
|
||
<label>Start at (seconds)<input type="number" name="start_at" min="0" max="86400" step="0.1" value="<%= value(config,"startAt",0) %>" /></label>
|
||
<label>Playback speed<input type="number" name="playback_rate" min="0.25" max="4" step="0.25" value="<%= value(config,"playbackRate",1) %>" /></label>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Player controls</span><label class="switch"><input class="switch-input" type="checkbox" name="show_controls" <%= value(config,"showControls",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Show playback controls</span></label></div>
|
||
<div><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Restart playback</button><p class="hint">Enable “Control audio via OBS” on the Lumi Browser Source to place this sound in the OBS mixer.</p></div>
|
||
</div>
|
||
|
||
<div class="field full overlay-conditional-fields" data-module-fields="web">
|
||
<label>Overlay website address <button type="button" class="overlay-help" data-tooltip="Paste a StreamElements alert link or another browser-overlay URL. Lumi keeps it isolated from your admin login.">?</button><input name="url" value="<%= module.type === "web" ? value(config, "url") : "" %>" placeholder="https://streamelements.com/overlay/..." /></label>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Website CSS</span><label class="switch"><input class="switch-input" type="checkbox" name="inject_page_css" <%= value(config,"injectPageCss",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Apply CSS inside the website</span></label><p class="hint">Recommended. Turn this off only if a website will not load through CSS injection.</p></div>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Transparent page</span><label class="switch"><input class="switch-input" type="checkbox" name="force_transparent_background" <%= value(config,"forceTransparentBackground",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Keep the website background transparent</span></label><p class="hint">Recommended for alerts. Lumi reapplies transparency if the website changes its page after loading.</p></div>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Automatic recovery</span><label class="switch"><input class="switch-input" type="checkbox" name="auto_refresh" <%= value(config,"autoRefresh",false) ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Refresh stale or broken content</span></label></div>
|
||
<div class="overlay-switch-field"><span class="overlay-field-label">Check website replies</span><label class="switch"><input class="switch-input" type="checkbox" name="health_check" <%= value(config,"healthCheck",true) !== false ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Check for errors</span></label></div>
|
||
<label>Refresh every<select name="refresh_interval_seconds"><% [[60,"1 minute"],[300,"5 minutes"],[900,"15 minutes"],[1800,"30 minutes"],[3600,"1 hour"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"refreshIntervalSeconds",300)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||
<label>Retry attempts<select name="retry_limit"><% [0,1,2,3,4,5].forEach((id) => { %><option value="<%= id %>" <%= Number(value(config,"retryLimit",3)) === id ? "selected" : "" %>><%= id %></option><% }) %></select></label>
|
||
<label>Crop top %<input type="number" name="crop_top" min="0" max="95" step="0.1" value="<%= value(config,"cropTop",0) %>" /></label>
|
||
<label>Crop right %<input type="number" name="crop_right" min="0" max="95" step="0.1" value="<%= value(config,"cropRight",0) %>" /></label>
|
||
<label>Crop bottom %<input type="number" name="crop_bottom" min="0" max="95" step="0.1" value="<%= value(config,"cropBottom",0) %>" /></label>
|
||
<label>Crop left %<input type="number" name="crop_left" min="0" max="95" step="0.1" value="<%= value(config,"cropLeft",0) %>" /></label>
|
||
<label>Zoom<input type="number" name="zoom" min="0.1" max="5" step="0.1" value="<%= value(config,"zoom",1) %>" /></label>
|
||
<label class="overlay-css-field">Website CSS <button type="button" class="overlay-help" data-tooltip="Lumi injects this stylesheet into the loaded website document, like OBS Custom CSS. It can target body and any other elements on the source page.">?</button><textarea name="custom_css" rows="6" maxlength="12000" placeholder="<%= obsBrowserDefaultCss %>"><%= value(config,"customCss",obsBrowserDefaultCss) %></textarea><small class="hint">OBS's transparent background CSS is included by default. Add selectors below it to hide or restyle parts of the alert website.</small></label>
|
||
<div><div class="button-group"><button type="button" class="button subtle" data-refresh-source="<%= module.id %>">Refresh now</button><button type="button" class="button subtle" data-check-source="<%= module.id %>">Check website</button></div><small class="hint" data-source-health="<%= module.id %>"></small></div>
|
||
</div>
|
||
|
||
<details class="field full overlay-transform-disclosure">
|
||
<summary>Position and size</summary>
|
||
<fieldset class="overlay-transform-fields" aria-label="Position and size">
|
||
<label>X % <input type="number" step="0.1" name="x" value="<%= value(config, "x", 0) %>" /></label><label>Y % <input type="number" step="0.1" name="y" value="<%= value(config, "y", 0) %>" /></label><label>Width % <input type="number" step="0.1" name="width" value="<%= value(config, "width", 100) %>" /></label><label>Height % <input type="number" step="0.1" name="height" value="<%= value(config, "height", 100) %>" /></label><label>Opacity <input type="number" min="0" max="1" step="0.05" name="opacity" value="<%= value(config, "opacity", 1) %>" /></label>
|
||
</fieldset>
|
||
</details>
|
||
<div class="form-actions overlay-source-save-row"><button class="button" type="submit" data-source-save>Save source</button><button class="button subtle" type="button" data-source-revert>Undo unsaved changes</button><span class="hint" data-source-preview-status>Saved settings</span></div>
|
||
</form>
|
||
<div class="overlay-module-actions"><button type="button" class="button subtle" data-select-source="<%= module.id %>">Select on canvas</button><form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy source</button></form><button type="button" class="button danger" data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete source" data-confirm-text="Delete this source?" data-confirm-label="Delete source">Delete source</button></div>
|
||
</details>
|
||
<% }) %>
|
||
|
||
<% if (scene.modules.length > 1) { %><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules/reorder" class="overlay-order-form" data-reorder-form><div class="field full"><label>Layer order <button type="button" class="overlay-help" data-tooltip="Sources later in the list appear in front of earlier sources.">?</button></label><div class="overlay-reorder-list" data-reorder-list><% scene.modules.forEach((module) => { %><button type="button" class="button subtle" data-reorder-id="<%= module.id %>"><%= module.name %></button><% }) %></div><input type="hidden" name="ids" data-reorder-value /></div><div class="form-actions"><button class="button subtle" type="submit">Save layer order</button></div></form><% } %>
|
||
|
||
<details class="overlay-add-source">
|
||
<summary>Add a source</summary>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/scenes/<%= scene.id %>/modules" class="form-grid overlay-source-form" data-module-editor>
|
||
<div class="field"><label>Name</label><input name="name" required placeholder="Headline" /></div>
|
||
<div class="field"><label>Source type</label><select name="type" data-module-type-select><% moduleTypes.forEach((type) => { %><option value="<%= type.id %>"><%= type.label %></option><% }) %></select></div>
|
||
<input type="hidden" name="enabled" value="on" /><input type="hidden" name="anchor" value="top-left" /><input type="hidden" name="x" value="0" /><input type="hidden" name="y" value="0" /><input type="hidden" name="width" value="50" /><input type="hidden" name="height" value="25" />
|
||
<div class="field full overlay-conditional-fields" data-module-fields="text"><label>Text<textarea name="text" rows="2">New text</textarea></label></div>
|
||
<div class="field full overlay-conditional-fields" data-module-fields="chat">
|
||
<p class="hint full">Lumi combines live messages from the services you select. You can adjust the appearance, animations, timeout, and CSS after adding it.</p>
|
||
<fieldset class="overlay-fieldset full"><legend>Chats to include</legend><div class="overlay-check-grid"><label><input type="checkbox" name="chat_platform_twitch" checked /> Twitch</label><label><input type="checkbox" name="chat_platform_youtube" checked /> YouTube</label><label><input type="checkbox" name="chat_platform_discord" checked /> Discord</label></div></fieldset>
|
||
<label class="full">Only these channels (optional)<textarea name="chat_channels" rows="2" placeholder="Leave empty for all connected channels"></textarea></label>
|
||
<label class="full">Hidden users (optional)<textarea name="chat_blocked_users" rows="2" placeholder="twitch:username|user-id lumi:internal-name|user-id"></textarea></label>
|
||
<input type="hidden" name="chat_show_avatars" value="on" /><input type="hidden" name="chat_show_badges" value="on" /><input type="hidden" name="chat_show_platform" value="on" />
|
||
</div>
|
||
<div class="field full overlay-conditional-fields" data-module-fields="image"><label>Image address<input name="url" placeholder="https://example.com/image.png" /></label></div>
|
||
<div class="field full overlay-conditional-fields" data-module-fields="video"><label>Video address<input name="url" placeholder="https://example.com/clip.webm" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
|
||
<div class="field full overlay-conditional-fields" data-module-fields="audio"><label>Audio address<input name="url" placeholder="https://example.com/alert.ogg" /></label><label>Playback<select name="play_behavior"><option value="once">Play once</option><option value="loop">Loop</option><option value="manual">Manual</option></select></label><label>Volume<input type="number" name="volume" min="0" max="1" step="0.05" value="1" /></label></div>
|
||
<div class="field full overlay-conditional-fields" data-module-fields="web"><label>Overlay website address<input name="url" placeholder="https://streamelements.com/overlay/..." /></label><label><input type="checkbox" name="health_check" checked /> Check for website errors</label><label><input type="checkbox" name="inject_page_css" checked /> Apply CSS inside the website</label><label><input type="checkbox" name="force_transparent_background" checked /> Keep the website background transparent</label><label>Website CSS<textarea name="custom_css" rows="3" maxlength="12000"><%= obsBrowserDefaultCss %></textarea></label></div>
|
||
<div class="form-actions"><button class="button" type="submit">Add source</button></div>
|
||
</form>
|
||
</details>
|
||
</div>
|
||
</details>
|
||
<% }) %>
|
||
</section>
|
||
|
||
<% if (canManageObs) { %>
|
||
<section class="card" data-obs-connector data-overlay-id="<%= overlay.id %>">
|
||
<details><summary><strong>Connect Lumi to OBS</strong> <span class="hint">Optional</span></summary><p class="hint">This can switch scenes in OBS and keep OBS scenes matched with Lumi scenes. Most users can leave it off.</p>
|
||
<div class="overlay-status-grid"><div><span>Connection</span><strong data-obs-state><%= obsStatus.state %></strong></div><div><span>Current OBS scene</span><strong data-obs-current><%= obsStatus.current_scene || "Unknown" %></strong></div><div><span>Page permission</span><strong data-obs-permission><%= obsStatus.control_level_label || "Not reported" %></strong></div><div><span>Current transition</span><strong data-obs-transition><%= obsStatus.current_transition || "Unknown" %></strong></div><div><span>OBS canvas</span><strong data-obs-canvas><%= obsStatus.canvas_width && obsStatus.canvas_height ? `${obsStatus.canvas_width} × ${obsStatus.canvas_height}` : "Unknown" %></strong></div><div><span>Outputs</span><strong data-obs-outputs><%= obsStatus.outputs?.streaming ? "Streaming" : obsStatus.outputs?.recording ? "Recording" : "Idle or unavailable" %></strong></div><div><span>Profile</span><strong data-obs-profile><%= obsStatus.profiles_supported === false ? "Not exposed by Browser Bridge" : (obsStatus.current_profile || "Unknown") %></strong></div><div><span>Scene collection</span><strong data-obs-collection><%= obsStatus.scene_collections_supported === false ? "Not exposed by Browser Bridge" : (obsStatus.current_scene_collection || "Unknown") %></strong></div><div><span>Browser Source</span><strong data-obs-source><%= obsStatus.source_active === true ? "Active" : obsStatus.source_visible === true ? "Visible" : "Unknown" %></strong></div><div><span>Problem</span><strong data-obs-error><%= obsStatus.error || "None" %></strong></div></div>
|
||
<form method="post" action="/admin/overlays/<%= overlay.id %>/obs" class="form-grid" data-obs-settings-form>
|
||
<div class="field"><label>Connect to OBS</label><label class="switch"><input class="switch-input" type="checkbox" name="enabled" <%= overlay.obs.enabled ? "checked" : "" %> /><span class="switch-track"></span><span class="switch-text"><%= overlay.obs.enabled ? "On" : "Off" %></span></label></div>
|
||
<div class="field"><label>Connection type</label><select name="provider" data-obs-provider><% connectorProviders.forEach((provider) => { %><option value="<%= provider.id %>" <%= overlay.obs.provider === provider.id ? "selected" : "" %> <%= provider.available === false ? "disabled" : "" %>><%= provider.label %><%= provider.available === false ? " — coming later" : "" %></option><% }) %></select></div>
|
||
<div class="field" data-obs-websocket-field><label>OBS address <button type="button" class="overlay-help" data-tooltip="The normal OBS address is ws://127.0.0.1:4455 when Lumi runs on the same computer.">?</button></label><input name="endpoint" value="<%= overlay.obs.endpoint %>" /></div><div class="field" data-obs-websocket-field><label>OBS password</label><input name="password" type="password" autocomplete="new-password" placeholder="<%= overlay.obs.has_password ? 'Saved — leave blank to keep it' : 'Password from OBS' %>" /></div>
|
||
<div class="field full callout" data-obs-browser-help hidden><strong>Browser Bridge setup</strong><p>Save this connection, add the private Lumi link to OBS, then choose <strong>Read access to user information</strong> under Page permissions. Choose <strong>Advanced access</strong> only if Lumi should change OBS scenes. Keep “Shutdown source when not visible” off for persistent synchronization.</p><p>OBS does not expose profiles or scene-collection names to Browser Sources; Lumi will import scenes, transitions, canvas size, output status, and source activity instead.</p></div>
|
||
<div class="field"><label>Keep scenes matched</label><select name="sync_direction"><option value="none" <%= overlay.obs.sync_direction === "none" ? "selected" : "" %>>Do not switch automatically</option><option value="obs_to_overlay" <%= overlay.obs.sync_direction === "obs_to_overlay" ? "selected" : "" %>>OBS chooses the Lumi scene</option><option value="overlay_to_obs" <%= overlay.obs.sync_direction === "overlay_to_obs" ? "selected" : "" %>>Lumi chooses the OBS scene</option><option value="bidirectional" <%= overlay.obs.sync_direction === "bidirectional" ? "selected" : "" %>>Keep both matched</option></select></div><input type="hidden" name="reconnect" value="on" />
|
||
<div class="form-actions"><button class="button" type="submit">Save OBS connection</button><button class="button subtle" type="button" data-obs-test>Test connection</button><button class="button subtle" type="button" data-obs-refresh>Check status</button><button class="button subtle" type="button" data-obs-import>Apply detected OBS setup</button></div><p class="hint full" data-obs-import-result>Applying the detected setup adds missing OBS scenes, matches same-name scenes, uses the OBS canvas size, and selects the current scene. It never deletes Lumi scenes.</p>
|
||
</form>
|
||
<form class="input-action-form" data-obs-scene-form><div class="field"><label>Switch OBS to</label><select name="scene_name" data-obs-scenes><option value="">Choose an OBS scene</option><% (obsStatus.scenes || []).forEach((scene) => { %><option value="<%= scene %>"><%= scene %></option><% }) %></select></div><button class="button subtle" type="submit">Switch OBS scene</button></form>
|
||
</details>
|
||
</section>
|
||
<% } %>
|
||
</div>
|
||
|
||
<aside class="overlay-preview-panel card" data-overlay-editor>
|
||
<div class="section-header"><div><span class="eyebrow">Live editor</span><h2>Scene preview</h2></div><span class="badge" data-editor-save-state>Ready</span></div>
|
||
<div class="overlay-editor-toolbar"><label>Editing <select data-editor-scene><% overlay.scenes.forEach((scene) => { %><option value="<%= scene.id %>" <%= scene.id === overlay.active_scene_id ? "selected" : "" %>><%= scene.name %></option><% }) %></select></label><label class="compact-field"><input type="checkbox" data-editor-snap checked /> Snap</label></div>
|
||
<div class="overlay-preview-tools" aria-label="Preview background"><span>Check contrast</span><div class="button-group"><button type="button" class="button subtle" data-preview-background="transparent" aria-pressed="true">Transparency</button><button type="button" class="button subtle" data-preview-background="dark" aria-pressed="false">Dark</button><button type="button" class="button subtle" data-preview-background="light" aria-pressed="false">Light</button><button type="button" class="button subtle" data-preview-grid aria-pressed="false">Grid</button></div></div>
|
||
<div class="overlay-canvas-frame" data-preview-frame style="--overlay-aspect: <%= overlay.canvas_width || 1920 %> / <%= overlay.canvas_height || 1080 %>"><div class="overlay-canvas" data-overlay-canvas tabindex="0" aria-label="Visual overlay editor"><div class="overlay-canvas-layer" data-overlay-canvas-layer></div><div class="overlay-snap-guide guide-x" data-guide-x></div><div class="overlay-snap-guide guide-y" data-guide-y></div></div></div>
|
||
<div class="overlay-editor-readout"><span data-selected-name>No source selected</span><span data-selected-position></span></div>
|
||
<div class="overlay-editor-actions"><button type="button" class="button subtle" data-editor-center>Center</button><button type="button" class="button subtle" data-editor-fit>Fit to canvas</button><button type="button" class="button subtle" data-editor-reset>Reset box</button></div>
|
||
<p class="hint">This is the same <strong><%= overlay.canvas_width || 1920 %> × <%= overlay.canvas_height || 1080 %></strong> canvas OBS receives. Source settings preview here while you type; use <strong>Save source</strong> only when you want to keep them. Drag a source to move it, use the red handles to resize it, or use arrow keys for precise nudging. Hold Ctrl or Cmd to temporarily turn snapping <span data-snap-hint>off</span>.</p>
|
||
<div class="overlay-canvas-source-list" data-canvas-source-list></div>
|
||
</aside>
|
||
</div>
|
||
|
||
<script type="application/json" data-overlay-editor-state><%- editorStateJson %></script>
|
||
<div class="modal-backdrop" data-scene-create-modal aria-hidden="true"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-create-title"><div class="modal-header"><h2 id="scene-create-title">Add scene</h2><button type="button" class="icon-button" data-scene-create-close aria-label="Close">×</button></div><form method="post" action="/admin/overlays/<%= overlay.id %>/scenes" class="form-grid"><div class="field"><label>Scene name</label><input name="name" required /></div><div class="field"><label>Matching OBS scene (optional)</label><input name="obs_scene_name" /></div><div class="modal-actions"><button type="button" class="button subtle" data-scene-create-close>Cancel</button><button type="submit" class="button">Add scene</button></div></form></div></div>
|
||
|
||
<script src="/overlay-renderer.js?v=<%= assetVersion %>" defer></script>
|
||
<script src="/overlay-management.js?v=<%= assetVersion %>" defer></script>
|
||
<%- include("partials/layout-bottom") %>
|