(function () { const apiState = { stateResponse: null, previewResponse: null, catalog: null, events: [], ws: null, commandTimers: new Map(), }; const dom = { projectName: document.getElementById("project-name"), topologyLabel: document.getElementById("topology-label"), connectionPill: document.getElementById("connection-pill"), previewUpdated: document.getElementById("preview-updated"), refreshButton: document.getElementById("refresh-button"), patternSelect: document.getElementById("pattern-select"), transitionSlider: document.getElementById("transition-slider"), transitionValue: document.getElementById("transition-value"), transitionStyleSelect: document.getElementById("transition-style-select"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), presetList: document.getElementById("preset-list"), presetIdInput: document.getElementById("preset-id-input"), presetOverwriteInput: document.getElementById("preset-overwrite-input"), savePresetButton: document.getElementById("save-preset-button"), groupFilterInput: document.getElementById("group-filter-input"), groupList: document.getElementById("group-list"), snapshotIdInput: document.getElementById("snapshot-id-input"), snapshotLabelInput: document.getElementById("snapshot-label-input"), snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"), saveSnapshotButton: document.getElementById("save-snapshot-button"), snapshotList: document.getElementById("snapshot-list"), sceneParams: document.getElementById("scene-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), snapshotJson: document.getElementById("snapshot-json"), eventKindFilter: document.getElementById("event-kind-filter"), eventSearchFilter: document.getElementById("event-search-filter"), eventList: document.getElementById("event-list"), }; function init() { bindControls(); refreshAll(); connectStream(); } function bindControls() { dom.refreshButton.addEventListener("click", () => refreshAll()); dom.patternSelect.addEventListener("change", (event) => { sendCommand({ type: "select_pattern", payload: { pattern_id: event.target.value }, }); }); dom.transitionSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.transitionValue.textContent = `${value} ms`; debounceCommand("transition-duration", { type: "set_transition_duration_ms", payload: { duration_ms: value }, }); }); dom.transitionStyleSelect.addEventListener("change", (event) => { sendCommand({ type: "set_transition_style", payload: { style: event.target.value }, }); }); dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; debounceCommand("brightness", { type: "set_master_brightness", payload: { value }, }); }); dom.blackoutButton.addEventListener("click", () => { const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false); sendCommand({ type: "set_blackout", payload: { enabled }, }); }); dom.savePresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim(); if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_id_required", message: "Preset ID is required before saving.", }); return; } await sendCommand({ type: "save_preset", payload: { preset_id: presetId, overwrite: dom.presetOverwriteInput.checked, }, }); }); dom.saveSnapshotButton.addEventListener("click", async () => { const snapshotId = dom.snapshotIdInput.value.trim(); if (!snapshotId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "snapshot_id_required", message: "Snapshot ID is required before saving a creative variant.", }); return; } await sendCommand({ type: "save_creative_snapshot", payload: { snapshot_id: snapshotId, label: dom.snapshotLabelInput.value.trim() || null, overwrite: dom.snapshotOverwriteInput.checked, }, }); }); dom.groupFilterInput.addEventListener("input", () => renderGroups()); dom.eventKindFilter.addEventListener("change", () => renderEvents()); dom.eventSearchFilter.addEventListener("input", () => renderEvents()); } async function refreshAll() { setConnectionState("connecting", "loading"); try { const [stateResponse, previewResponse, catalog] = await Promise.all([ fetchJson("/api/v1/state"), fetchJson("/api/v1/preview"), fetchJson("/api/v1/catalog"), ]); apiState.stateResponse = stateResponse; apiState.previewResponse = previewResponse; apiState.catalog = catalog; renderAll(); setConnectionState("online", "HTTP sync"); } catch (error) { console.error(error); setConnectionState("offline", "snapshot fetch failed"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "error", code: "http_refresh_failed", message: `HTTP refresh failed: ${error.message}`, }); } } function connectStream() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = `${protocol}//${window.location.host}/api/v1/stream`; const socket = new WebSocket(url); apiState.ws = socket; socket.addEventListener("open", () => { setConnectionState("online", "stream connected"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "stream_connected", message: "WebSocket stream connected", }); }); socket.addEventListener("message", (event) => { const envelope = JSON.parse(event.data); handleStreamEnvelope(envelope); }); socket.addEventListener("close", () => { setConnectionState("offline", "stream disconnected"); pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "stream_reconnect", message: "WebSocket stream closed, retrying", }); window.setTimeout(connectStream, 1500); }); socket.addEventListener("error", () => { setConnectionState("warning", "stream error"); }); } function handleStreamEnvelope(envelope) { const message = envelope.message; if (!message) { return; } if (message.type === "snapshot") { apiState.stateResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, state: message.payload, }; renderState(); return; } if (message.type === "preview") { apiState.previewResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, preview: message.payload, }; renderPreview(); renderSnapshotJson(); return; } if (message.type === "event") { pushEvent({ at: `${envelope.generated_at_millis} ms`, kind: message.payload.kind || "info", code: message.payload.code || null, message: message.payload.message, }); } } async function sendCommand(command) { try { const response = await fetchJson("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: `web-${Date.now()}`, command, }), }); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: response.command_type, message: response.summary, }); await refreshAll(); return response; } catch (error) { console.error(error); pushEvent({ at: new Date().toLocaleTimeString(), kind: "error", code: "command_failed", message: `Command failed: ${error.message}`, }); return null; } } function debounceCommand(key, command) { const existing = apiState.commandTimers.get(key); if (existing) { window.clearTimeout(existing); } const timeoutId = window.setTimeout(() => { sendCommand(command); apiState.commandTimers.delete(key); }, 120); apiState.commandTimers.set(key, timeoutId); } async function fetchJson(url, options) { const response = await window.fetch(url, options); const body = await response.text(); let payload = null; try { payload = body ? JSON.parse(body) : null; } catch (error) { throw new Error(`Invalid JSON from ${url}`); } if (!response.ok) { const message = payload?.error?.message || payload?.error || response.statusText; throw new Error(message); } return payload; } function renderAll() { renderState(); renderPreview(); renderEvents(); } function renderState() { const state = apiState.stateResponse?.state; if (!state) { return; } const global = state.global; const scene = state.active_scene; dom.projectName.textContent = state.system.project_name; dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; dom.patternSelect.innerHTML = ""; (apiState.catalog?.patterns || []).forEach((pattern) => { const option = document.createElement("option"); option.value = pattern.pattern_id; option.textContent = `${pattern.display_name} (${pattern.pattern_id})`; option.selected = pattern.pattern_id === global.selected_pattern; dom.patternSelect.appendChild(option); }); dom.transitionSlider.value = String(global.transition_duration_ms); dom.transitionValue.textContent = `${global.transition_duration_ms} ms`; dom.transitionStyleSelect.value = global.transition_style; dom.brightnessSlider.value = String(global.master_brightness); dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout"; dom.blackoutButton.classList.toggle("is-active", global.blackout); renderPresets(scene); renderGroups(global); renderCreativeSnapshots(); renderSceneParameters(scene); renderSummaryCards(state); renderSnapshotJson(); } function renderPresets(scene) { dom.presetList.innerHTML = ""; const presets = apiState.catalog?.presets || []; if (!presets.length) { dom.presetList.innerHTML = '