Lumi/src/web/views/admin-overlay-detail.ejs
2026-07-19 13:01:16 +02:00

269 lines
49 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<%
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&#10;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&#10;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&#10;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") %>