// Settings page for every OBS Tools action. The fields come from schemas.js.
import { setLanguage, t, translateMessage } from "./lib/i18n.js";
import { CONTROLS, LOOK, SCHEMAS } from "./schemas.js";
const FIELD_LABELS = {
positionX: "Position X",
positionY: "Position Y",
rotation: "Rotation °",
scaleX: "Scale X",
scaleY: "Scale Y",
cropLeft: "Crop left",
cropRight: "Crop right",
cropTop: "Crop top",
cropBottom: "Crop bottom",
boundsWidth: "Bounds W",
boundsHeight: "Bounds H",
};
const EASINGS = [
["linear", "Linear"],
["easeIn", "Ease in"],
["easeOut", "Ease out"],
["easeInOut", "Smooth"],
["back", "Overshoot"],
];
let ws = null;
let context = "";
let action = "";
let schema = { fields: [] };
let settings = {};
let conn = null; // last "state" message from the plugin
let lists = {};
let listsLoaded = false;
let requestCounter = 0;
const pending = new Map();
const ui = {
// Panels of the fields (see section in schemas.js) the user opened or closed.
open: {},
// The connection box under the status. null: autoOpen decides, which is true while there is a problem.
setupOpen: null,
autoOpen: false,
setupMsg: null,
setupDraft: null,
recording: false,
animationMsg: null,
exportOpen: false,
importOpen: false,
importText: "",
twitchMsg: null,
pickMsg: null,
};
// ---------- connection to OpenDeck ----------
window.connectElgatoStreamDeckSocket = (port, uuid, registerEvent, info, actionInfo) => {
context = uuid;
document.documentElement.lang = setLanguage(JSON.parse(info || "{}").application?.language);
const parsed = JSON.parse(actionInfo || "{}");
action = parsed.action;
schema = SCHEMAS[action] ?? { fields: [] };
settings = { ...(parsed.payload?.settings ?? {}) };
ws = new WebSocket(`ws://127.0.0.1:${port}`);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ event: registerEvent, uuid }));
send({ type: "hello" });
});
ws.addEventListener("message", (event) => onMessage(JSON.parse(event.data)));
render();
};
function send(payload) {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ event: "sendToPlugin", action, context, payload }));
}
function request(payload) {
const requestId = `r${++requestCounter}`;
return new Promise((resolve) => {
pending.set(requestId, resolve);
send({ ...payload, requestId });
setTimeout(() => {
if (pending.delete(requestId)) resolve({ type: "timeout" });
}, 15000);
});
}
let saveTimer = null;
function save(now = false) {
clearTimeout(saveTimer);
const write = () => ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ event: "setSettings", context, payload: settings }));
if (now) write();
else saveTimer = setTimeout(write, 250);
}
function onMessage(message) {
if (message.event === "didReceiveSettings") {
settings = { ...settings, ...(message.payload?.settings ?? {}) };
if (!isTyping()) render();
return;
}
if (message.event !== "sendToPropertyInspector") return;
const payload = message.payload ?? {};
if (payload.requestId && pending.has(payload.requestId)) {
pending.get(payload.requestId)(payload);
pending.delete(payload.requestId);
}
if (payload.type === "state") {
const wasConnected = conn?.connection?.status === "connected";
const first = !conn;
conn = payload;
// A new problem opens the connection box, a working connection closes it. "Connecting" while
// OBS is away changes nothing, or the box would flicker with each try.
if ((conn.connection.status === "connected" || problem()) && problem() !== ui.autoOpen) {
ui.autoOpen = problem();
ui.setupOpen = null;
}
// The plugin knows the defaults, so its copy wins on the first load.
if (payload.settings && first) settings = { ...settings, ...payload.settings };
const connected = conn.connection.status === "connected";
// Open the help that fits the problem, once.
if (connected && !wasConnected) loadLists();
if (!isTyping()) render();
else renderStatus();
} else if (payload.type === "twitchError") {
ui.twitchMsg = { kind: "bad", text: translateMessage(payload.message) };
render();
}
}
// Lists follow OBS by themselves (new scenes, sources, filters), no reload button needed.
setInterval(() => {
if (conn?.connection?.status !== "connected" || document.hidden) return;
const el = document.activeElement;
if (openDropdown || (el && isTyping())) return;
loadLists();
}, 5000);
function isTyping() {
const el = document.activeElement;
return !!el && (el.tagName === "TEXTAREA" || (el.tagName === "INPUT" && ["text", "number", "password"].includes(el.type)));
}
// ---------- lists from OBS ----------
function neededLists() {
const names = new Set();
for (const field of schema.fields) if (field.list) names.add(field.list);
return [...names];
}
async function loadLists(names = neededLists()) {
if (!names.length) return;
const reply = await request({ type: "getLists", lists: names, source: settings.source, scene: settings.scene, filter: settings.filter });
if (reply.type !== "lists") return;
const next = { ...lists, ...reply.lists };
const changed = JSON.stringify(next) !== JSON.stringify(lists) || !listsLoaded;
lists = next;
listsLoaded = true;
if (changed && !isTyping()) render();
}
// ---------- tiny DOM helper ----------
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs ?? {})) {
if (value === undefined || value === null || value === false) continue;
if (key === "class") el.className = value;
else if (key === "style") el.style.cssText = value;
else if (key.startsWith("on")) el.addEventListener(key.slice(2).toLowerCase(), value);
else if (key in el) el[key] = value;
else el.setAttribute(key, value === true ? "" : value);
}
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return el;
}
// Like el.append, but skips null and false (append would print "null").
function append(el, ...children) {
el.append(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
return el;
}
// **bold** and _italic_ inside a (translated) sentence.
function rich(text) {
return text.split(/(\*\*[^*]+\*\*|_[^_]+_)/).map((part) => {
if (part.startsWith("**")) return h("b", {}, part.slice(2, -2));
if (part.length > 2 && part.startsWith("_")) return h("i", {}, part.slice(1, -1));
return part;
});
}
function row(label, control, { hint, top = false } = {}) {
return h("div", { class: `row${top ? " top" : ""}` }, h("div", { class: "name" }, label ?? ""), h("div", { class: "control" }, control), hint ? h("div", { class: "hint-line" }, hint) : null);
}
function msg(m) {
return m ? h("div", { class: `msg ${m.kind ?? ""}` }, m.text) : null;
}
// ---------- own controls ----------
// WebKitGTK (OpenDeck) draws native drop-downs and checkboxes in the GTK style, which does not fit.
// These look like the rest of the page and work with mouse and keyboard.
const CHECK = ``;
function checkMark(on) {
const el = h("span", { class: "dd-check" });
if (on) el.innerHTML = CHECK;
return el;
}
const CHEVRON = ``;
let openDropdown = null;
let renderPending = false;
function closeDropdown() {
if (!openDropdown) return;
const current = openDropdown;
openDropdown = null;
current.close();
if (renderPending) {
renderPending = false;
render();
}
}
document.addEventListener("mousedown", (e) => {
if (openDropdown && !openDropdown.root.contains(e.target)) closeDropdown();
});
window.addEventListener("blur", () => closeDropdown());
// options: [{ value, label, missing }]
function dropdown({ value, options, placeholder = t("Choose…"), onChange, focusKey }) {
const current = options.find((o) => o.value === value);
const root = h("div", { class: "dropdown" });
const button = h(
"button",
{
type: "button",
class: `dd-button${current?.missing ? " missing" : ""}`,
"data-focus-key": focusKey,
onclick: () => (openDropdown?.root === root ? closeDropdown() : open()),
onkeydown: (e) => {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
open();
}
},
},
h("span", { class: `dd-label${current ? "" : " placeholder"}` }, current ? current.label : placeholder),
);
button.insertAdjacentHTML("beforeend", `${CHEVRON}`);
root.append(button);
function open() {
closeDropdown();
root.classList.add("open");
const pop = h("div", { class: "dd-pop", role: "listbox" });
const search = options.length > 10 ? h("input", { type: "text", class: "dd-search", placeholder: t("Search…") }) : null;
const list = h("div", { class: "dd-list" });
let shown = options;
let active = Math.max(0, options.findIndex((o) => o.value === value));
const pick = (option) => {
closeDropdown();
if (option && option.value !== value) onChange(option.value);
const again = focusKey && document.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`);
(again || button).focus();
};
const mark = () => {
[...list.children].forEach((el, i) => el.classList.toggle("active", i === active));
list.children[active]?.scrollIntoView({ block: "nearest" });
};
const draw = () => {
list.replaceChildren(
...shown.map((o, i) =>
h(
"div",
{
class: `dd-item${o.value === value ? " selected" : ""}${o.missing ? " missing" : ""}`,
role: "option",
onmousedown: (e) => (e.preventDefault(), pick(o)),
onmouseenter: () => ((active = i), mark()),
},
checkMark(o.value === value),
h("span", { class: "dd-text" }, o.label),
),
),
);
if (!shown.length) list.append(h("div", { class: "dd-empty" }, t("Nothing found")));
mark();
};
const onKey = (e) => {
const handled = () => (e.preventDefault(), e.stopPropagation());
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
handled();
active = Math.min(shown.length - 1, Math.max(0, active + (e.key === "ArrowDown" ? 1 : -1)));
mark();
} else if (e.key === "Enter" || (e.key === " " && document.activeElement !== search)) {
// Stop it here, or the button would open the list again.
handled();
pick(shown[active]);
} else if (e.key === "Escape" || e.key === "Tab") {
handled();
closeDropdown();
button.focus();
} else if (search && e.key.length === 1 && document.activeElement !== search) {
search.focus();
}
};
if (search) {
search.addEventListener("input", () => {
const q = search.value.trim().toLowerCase();
shown = q ? options.filter((o) => String(o.label).toLowerCase().includes(q)) : options;
active = 0;
draw();
});
pop.append(search);
}
pop.append(list);
root.append(pop);
draw();
// Open upwards when there is more room above, and never taller than the page.
const rect = button.getBoundingClientRect();
const below = window.innerHeight - rect.bottom - 8;
const above = rect.top - 8;
const up = below < 160 && above > below;
pop.classList.toggle("up", up);
list.style.maxHeight = `${Math.max(90, Math.min(240, (up ? above : below) - (search ? 40 : 8)))}px`;
mark();
document.addEventListener("keydown", onKey, true);
if (search) search.focus();
openDropdown = {
root,
close: () => {
document.removeEventListener("keydown", onKey, true);
pop.remove();
root.classList.remove("open");
},
};
}
return root;
}
function checkbox(checked, text, onChange) {
return h(
"label",
{ class: "check" },
h("input", { type: "checkbox", checked: !!checked, onchange: (e) => onChange(e.target.checked) }),
h("span", { class: "check-box" }),
text ? h("span", { class: "check-text" }, text) : null,
);
}
// ---------- rendering ----------
function render() {
if (openDropdown) {
renderPending = true;
return;
}
const app = document.getElementById("app");
const focus = document.activeElement?.dataset?.focusKey;
const parts = [
renderStatusCard(),
setupOpen() ? renderSetup() : null,
renderKeyError(),
schema.help ? h("p", { class: "note" }, t(schema.help)) : null,
...renderFields(),
schema.hint ? h("p", { class: "note" }, t(schema.hint)) : null,
];
app.replaceChildren(...parts.flat(Infinity).filter(Boolean));
// WebKitGTK in OpenDeck sometimes paints only parts of the page after a big change and keeps old
// pixels elsewhere. A tiny change of the opacity makes it paint the whole page again.
app.style.opacity = app.style.opacity === "0.999" ? "" : "0.999";
if (focus) document.querySelector(`[data-focus-key="${CSS.escape(focus)}"]`)?.focus();
}
function renderStatus() {
const old = document.querySelector(".status");
if (old) old.replaceWith(renderStatusCard());
}
function statusText() {
if (!conn) return { kind: "busy", title: t("Checking connection to OBS…") };
const c = conn.connection;
const where = `${c.host}:${c.port}`;
// The plugin sends its messages in English.
const error = c.error ? translateMessage(c.error) : "";
switch (c.status) {
case "connected": {
// All good: a quiet line, the details only on hover.
const detail = c.custom ? t("This key uses another PC") : "";
return { kind: "ok", quiet: true, title: t("Connected to OBS {version}", { version: c.version?.obs ?? "" }).replace(/\s+/g, " ").trim(), hint: where, detail };
}
case "auth":
return { kind: "warn", title: t(c.error?.includes("asks") ? "OBS asks for a password" : "Wrong password"), hint: t("{where} · check the password below", { where }) };
case "offline":
return { kind: "bad", title: t("OBS is not reachable"), hint: t("{error}. Is OBS running with the WebSocket server on?", { error: error || where }) };
case "error":
return { kind: "bad", title: t("OBS error"), hint: error || where };
default:
return { kind: "warn busy", title: t("Connecting to OBS…"), hint: where };
}
}
// Why the key showed its alert last time. It goes away after the next press that works.
function renderKeyError() {
if (!conn?.keyError) return null;
return h("div", { class: "msg bad key-error" }, t("Last error: {error}", { error: translateMessage(conn.keyError) }));
}
function problem() {
return ["auth", "offline", "error"].includes(conn?.connection?.status);
}
// The connection box under the status: open while there is a problem, or when opened with the button.
function setupOpen() {
return ui.setupOpen ?? ui.autoOpen;
}
function renderStatusCard() {
const s = statusText();
const open = setupOpen();
const button = h("button", { class: "status-action", "aria-expanded": String(open), onclick: () => ((ui.setupOpen = !open), render()) }, t(open ? "Close" : "Change"));
if (s.quiet) {
return h(
"section",
{ class: `status ${s.kind} quiet`, "aria-live": "polite", title: s.detail || undefined },
h("span", { class: "status-dot" }),
h("span", { class: "status-title" }, s.title),
h("span", { class: "status-hint" }, s.hint),
button,
);
}
return h(
"section",
{ class: `status ${s.kind}`, "aria-live": "polite" },
h("span", { class: "status-dot" }),
h("div", {}, h("div", { class: "status-title" }, s.title), s.hint ? h("div", { class: "status-hint" }, s.hint) : null),
button,
);
}
// Only needed while there is no connection.
function renderGuide() {
return [
h("div", { class: "box-title" }, t("How to connect OBS")),
h(
"ol",
{ class: "steps" },
h("li", {}, rich(t("Open **OBS** and go to **Tools → WebSocket Server Settings**."))),
h("li", {}, rich(t("Turn on **Enable WebSocket server**."))),
h("li", {}, rich(t("OBS on this PC? Click **Read port and password from OBS** below."))),
h("li", {}, t("OBS on another PC? Enter its address, port and password below.")),
),
h("p", { class: "note" }, t("The status at the top turns green as soon as the connection works.")),
];
}
function renderSetup() {
const g = conn?.global ?? { host: "127.0.0.1", port: 4455 };
const draft = (ui.setupDraft ??= { host: g.host, port: g.port, password: null });
const field = (key, attrs) =>
h("input", {
...attrs,
"data-focus-key": `setup-${key}`,
oninput: (e) => (draft[key] = e.target.value),
});
return h(
"section",
{ class: "connection" },
conn?.connection?.status === "connected" ? null : renderGuide(),
// Text, checkboxes and messages line up with the fields.
row("", h("div", { class: "note" }, t("These settings apply to all OBS Tools actions."))),
row("", h("div", { class: "buttons" }, h("button", { class: "btn", onclick: readLocalObs }, t("Read port and password from OBS")))),
ui.localMsg ? row("", msg(ui.localMsg)) : null,
row(t("Host"), field("host", { type: "text", value: draft.host ?? "", placeholder: "127.0.0.1" })),
row(t("Port"), field("port", { type: "number", value: draft.port, min: 1, max: 65535 })),
row(
t("Password"),
field("password", {
type: "password",
value: draft.password ?? "",
placeholder: t(g.hasPassword ? "unchanged" : "none"),
autocomplete: "off",
}),
),
row(
"",
h(
"div",
{ class: "buttons" },
h("button", { class: "btn", onclick: testSetup }, t("Test")),
h("button", { class: "btn primary", onclick: saveSetup }, t("Save and reconnect")),
),
),
ui.setupMsg ? row("", msg(ui.setupMsg)) : null,
schema.remote ? renderKeyConnection() : null,
);
}
function setupPayload() {
const d = ui.setupDraft;
return { host: d.host, port: Number(d.port) || 4455, password: d.password === "" && conn?.global?.hasPassword ? null : d.password };
}
// Reads the OBS settings file on this PC, but only when the user clicks the button.
async function readLocalObs() {
const r = await request({ type: "readLocalObs" });
if (!r.found) ui.localMsg = { kind: "warn", text: t("No OBS settings found on this PC. Start OBS once, or type port and password.") };
else if (!r.enabled) ui.localMsg = { kind: "warn", text: t("The WebSocket server is off. In OBS: Tools → WebSocket Server Settings → Enable WebSocket server.") };
else ui.localMsg = { kind: "ok", text: t(r.authRequired ? "Found OBS on this PC: port {port}, password read from OBS." : "Found OBS on this PC: port {port}, no password.", { port: r.port }) };
ui.setupMsg = null;
ui.setupDraft = null;
render();
}
async function testSetup() {
ui.setupMsg = { kind: "", text: t("Testing…") };
render();
const result = await request({ type: "testConnection", settings: setupPayload() });
ui.setupMsg = result.ok
? { kind: "ok", text: t("It works: OBS {version} on {host}:{port}", { version: result.version?.obs ?? "", host: result.host, port: result.port }) }
: { kind: "bad", text: `${result.message ? translateMessage(result.message) : t("No connection")} (${result.host}:${result.port})` };
render();
}
async function saveSetup() {
await request({ type: "saveConnection", settings: setupPayload() });
ui.setupMsg = { kind: "ok", text: t("Saved. Connecting…") };
ui.localMsg = null;
ui.setupDraft = null;
render();
}
// Recording and streaming keys can talk to another OBS than the other keys.
function renderKeyConnection() {
const custom = !!settings.customConnection;
const section = h(
"div",
{ class: "panel-section" },
row("", h("div", { class: "section-title" }, t("Only this key"))),
row("", checkbox(custom, t("Use another OBS for this key"), (on) => setValue("customConnection", on, true))),
);
if (custom) {
append(section,
row(t("Host"), textInput("host", { placeholder: "192.168.1.20" })),
row(t("Port"), textInput("port", { type: "number", min: 1, max: 65535, placeholder: "4455" })),
row(t("Password"), textInput("password", { type: "password", placeholder: t("none"), autocomplete: "off" })),
row(
"",
h(
"button",
{
class: "btn",
onclick: async () => {
ui.remoteMsg = { kind: "", text: t("Testing…") };
render();
const r = await request({ type: "testConnection", direct: true, settings: { host: settings.host, port: settings.port, password: settings.password ?? "" } });
ui.remoteMsg = r.ok ? { kind: "ok", text: t("It works: OBS {version}", { version: r.version?.obs ?? "" }) } : { kind: "bad", text: r.message ? translateMessage(r.message) : t("No connection") };
render();
},
},
t("Test"),
),
),
ui.remoteMsg ? row("", msg(ui.remoteMsg)) : null,
);
}
return section;
}
function visible(field) {
return !field.showIf || field.showIf(settings);
}
// What the key is about at the top, then what it does, how it looks, and the closed sections.
function renderFields() {
const top = [];
const look = [];
const panels = new Map();
for (const field of schema.fields) {
if (!visible(field)) continue;
const el = renderField(field);
if (!el) continue;
if (!field.section) top.push(el);
else if (field.section === LOOK) look.push(el);
else {
if (!panels.has(field.section)) panels.set(field.section, { fields: [], wanted: false });
const p = panels.get(field.section);
p.fields.push(el);
if (field.openIf?.(settings)) p.wanted = true;
}
}
const out = [...top];
if (panels.has(CONTROLS)) out.push(section(CONTROLS, true, panels.get(CONTROLS).fields));
if (look.length) out.push(section(schema.lookTitle ?? "On the key", true, look));
for (const [title, p] of panels) if (title !== CONTROLS) out.push(section(title, p.wanted, p.fields));
return out;
}
// A small heading that folds the fields below it. The fields keep the grid of the rows above.
function section(title, open, fields) {
const id = `${action}:${title}`;
return h(
"details",
{ class: "section", open: ui.open[id] ?? open, ontoggle: (e) => (ui.open[id] = e.target.open) },
h("summary", { class: "heading" }, t(title)),
...fields,
);
}
// Several settings at once, from one choice.
function setValues(values) {
Object.assign(settings, values);
save(true);
render();
}
// Pictures on one page share the ids of their gradients and clips. Give each picture its own.
function ownIds(svg, suffix) {
return svg.replace(/\bid="([^"]+)"/g, `id="$1-${suffix}"`).replace(/url\(#([^)]+)\)/g, `url(#$1-${suffix})`);
}
function setValue(key, value, rerender = false) {
settings[key] = value;
save(rerender);
if (key === "source") loadLists(["filters", "filterSettings"]);
if (key === "filter" && neededLists().includes("filterSettings")) loadLists(["filterSettings"]);
if (key === "scene" && neededLists().includes("sceneItems")) loadLists(["sceneItems"]);
if (rerender) render();
}
function textInput(key, attrs = {}) {
return h("input", {
type: "text",
...attrs,
value: settings[key] ?? "",
"data-focus-key": key,
oninput: (e) => setValue(key, e.target.value),
onchange: () => (save(true), render()),
});
}
function numberInput(field) {
const { key } = field;
const input = h("input", {
type: "number",
value: settings[key] ?? "",
min: field.min,
max: field.max,
step: field.step,
placeholder: field.placeholder ? t(field.placeholder) : undefined,
"data-focus-key": key,
oninput: (e) => setValue(key, e.target.value === "" ? (field.allowEmpty ? "" : settings[key]) : Number(e.target.value)),
onchange: () => (save(true), render()),
});
// − and + instead of the tiny native arrows.
const bump = (direction) => {
const step = Number(field.step) > 0 ? Number(field.step) : 1;
const from = input.value === "" ? Number(field.min ?? 0) || 0 : Number(input.value);
let next = Math.round((from + direction * step) / step) * step;
if (field.min !== undefined) next = Math.max(Number(field.min), next);
if (field.max !== undefined) next = Math.min(Number(field.max), next);
next = Math.round(next * 1000) / 1000;
input.value = next;
setValue(key, next);
save(true);
};
return h(
"div",
{ class: "stepper" },
h("button", { type: "button", class: "step-btn", tabindex: -1, onclick: () => bump(-1), "aria-label": t("Less") }, "−"),
input,
h("button", { type: "button", class: "step-btn", tabindex: -1, onclick: () => bump(1), "aria-label": t("More") }, "+"),
);
}
function selectInput(field) {
// Lists from the plugin hold names, or { value, label } when the name is not readable.
const fixed = field.options;
// Lists can hold English names from the plugin (hotkeys, filter values), translated here.
const listLabel = (item) => {
const name = field.translate ? translateMessage(item.label) : item.label;
return item.range ? t("{name} ({min} to {max})", { name, min: item.range[0], max: item.range[1] }) : name;
};
const items = fixed ?? (lists[field.list] ?? []).map((item) => (typeof item === "string" ? [item, item] : [item.value, listLabel(item)]));
const value = settings[field.key] ?? "";
// Fixed options are our texts, lists hold names from OBS (never translated).
const options = items.map(([v, label]) => ({ value: v, label: fixed ? t(label) : label }));
if (field.empty !== undefined) options.unshift({ value: "", label: t(field.empty) });
if (value && !options.some((o) => o.value === value)) {
const known = fixed || (listsLoaded && field.list in lists);
options.push({ value, label: known ? t("{value} (not found)", { value }) : value, missing: known });
}
const offline = conn?.connection?.status !== "connected";
const placeholder = t(field.list && offline && !items.length ? "OBS not connected" : "Choose…");
return dropdown({ value, options, placeholder, focusKey: field.key, onChange: (v) => setValue(field.key, v, true) });
}
function renderField(field) {
const { key } = field;
const label = field.label ? t(field.label) : field.label;
const hint = field.hint ? t(field.hint) : field.hint;
const placeholder = field.placeholder ? t(field.placeholder) : field.placeholder;
switch (field.type) {
case "help":
case "note":
return h("p", { class: "note" }, t(field.text));
case "select":
return row(label, selectInput(field), { hint });
case "text":
return row(label, textInput(key, { placeholder }), { hint });
case "number": {
const unit = typeof field.unit === "function" ? field.unit(settings) : field.unit;
return row(label, [numberInput(field), unit ? h("span", { class: "unit" }, t(unit)) : null], { hint });
}
case "checkbox":
return row(label, checkbox(settings[key], field.text ? t(field.text) : "", (on) => setValue(key, on, true)));
case "color":
return row(
label,
h("label", { class: "swatch", style: `background:${settings[key] || "#ff0000"}`, title: t("Pick a color") }, h("input", { type: "color", value: settings[key] || "#ff0000", onchange: (e) => setValue(key, e.target.value, true) })),
);
case "textarea":
return row(label, h("textarea", { value: settings[key] ?? "", placeholder, rows: 4, "data-focus-key": key, oninput: (e) => setValue(key, e.target.value), onchange: () => save(true) }), { top: true, hint });
case "segmented":
return row(
label,
h(
"div",
{ class: "segmented" },
field.options.map(([v, text]) => h("button", { class: (settings[key] ?? field.options[0][0]) === v ? "on" : "", onclick: () => setValue(key, v, true) }, t(text))),
),
);
case "tiles": {
const current = field.value ? field.value(settings) : (settings[key] ?? field.options[0][0]);
return row(
label,
h(
"div",
{ class: "tiles", role: "radiogroup" },
field.options.map(([v, text], i) => {
const on = current === v;
const pick = () => (field.apply ? setValues(field.apply(v)) : setValue(key, v, true));
return h(
"button",
{ class: `tile${on ? " on" : ""}`, role: "radio", "aria-checked": String(on), onclick: pick },
h("span", { class: "tile-key", innerHTML: ownIds(field.preview(v, settings), `${key}-${i}`) }),
h("span", { class: "tile-name" }, t(text)),
);
}),
),
{ top: true },
);
}
case "file":
case "folder":
return [
row(label, [textInput(key, { placeholder }), h("button", { class: "btn", onclick: () => pickFile(key, field.type === "folder", field.images) }, t("Browse…"))], { hint }),
ui.pickMsg ? row("", msg(ui.pickMsg)) : null,
];
case "phases":
return renderPhases();
case "twitch":
return renderTwitch();
}
return null;
}
async function pickFile(key, folder = false, images = false) {
ui.pickMsg = { kind: "", text: t(folder ? "A folder dialog opened on your desktop…" : "A file dialog opened on your desktop…") };
render();
const title = t(folder ? "Choose a folder" : images ? "Choose a picture" : "Choose a video");
const reply = await request({ type: "pickFile", key, folder, images, start: settings[key] ?? "", title });
ui.pickMsg = reply.error ? { kind: "bad", text: translateMessage(reply.error) } : null;
if (reply.file) setValue(key, reply.file, true);
else render();
}
// ---------- Source Animation ----------
function phases() {
return Array.isArray(settings.phases) ? settings.phases : [];
}
function renderPhases() {
const list = phases();
if (!Array.isArray(settings.phases) && conn) settings.phases = list;
const wrap = h("div", { class: "box" }, h("div", { class: "box-title" }, t("Phases")));
list.forEach((phase, index) => {
phase.changes ??= {};
const setPhase = (k, v) => {
phase[k] = v;
save();
};
const grid = h("div", { class: "phase-grid" });
for (const [field, label] of Object.entries(FIELD_LABELS)) {
const value = phase.changes[field] ?? "";
append(grid,
h(
"label",
{},
t(label),
h("input", {
type: "number",
step: field.startsWith("scale") ? 0.01 : 1,
value,
class: value !== "" && Number(value) !== 0 ? "changed" : "",
"data-focus-key": `phase-${index}-${field}`,
oninput: (e) => {
if (e.target.value === "" || Number(e.target.value) === 0) delete phase.changes[field];
else phase.changes[field] = Number(e.target.value);
e.target.classList.toggle("changed", e.target.value !== "" && Number(e.target.value) !== 0);
save();
},
}),
),
);
}
const move = (dir) => {
const target = index + dir;
if (target < 0 || target >= list.length) return;
[list[index], list[target]] = [list[target], list[index]];
save(true);
render();
};
append(wrap,
h(
"div",
{ class: "phase" },
h(
"div",
{ class: "phase-head" },
h("span", { class: "label" }, t("Phase {n}", { n: index + 1 })),
h("button", { class: "btn icon", title: t("Move up"), disabled: index === 0, onclick: () => move(-1) }, "▲"),
h("button", { class: "btn icon", title: t("Move down"), disabled: index === list.length - 1, onclick: () => move(1) }, "▼"),
h("button", { class: "btn icon danger", title: t("Remove"), onclick: () => (list.splice(index, 1), save(true), render()) }, "✕"),
),
h(
"div",
{ class: "phase-grid" },
h("label", {}, t("Duration ms"), h("input", { type: "number", min: 0, step: 100, value: phase.duration ?? 1000, "data-focus-key": `phase-${index}-duration`, oninput: (e) => setPhase("duration", Number(e.target.value) || 0) })),
h("label", {}, t("Wait ms"), h("input", { type: "number", min: 0, step: 100, value: phase.delay ?? 0, "data-focus-key": `phase-${index}-delay`, oninput: (e) => setPhase("delay", Number(e.target.value) || 0) })),
h(
"label",
{},
t("Easing"),
(() => {
return dropdown({
value: phase.easing ?? "linear",
options: EASINGS.map(([v, l]) => ({ value: v, label: t(l) })),
focusKey: `phase-${index}-easing`,
onChange: (v) => (setPhase("easing", v), render()),
});
})(),
),
),
h("div", { class: "note", style: "margin:6px 0 4px" }, t("Change by (empty = no change):")),
grid,
),
);
});
const recordButtons = ui.recording
? [
h("button", { class: "btn primary", onclick: () => record("recordStop") }, t("■ Stop recording")),
h("button", { class: "btn", onclick: () => record("recordCancel") }, t("Cancel")),
]
: [h("button", { class: "btn", onclick: () => record("recordStart"), disabled: !settings.source }, t("● Record"))];
append(wrap,
h(
"div",
{ class: "buttons" },
h("button", { class: "btn", onclick: () => (list.push({ duration: 1000, delay: 0, easing: "easeInOut", changes: {} }), save(true), render()) }, t("+ Add phase")),
...recordButtons,
h("button", { class: "btn", onclick: playAnimation, disabled: !settings.source }, t("▶ Play")),
h("button", { class: "btn", onclick: () => ((ui.exportOpen = !ui.exportOpen), (ui.importOpen = false), render()) }, t("Export")),
h("button", { class: "btn", onclick: () => ((ui.importOpen = !ui.importOpen), (ui.exportOpen = false), render()) }, t("Import")),
),
msg(ui.animationMsg),
);
if (ui.recording) append(wrap, h("div", { class: "msg warn" }, t("Recording: now move, resize, rotate or crop the source in OBS. Then press Stop recording.")));
if (ui.exportOpen) {
const text = JSON.stringify(exportData(), null, 2);
const area = h("textarea", { value: text, rows: 8, readOnly: true });
append(wrap,
h("div", { class: "msg" }, t("Copy this text to share the animation or keep a backup:")),
area,
h(
"div",
{ class: "buttons", style: "margin-top:6px" },
h(
"button",
{
class: "btn",
onclick: async () => {
area.select();
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch {
ok = document.execCommand?.("copy");
}
ui.animationMsg = ok ? { kind: "ok", text: t("Copied.") } : { kind: "warn", text: t("Select the text and press Ctrl+C.") };
render();
},
},
t("Copy"),
),
),
);
}
if (ui.importOpen) {
const area = h("textarea", { value: ui.importText, rows: 8, placeholder: t("Paste an exported animation here"), "data-focus-key": "import", oninput: (e) => (ui.importText = e.target.value) });
append(wrap, area, h("div", { class: "buttons", style: "margin-top:6px" }, h("button", { class: "btn primary", onclick: importAnimation }, t("Import"))));
}
return wrap;
}
const EXPORT_KEYS = ["phases", "showAtStart", "hideAtEnd", "resetAtEnd", "filter", "filterStart", "filterEnd"];
function exportData() {
return { type: "obs-tools-animation", version: 1, ...Object.fromEntries(EXPORT_KEYS.map((k) => [k, settings[k]])) };
}
function importAnimation() {
try {
const data = JSON.parse(ui.importText);
if (!Array.isArray(data.phases)) throw new Error(t("no phases in the text"));
for (const key of EXPORT_KEYS) if (key in data) settings[key] = data[key];
save(true);
ui.importOpen = false;
ui.importText = "";
ui.animationMsg = { kind: "ok", text: t("Imported {n} phase(s).", { n: data.phases.length }) };
} catch (error) {
ui.animationMsg = { kind: "bad", text: t("Could not import: {error}", { error: error.message }) };
}
render();
}
async function record(type) {
save(true);
const reply = await request({ type });
if (reply.type === "error") ui.animationMsg = { kind: "bad", text: translateMessage(reply.message) };
else if (reply.type === "recording") {
ui.recording = !!reply.active;
ui.animationMsg = reply.message ? { kind: reply.message.startsWith("Nothing") ? "warn" : "ok", text: translateMessage(reply.message) } : null;
}
render();
}
async function playAnimation() {
save(true);
ui.animationMsg = { kind: "", text: t("Playing…") };
render();
const reply = await request({ type: "preview" });
ui.animationMsg = reply.type === "error" ? { kind: "bad", text: translateMessage(reply.message) } : null;
render();
}
// ---------- Twitch ----------
function renderTwitch() {
const tw = conn?.twitch ?? {};
const box = h("div", { class: "box" }, h("div", { class: "box-title" }, t("Twitch account (all keys)")));
append(box,
row(
t("Channel"),
h("input", {
type: "text",
value: tw.channel ?? "",
placeholder: t("your channel name"),
"data-focus-key": "twitch-channel",
onchange: (e) => send({ type: "twitchSave", channel: e.target.value }),
}),
{ hint: tw.chatWanted ? (tw.chatConnected ? t("Reading chat of #{channel}", { channel: tw.channel }) : t("Connecting to chat…")) : t("Chat is read without login. Clips need a login.") },
),
);
if (tw.authorized) {
append(box,
row(t("Login"), [h("span", {}, t("Logged in as {login}", { login: tw.login })), h("button", { class: "btn", onclick: () => send({ type: "twitchLogout" }) }, t("Log out"))]),
);
const missing = ["clips:edit", "user:write:chat"].filter((s) => !(tw.scopes ?? []).includes(s));
if (missing.length) append(box, h("div", { class: "msg warn" }, t("The token is missing: {scopes}", { scopes: missing.join(", ") })));
} else if (tw.deviceFlow) {
append(box,
row(t("Code"), [h("span", { class: "code" }, tw.deviceFlow.userCode), h("button", { class: "btn", onclick: () => send({ type: "openUrl", url: tw.deviceFlow.uri }) }, t("Open page"))]),
h("div", { class: "msg" }, t("Enter the code on the Twitch page that opened in your browser. This page updates when you are done.")),
row("", h("button", { class: "btn", onclick: () => send({ type: "twitchCancel" }) }, t("Cancel"))),
);
} else {
const clientId = h("input", { type: "text", placeholder: t("Client ID of your Twitch app"), "data-focus-key": "twitch-client" });
const token = h("input", { type: "password", placeholder: t("or paste an access token"), autocomplete: "off", "data-focus-key": "twitch-token" });
append(box,
h("details", { class: "inline" }, h("summary", {}, t("Log in for clips")), h("p", { class: "note" }, t("Create an app on dev.twitch.tv (Client Type: Public, redirect URL http://localhost), then paste its Client ID here.")),
row(t("Client ID"), [clientId, h("button", { class: "btn primary", onclick: () => ((ui.twitchMsg = null), send({ type: "twitchDeviceFlow", clientId: clientId.value })) }, t("Log in"))]),
row(t("Token"), [token, h("button", { class: "btn", onclick: () => ((ui.twitchMsg = null), send({ type: "twitchToken", token: token.value })) }, t("Use token"))], { hint: t("Scopes needed: clips:edit and user:write:chat") }),
),
);
}
if (ui.twitchMsg) append(box, msg(ui.twitchMsg));
return box;
}