// 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; }