(function () { const PREVIEW_RENDER_INTERVAL_MS = 16; const STATE_RENDER_INTERVAL_MS = 180; const POSITION_ORDER = { top: 0, middle: 1, bottom: 2 }; const COLOR_PARAM_KEYS = new Set(["color_mode", "palette", "primary_color", "secondary_color"]); const HIDDEN_PARAMETER_KEYS = new Set(["speed", "brightness"]); const TEMPO_BPM_MIN = 10; const TEMPO_BPM_MAX = 300; const TEMPO_BPM_DEFAULT = 120; const SPEED_TO_BPM_FACTOR = 60; const TILE_LED_GEOMETRY = buildTileLedGeometry(); const OPERATOR_MODES = [ { mode_id: "arrow", label: "Arrow", pattern_id: "arrow", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "breathing", label: "Breathing", pattern_id: "breathing", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, { mode_id: "center_pulse", label: "Center Pulse", pattern_id: "center_pulse", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "checker", label: "Checkerd", pattern_id: "checker", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "column_gradient", label: "Column Gradient", pattern_id: "column_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, { mode_id: "row_gradient", label: "Row Gradient", pattern_id: "row_gradient", defaults: { speed: 0.35, intensity: 0.9 }, canonical: true }, { mode_id: "saw", label: "Saw", pattern_id: "saw", defaults: { speed: 1.0, intensity: 0.9 }, canonical: true }, { mode_id: "scan", label: "Scan", pattern_id: "scan", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "scan_dual", label: "Scan Dual", pattern_id: "scan_dual", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "snake", label: "Snake", pattern_id: "snake", defaults: { speed: 1.0, randomness: 0.35, intensity: 1.0 }, canonical: true }, { mode_id: "solid", label: "Solid", pattern_id: "solid", defaults: { speed: 0.0, intensity: 1.0 }, canonical: true }, { mode_id: "sparkle", label: "Sparkle", pattern_id: "sparkle", defaults: { speed: 1.0, strobe_duty_cycle: 0.72, intensity: 0.86 }, canonical: true }, { mode_id: "stopwatch", label: "Stopwatch", pattern_id: "stopwatch", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "strobe", label: "Strobe", pattern_id: "strobe", defaults: { speed: 1.0, strobe_duty_cycle: 0.5, intensity: 1.0 }, canonical: true }, { mode_id: "sweep", label: "Sweep", pattern_id: "sweep", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "two_dots", label: "Two Dots", pattern_id: "two_dots", defaults: { speed: 1.0, block_size: 1.0, intensity: 1.0 }, canonical: true }, { mode_id: "wave_line", label: "Wave Line", pattern_id: "wave_line", defaults: { speed: 1.0, intensity: 1.0 }, canonical: true }, ]; const OPERATOR_MODE_BY_ID = new Map(OPERATOR_MODES.map((mode) => [mode.mode_id, mode])); const LEGACY_PARAMETER_CONTRACT = { color_mode: { control: "enum", label: "Color Mode", default_value: "dual", options: [ { value: "dual", label: "Dual" }, { value: "palette", label: "Palette" }, { value: "mono", label: "Mono" }, { value: "complementary", label: "Complementary" }, { value: "random_colors", label: "Random Colors" }, { value: "custom_random", label: "Custom Random" }, ], }, palette: { control: "enum", label: "Palette", default_value: "Deep Blue", groups: [ { label: "White / Temperature Presets", options: [ { value: "Candle", label: "Candle (#FF9329)" }, { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, { value: "Halogen", label: "Halogen (#FFF1E0)" }, { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, ], }, { label: "Club / Show Presets", options: [ { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, { value: "Emerald", label: "Emerald (#00C853)" }, { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, { value: "Amber", label: "Amber (#FF9D00)" }, { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, { value: "Violet", label: "Violet (#7A3CFF)" }, { value: "Warm White", label: "Warm White (#FFD6AA)" }, { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, { value: "Cool White", label: "Cool White (#C9E2FF)" }, { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, ], }, ], options: [ { value: "Candle", label: "Candle (#FF9329)" }, { value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" }, { value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" }, { value: "Halogen", label: "Halogen (#FFF1E0)" }, { value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" }, { value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" }, { value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" }, { value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" }, { value: "Deep Blue", label: "Deep Blue (#0047FF)" }, { value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" }, { value: "Emerald", label: "Emerald (#00C853)" }, { value: "Acid Lime", label: "Acid Lime (#AEEA00)" }, { value: "Amber", label: "Amber (#FF9D00)" }, { value: "Signal Red", label: "Signal Red (#FF2D2D)" }, { value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" }, { value: "Violet", label: "Violet (#7A3CFF)" }, { value: "Warm White", label: "Warm White (#FFD6AA)" }, { value: "Neutral White", label: "Neutral White (#FFF1E0)" }, { value: "Cool White", label: "Cool White (#C9E2FF)" }, { value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" }, ], }, direction: { control: "enum", label: "Direction", default_value: "left_to_right", options: [ { value: "left_to_right", label: "Left to Right" }, { value: "right_to_left", label: "Right to Left" }, { value: "top_to_bottom", label: "Top to Bottom" }, { value: "bottom_to_top", label: "Bottom to Top" }, { value: "outward", label: "Outward" }, { value: "inward", label: "Inward" }, ], }, symmetry: { control: "enum", label: "Mirror", default_value: "none", options: [ { value: "none", label: "None" }, { value: "horizontal", label: "Horizontal" }, { value: "vertical", label: "Vertical" }, { value: "both", label: "Both" }, ], }, checker_mode: { control: "enum", label: "Checker Mode", default_value: "classic", options: [ { value: "classic", label: "Classic" }, { value: "diagonal", label: "Diagonal Split" }, { value: "checkerd", label: "Checkerd" }, ], }, scan_style: { control: "enum", label: "Scan Style", default_value: "line", options: [ { value: "line", label: "Line" }, { value: "bands", label: "Bands" }, ], }, strobe_mode: { control: "enum", label: "Strobe Mode", default_value: "global", options: [ { value: "global", label: "Global" }, { value: "random_pixels", label: "Random Pixels" }, { value: "random_leds", label: "Random LEDs" }, ], }, stopwatch_mode: { control: "enum", label: "Stopwatch Mode", default_value: "sync", options: [ { value: "sync", label: "Sync" }, { value: "random", label: "Random" }, ], }, center_pulse_mode: { control: "enum", label: "Pulse Mode", default_value: "expand", options: [ { value: "expand", label: "Expand" }, { value: "reverse", label: "Reverse" }, { value: "outline", label: "Outline" }, { value: "outline_reverse", label: "Outline Reverse" }, ], }, angle: { control: "enum", label: "Angle", default_value: "0", value_kind: "scalar", options: [0, 45, 90, 135, 180, 225, 270, 315].map((value) => ({ value: String(value), label: `${value}°`, })), }, primary_color: { control: "color", label: "Primary Color", default_value: "#4D7CFF" }, secondary_color: { control: "color", label: "Secondary Color", default_value: "#0E1630" }, flip_horizontal: { control: "toggle", label: "Flip Horizontal", default_value: false }, flip_vertical: { control: "toggle", label: "Flip Vertical", default_value: false }, }; const apiState = { stateResponse: null, previewResponse: null, catalog: null, events: [], ws: null, commandTimers: new Map(), controlClient: null, ui: { previewMode: "leds", workMode: "test_edit", selectedPanelKey: null, previewNodes: new Map(), previewSignatures: new Map(), parameterCards: new Map(), lastSelectedModeId: null, patternsSignature: null, presetsSignature: null, selectedPresetId: null, snapshotsSignature: null, parameterSignature: null, previewLayoutSignature: null, viewOutputSignature: null, stateTimer: null, lastStateRenderAt: 0, lastPreviewRenderAt: 0, stateRenderQueued: false, previewRenderQueued: false, previewAnimationFrame: 0, eventFilterSignature: null, }, }; const dom = { projectName: document.getElementById("project-name"), topologyLabel: document.getElementById("topology-label"), connectionPill: document.getElementById("connection-pill"), editContextLabel: document.getElementById("edit-context-label"), previewUpdated: document.getElementById("preview-updated"), refreshButton: document.getElementById("refresh-button"), tempoBpmInput: document.getElementById("tempo-bpm-input"), tempoBpmLabel: document.getElementById("tempo-bpm-label"), workModeSelect: document.getElementById("work-mode-select"), previewModeLabel: document.getElementById("preview-mode-label"), controlModePill: document.getElementById("control-mode-pill"), pendingPanelDescription: document.getElementById("pending-panel-description"), sessionScopeLabel: document.getElementById("session-scope-label"), pendingCommitPill: document.getElementById("pending-commit-pill"), pendingCompactLabel: document.getElementById("pending-compact-label"), pendingSessionSummary: document.getElementById("pending-session-summary"), primitiveErrorBanner: document.getElementById("primitive-error-banner"), triggerTransitionButton: document.getElementById("trigger-transition-button"), clearStagedButton: document.getElementById("clear-staged-button"), goButton: document.getElementById("go-button"), fadeGoButton: document.getElementById("fade-go-button"), utilityGoButton: document.getElementById("utility-go-button"), utilityFadeGoButton: document.getElementById("utility-fade-go-button"), patternSelect: document.getElementById("pattern-select"), transitionSecondsInput: document.getElementById("transition-seconds-input"), transitionSecondsLabel: document.getElementById("transition-seconds-label"), transitionStyleSelect: document.getElementById("transition-style-select"), brightnessSlider: document.getElementById("brightness-slider"), brightnessValue: document.getElementById("brightness-value"), blackoutButton: document.getElementById("blackout-button"), utilityBlackoutButton: document.getElementById("utility-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"), loadPresetButton: document.getElementById("load-preset-button"), deletePresetButton: document.getElementById("delete-preset-button"), 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"), motionParams: document.getElementById("motion-params"), colorParams: document.getElementById("color-params"), previewGrid: document.getElementById("preview-grid"), summaryCards: document.getElementById("summary-cards"), selectedTileCard: document.getElementById("selected-tile-card"), whiteTestButton: document.getElementById("white-test-button"), livePatternButton: document.getElementById("live-pattern-button"), viewOutputList: document.getElementById("view-output-list"), snapshotJson: document.getElementById("snapshot-json"), eventKindFilter: document.getElementById("event-kind-filter"), eventSearchFilter: document.getElementById("event-search-filter"), eventList: document.getElementById("event-list"), }; function init() { apiState.controlClient = createShowControlClient(); bindControls(); syncPresetActionButtons(); refreshAll(); connectStream(); } function bindControls() { dom.refreshButton.addEventListener("click", () => refreshAll()); dom.workModeSelect.addEventListener("change", (event) => { setWorkMode(event.target.value); }); dom.patternSelect.addEventListener("change", async (event) => { await applyPatternSelection(event.target.value); }); dom.tempoBpmInput.addEventListener("change", async (event) => { await applyTempoBpm(Number(event.target.value)); }); dom.tempoBpmInput.addEventListener("input", (event) => { const bpm = normalizeTempoBpm(Number(event.target.value)); dom.tempoBpmLabel.textContent = `${bpm} BPM`; }); dom.transitionSecondsInput.addEventListener("change", async (event) => { await applyTransitionSettings(dom.transitionStyleSelect.value, Number(event.target.value), true); }); dom.transitionSecondsInput.addEventListener("input", (event) => { const seconds = normalizeTransitionSeconds(Number(event.target.value)); dom.transitionSecondsLabel.textContent = `${seconds.toFixed(1)} s`; }); dom.transitionStyleSelect.addEventListener("change", async (event) => { await applyTransitionSettings(event.target.value, Number(dom.transitionSecondsInput.value), true); }); dom.brightnessSlider.addEventListener("input", (event) => { const value = Number(event.target.value); dom.brightnessValue.textContent = `${Math.round(value * 100)}%`; debounceCommand("master_brightness", async () => { await handlePrimitive({ primitive: "set_master_brightness", payload: { value }, }); }); }); dom.blackoutButton.addEventListener("click", async () => toggleBlackout()); dom.utilityBlackoutButton.addEventListener("click", async () => toggleBlackout()); dom.goButton.addEventListener("click", async () => commitTransition({ cut: true })); dom.utilityGoButton.addEventListener("click", async () => commitTransition({ cut: true })); dom.fadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.utilityFadeGoButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.triggerTransitionButton.addEventListener("click", async () => commitTransition({ cut: false })); dom.clearStagedButton.addEventListener("click", () => { apiState.controlClient.clearPending(); pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "staged_transition_cleared", message: "Staged transition buffer cleared.", }); renderLocalUi(); }); dom.savePresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_id_required", message: "Preset ID is required before saving.", }); return; } try { await sendCommand({ type: "save_preset", payload: { preset_id: presetId, overwrite: dom.presetOverwriteInput.checked, }, }); } catch (error) { handleClientError(error, "save_preset"); renderLocalUi(); } }); dom.loadPresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_selection_required", message: "Select a preset or provide a preset ID before loading.", }); return; } await handlePrimitive({ primitive: "recall_preset", payload: { preset_id: presetId }, }); }); dom.deletePresetButton.addEventListener("click", async () => { const presetId = dom.presetIdInput.value.trim() || apiState.ui.selectedPresetId || ""; if (!presetId) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "preset_selection_required", message: "Select a preset or provide a preset ID before deleting.", }); return; } try { await sendCommand({ type: "delete_preset", payload: { preset_id: presetId, }, }); if (apiState.ui.selectedPresetId === presetId) { apiState.ui.selectedPresetId = null; } } catch (error) { handleClientError(error, "delete_preset"); renderLocalUi(); } }); 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; } try { await sendCommand({ type: "save_creative_snapshot", payload: { snapshot_id: snapshotId, label: dom.snapshotLabelInput.value.trim() || null, overwrite: dom.snapshotOverwriteInput.checked, }, }); } catch (error) { handleClientError(error, "save_creative_snapshot"); renderLocalUi(); } }); dom.eventKindFilter.addEventListener("change", () => renderEvents(true)); dom.eventSearchFilter.addEventListener("input", () => renderEvents(true)); dom.presetIdInput.addEventListener("input", () => { syncPresetActionButtons(); }); dom.whiteTestButton.addEventListener("click", async () => { const selected = selectedPanel(); if (!selected) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "panel_selection_required", message: "Select a tile before triggering a white test.", }); return; } try { await sendCommand( { type: "trigger_panel_test", payload: { node_id: selected.node_id, panel_position: selected.panel_position, pattern: "walking_pixel_106", }, }, { refresh: false } ); } catch (error) { handleClientError(error, "trigger_panel_test"); } }); dom.livePatternButton.addEventListener("click", () => { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "live_pattern_notice", message: "Live Pattern follows the host preview automatically once any temporary panel test expires.", }); }); } async function refreshAll() { setConnectionState("warning", "syncing"); try { const responses = await Promise.all([ fetchJson("/api/v1/state"), fetchJson("/api/v1/preview"), fetchJson("/api/v1/catalog"), ]); apiState.stateResponse = responses[0]; apiState.previewResponse = responses[1]; apiState.catalog = responses[2]; renderAll(true); setConnectionState("live", "stream ready"); } catch (error) { setConnectionState("alert", "sync failed"); handleClientError(error, "http_refresh_failed"); renderLocalUi(); } } 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("live", "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("warning", "reconnecting"); 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, }; scheduleStateRender(); return; } if (message.type === "preview") { apiState.previewResponse = { api_version: envelope.api_version, generated_at_millis: envelope.generated_at_millis, preview: message.payload, }; schedulePreviewRender(); 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, }); } } function scheduleStateRender() { if (apiState.ui.stateRenderQueued) { return; } apiState.ui.stateRenderQueued = true; const now = window.performance.now(); const waitMs = Math.max(0, STATE_RENDER_INTERVAL_MS - (now - apiState.ui.lastStateRenderAt)); window.setTimeout(() => { apiState.ui.stateRenderQueued = false; apiState.ui.lastStateRenderAt = window.performance.now(); renderState(false); }, waitMs); } function schedulePreviewRender() { if (apiState.ui.previewRenderQueued) { return; } apiState.ui.previewRenderQueued = true; const scheduleFrame = () => { apiState.ui.previewAnimationFrame = window.requestAnimationFrame(() => { const now = window.performance.now(); if (now - apiState.ui.lastPreviewRenderAt < PREVIEW_RENDER_INTERVAL_MS) { scheduleFrame(); return; } apiState.ui.previewAnimationFrame = 0; apiState.ui.previewRenderQueued = false; apiState.ui.lastPreviewRenderAt = now; renderPreview(false); renderSelectedTile(); renderSnapshotJson(); }); }; if (apiState.ui.previewAnimationFrame) { apiState.ui.previewRenderQueued = false; return; } scheduleFrame(); } async function handlePrimitive(primitive, options) { const settings = options || {}; const announceBuffered = settings.announceBuffered !== false; const rerenderState = settings.rerenderState === true; try { const outcome = await apiState.controlClient.applyPrimitive(primitive); if (outcome.kind === "buffered" && announceBuffered) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: primitive.primitive, message: outcome.summary, }); } else if (outcome.kind === "command" && outcome.summary) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: primitive.primitive, message: outcome.summary, }); } if (rerenderState) { renderState(true); } else { renderLocalUi(); } return outcome; } catch (error) { handleClientError(error, primitive.primitive); renderLocalUi(); return null; } } async function commitTransition(options) { const config = options || {}; const client = apiState.controlClient; if (apiState.ui.workMode !== "show_event") { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: "direct_mode_active", message: "Test/Edit mode applies changes immediately. Go and Fade Go are only used in Show/Event mode.", }); return; } if (!client.hasPending()) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "warning", code: "no_pending_transition", message: "Stage a pattern or parameter change before committing.", }); return; } if (config.cut) { await handlePrimitive( { primitive: "set_transition_style", payload: { style: "snap", duration_ms: 0, }, }, { announceBuffered: false, rerenderState: false } ); } await handlePrimitive({ primitive: "trigger_transition" }, { rerenderState: true }); } async function toggleBlackout() { const enabled = !(apiState.stateResponse && apiState.stateResponse.state ? apiState.stateResponse.state.global.blackout : false); await handlePrimitive({ primitive: "blackout", payload: { enabled: enabled }, }); } async function sendCommand(command, options) { const settings = options || {}; const announce = settings.announce !== false; const refresh = settings.refresh !== false; const response = await fetchJson("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: `web-${Date.now()}`, command: command, }), }); if (announce) { pushEvent({ at: new Date().toLocaleTimeString(), kind: "info", code: response.command_type, message: response.summary, }); } if (refresh) { await refreshAll(); } return response; } function debounceCommand(key, callback) { const existing = apiState.commandTimers.get(key); if (existing) { window.clearTimeout(existing); } const timeoutId = window.setTimeout(async () => { try { await callback(); } finally { 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 createClientError("invalid_json", `Invalid JSON from ${url}`); } if (!response.ok) { const code = payload && payload.error ? payload.error.code : "request_failed"; const message = payload && payload.error ? payload.error.message : response.statusText; throw createClientError(code, message); } return payload; } function createShowControlClient() { const urlMode = new URLSearchParams(window.location.search).get("show_control_mode"); const mode = urlMode === "stateless" ? "stateless" : "stateful"; return { mode: mode, sessionLabel: mode === "stateful" ? "local browser session" : "stateless direct port", commitState: "idle", lastError: null, pending: createEmptyPendingState(), clearPending() { this.pending = createEmptyPendingState(); this.commitState = "idle"; this.lastError = null; }, hasPending() { return hasPendingState(this.pending); }, effectiveGroupId(liveGroupId) { return this.pending.hasGroupTarget ? this.pending.groupId : liveGroupId; }, stageGroupTarget(groupId) { ensureStatefulSession(this); this.pending.hasGroupTarget = true; this.pending.groupId = groupId; this.lastError = null; this.commitState = "staged"; return { kind: "buffered", summary: `group target staged: ${groupId || "all_panels"}`, }; }, async applyPrimitive(primitive) { this.lastError = null; try { const outcome = await applyPrimitiveWithClient(this, primitive); this.lastError = null; if (outcome.kind === "buffered") { this.commitState = "staged"; } else if (primitive.primitive !== "trigger_transition") { this.commitState = this.hasPending() ? "staged" : "idle"; } return outcome; } catch (error) { this.lastError = { code: error.code || "primitive_failed", message: error.message, }; throw error; } }, }; } async function applyPrimitiveWithClient(client, primitive) { switch (primitive.primitive) { case "blackout": return { kind: "direct", summary: primitive.payload.enabled ? "blackout enabled" : "blackout released", response: await sendCommand({ type: "set_blackout", payload: primitive.payload, }), }; case "recall_preset": return { kind: "direct", summary: `preset recalled: ${primitive.payload.preset_id}`, response: await sendCommand({ type: "recall_preset", payload: primitive.payload, }), }; case "recall_creative_snapshot": return { kind: "direct", summary: `creative snapshot recalled: ${primitive.payload.snapshot_id}`, response: await sendCommand({ type: "recall_creative_snapshot", payload: primitive.payload, }), }; case "set_master_brightness": return { kind: "direct", summary: `master brightness set to ${Math.round(primitive.payload.value * 100)}%`, response: await sendCommand({ type: "set_master_brightness", payload: primitive.payload, }), }; case "upsert_group": return { kind: "direct", summary: `group ${primitive.payload.overwrite ? "updated" : "saved"}: ${primitive.payload.group_id}`, response: await sendCommand({ type: "upsert_group", payload: primitive.payload, }), }; case "request_snapshot": return { kind: "snapshot", snapshot: await fetchJson("/api/v1/snapshot"), }; case "set_pattern": ensureStatefulSession(client); if (!primitive.payload.pattern_id || !primitive.payload.pattern_id.trim()) { throw createClientError("invalid_pattern_id", "pattern_id must not be empty"); } client.pending.patternId = primitive.payload.pattern_id; return { kind: "buffered", summary: `pattern staged: ${primitive.payload.pattern_id}`, }; case "set_group_parameter": ensureStatefulSession(client); if (!primitive.payload.key || !primitive.payload.key.trim()) { throw createClientError( "invalid_group_parameter_key", "group parameter key must not be empty" ); } client.pending.hasGroupTarget = true; client.pending.groupId = primitive.payload.group_id == null ? null : primitive.payload.group_id; client.pending.parameters[primitive.payload.key] = primitive.payload.value; return { kind: "buffered", summary: `group parameter staged: ${primitive.payload.key} for ` + `${primitive.payload.group_id || "all_panels"}`, }; case "set_transition_style": ensureStatefulSession(client); client.pending.transitionStyle = primitive.payload.style; if (typeof primitive.payload.duration_ms === "number") { client.pending.transitionDurationMs = primitive.payload.duration_ms; } return { kind: "buffered", summary: `transition style staged: ${primitive.payload.style}`, }; case "trigger_transition": ensureStatefulSession(client); if (!client.pending.patternId) { throw createClientError( "transition_pattern_required", "trigger_transition requires a staged pattern" ); } client.commitState = "committing"; if (client.pending.hasGroupTarget) { await sendCommand( { type: "select_group", payload: { group_id: client.pending.groupId }, }, { announce: false, refresh: false } ); } if (client.pending.transitionDurationMs !== null) { await sendCommand( { type: "set_transition_duration_ms", payload: { duration_ms: client.pending.transitionDurationMs }, }, { announce: false, refresh: false } ); } if (client.pending.transitionStyle) { await sendCommand( { type: "set_transition_style", payload: { style: client.pending.transitionStyle }, }, { announce: false, refresh: false } ); } await sendCommand( { type: "select_pattern", payload: { pattern_id: client.pending.patternId }, }, { announce: false, refresh: false } ); const parameterEntries = Object.entries(client.pending.parameters); for (let index = 0; index < parameterEntries.length; index += 1) { const entry = parameterEntries[index]; await sendCommand( { type: "set_scene_parameter", payload: { key: entry[0], value: entry[1] }, }, { announce: false, refresh: false } ); } const summary = client.pending.hasGroupTarget ? `transition triggered: ${client.pending.patternId} on ${client.pending.groupId || "all_panels"}` : `transition triggered: ${client.pending.patternId}`; client.clearPending(); client.commitState = "committed"; await refreshAll(); return { kind: "command", summary: summary, }; default: throw createClientError("unknown_primitive", `unknown primitive '${primitive.primitive}'`); } } function ensureStatefulSession(client) { if (client.mode !== "stateful") { throw createClientError( "show_control_session_required", "staged show-control primitives require a stateful session" ); } } function createEmptyPendingState() { return { patternId: null, hasGroupTarget: false, groupId: null, parameters: {}, transitionStyle: null, transitionDurationMs: null, }; } function hasPendingState(pending) { return Boolean( pending.patternId || pending.hasGroupTarget || Object.keys(pending.parameters).length || pending.transitionStyle || pending.transitionDurationMs !== null ); } function createClientError(code, message) { const error = new Error(message); error.code = code; return error; } function handleClientError(error, fallbackCode) { apiState.controlClient.commitState = "error"; pushEvent({ at: new Date().toLocaleTimeString(), kind: "error", code: error.code || fallbackCode || "primitive_failed", message: error.message, }); } function renderAll(force) { renderState(force === true); renderPreview(force === true); renderEvents(true); } function renderLocalUi() { const state = apiState.stateResponse ? apiState.stateResponse.state : null; renderTopBar(state); renderPendingSession(); renderPatternSelection(state); renderParameterSections(state ? state.active_scene : null, state ? state.global : null, false); renderSelectedTile(); renderViewOutput(state, false); renderSnapshotJson(); } function renderState(force) { const state = apiState.stateResponse ? apiState.stateResponse.state : null; if (!state) { renderPendingSession(); return; } reconcileSelection(); renderTopBar(state); renderPatternSelection(state); renderPresets(state, force); renderCreativeSnapshots(force); renderParameterSections(state.active_scene, state.global, force); renderSummaryCards(state); renderSelectedTile(); renderViewOutput(state, force); renderPendingSession(); renderSnapshotJson(); } function renderTopBar(state) { if (!state) { return; } const global = state.global; const scene = state.active_scene; const pending = apiState.controlClient.pending; const displayedTransitionDurationMs = pending.transitionDurationMs !== null ? pending.transitionDurationMs : global.transition_duration_ms; const displayedTransitionStyle = pending.transitionStyle || global.transition_style; const displayedTempoBpm = displayedTempoFromState(state, pending); const displayedTransitionSeconds = durationMsToSeconds(displayedTransitionDurationMs); if (dom.projectName) { dom.projectName.textContent = state.system.project_name; } if (dom.topologyLabel) { dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`; } if (document.activeElement !== dom.tempoBpmInput) { dom.tempoBpmInput.value = String(displayedTempoBpm); } dom.tempoBpmLabel.textContent = `${displayedTempoBpm} BPM`; if (document.activeElement !== dom.transitionSecondsInput) { dom.transitionSecondsInput.value = displayedTransitionSeconds.toFixed(1); } dom.transitionSecondsLabel.textContent = `${displayedTransitionSeconds.toFixed(1)} s`; if (document.activeElement !== dom.transitionStyleSelect) { dom.transitionStyleSelect.value = displayedTransitionStyle; } if (document.activeElement !== dom.brightnessSlider) { dom.brightnessSlider.value = String(global.master_brightness); } dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`; if (dom.previewModeLabel) { dom.previewModeLabel.textContent = "LEDs Only"; } dom.workModeSelect.value = apiState.ui.workMode; const hasPending = apiState.controlClient.hasPending(); if (apiState.ui.workMode === "show_event" && hasPending) { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Next (${operatorModeLabelForPatternId(pending.patternId || scene.pattern_id)})`; } } else if (apiState.ui.workMode === "show_event") { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Live (${scene.preset_id || operatorModeLabelForPatternId(scene.pattern_id)})`; } } else { if (dom.editContextLabel) { dom.editContextLabel.textContent = `Edit: Live (${operatorModeLabelForPatternId(scene.pattern_id)})`; } } if (dom.controlModePill) { dom.controlModePill.textContent = apiState.ui.workMode === "show_event" ? "Show/Event" : "Test/Edit"; dom.controlModePill.className = apiState.ui.workMode === "show_event" ? "status-chip status-chip-warning" : "status-chip status-chip-live"; } const blackoutLabel = global.blackout ? "Blackout Active" : "Blackout"; dom.blackoutButton.textContent = blackoutLabel; dom.utilityBlackoutButton.textContent = blackoutLabel; dom.blackoutButton.classList.toggle("is-active", global.blackout); dom.utilityBlackoutButton.classList.toggle("is-active", global.blackout); } function renderPatternSelection(state) { if (!state) { return; } const modes = availableOperatorModes(); const signature = modes.map((mode) => `${mode.mode_id}:${mode.pattern_id}`).join("|"); if (signature !== apiState.ui.patternsSignature) { apiState.ui.patternsSignature = signature; dom.patternSelect.innerHTML = ""; modes.forEach((mode) => { const option = document.createElement("option"); option.value = mode.mode_id; option.textContent = mode.label; dom.patternSelect.appendChild(option); }); } const selectedModeId = displayedOperatorModeId(state, modes); if (document.activeElement !== dom.patternSelect) { dom.patternSelect.value = selectedModeId; } } function renderParameterSections(scene, global, force) { if (!scene || !global) { dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; apiState.ui.parameterCards.clear(); return; } if ( !force && (dom.motionParams.contains(document.activeElement) || dom.colorParams.contains(document.activeElement)) ) { return; } const parameters = (scene.parameters || []).filter( (parameter) => !HIDDEN_PARAMETER_KEYS.has(parameter.key) ); const signature = parameters .map((parameter) => `${parameter.key}:${parameter.kind}`) .join("|"); if (signature !== apiState.ui.parameterSignature) { apiState.ui.parameterSignature = signature; apiState.ui.parameterCards.clear(); dom.motionParams.innerHTML = ""; dom.colorParams.innerHTML = ""; parameters.forEach((parameter) => { const card = createParameterCard(parameter, global); apiState.ui.parameterCards.set(parameter.key, card); parameterContainer(parameter).appendChild(card.root); }); } parameters.forEach((parameter) => { const card = apiState.ui.parameterCards.get(parameter.key); if (card) { updateParameterCard(card, parameter, global); } }); } function parameterContainer(parameter) { if (COLOR_PARAM_KEYS.has(parameter.key)) { return dom.colorParams; } return dom.motionParams; } function createParameterCard(parameter, global) { const card = document.createElement("div"); card.className = "parameter-card"; card.dataset.key = parameter.key; const contract = controlContractFor(parameter.key); const controlKind = contract ? contract.control : inferControlKind(parameter); const header = document.createElement("div"); header.className = "parameter-header"; const title = document.createElement("strong"); title.textContent = contract && contract.label ? contract.label : parameter.label; const key = document.createElement("span"); key.className = "parameter-key"; key.textContent = parameter.key; header.appendChild(title); header.appendChild(key); card.appendChild(header); const readout = document.createElement("div"); readout.className = "parameter-readout"; let primaryInput = null; let secondaryInput = null; if (controlKind === "scalar") { primaryInput = document.createElement("input"); primaryInput.type = "range"; primaryInput.min = String(parameter.min_scalar == null ? 0 : parameter.min_scalar); primaryInput.max = String(parameter.max_scalar == null ? 1 : parameter.max_scalar); primaryInput.step = String(parameter.step == null ? 0.01 : parameter.step); primaryInput.addEventListener("input", async (event) => { const value = Number(event.target.value); readout.textContent = value.toFixed(2); await applySceneParameterChange(global.selected_group, parameter.key, { kind: "scalar", value: value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else if (controlKind === "toggle") { primaryInput = document.createElement("input"); primaryInput.type = "checkbox"; primaryInput.addEventListener("change", async (event) => { await applySceneParameterChange(global.selected_group, parameter.key, { kind: "toggle", value: event.target.checked, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else if (controlKind === "color") { const row = document.createElement("div"); row.className = "color-input-row"; primaryInput = document.createElement("input"); primaryInput.type = "color"; secondaryInput = document.createElement("input"); secondaryInput.type = "text"; primaryInput.addEventListener("input", async (event) => { secondaryInput.value = event.target.value.toUpperCase(); await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: event.target.value.toUpperCase(), }); }); secondaryInput.addEventListener("change", async (event) => { const normalized = normalizeColorHex(event.target.value); secondaryInput.value = normalized; primaryInput.value = normalized; await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: normalized, }); }); row.appendChild(primaryInput); row.appendChild(secondaryInput); card.appendChild(row); card.appendChild(readout); } else if (controlKind === "enum") { primaryInput = document.createElement("select"); const groupedOptions = contract && Array.isArray(contract.groups) ? contract.groups : []; if (groupedOptions.length) { groupedOptions.forEach((group) => { const groupNode = document.createElement("optgroup"); groupNode.label = group.label; group.options.forEach((option) => { const node = document.createElement("option"); node.value = option.value; node.textContent = option.label; groupNode.appendChild(node); }); primaryInput.appendChild(groupNode); }); } else { const options = contract && Array.isArray(contract.options) ? contract.options : []; options.forEach((option) => { const node = document.createElement("option"); node.value = option.value; node.textContent = option.label; primaryInput.appendChild(node); }); } primaryInput.addEventListener("change", async (event) => { const optionValue = event.target.value; const payloadValue = contract && contract.value_kind === "scalar" ? { kind: "scalar", value: Number(optionValue) } : { kind: "text", value: optionValue }; await applySceneParameterChange(global.selected_group, parameter.key, { kind: payloadValue.kind, value: payloadValue.value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } else { primaryInput = document.createElement("input"); primaryInput.type = "text"; primaryInput.addEventListener("change", async (event) => { await applySceneParameterChange(global.selected_group, parameter.key, { kind: "text", value: event.target.value, }); }); card.appendChild(primaryInput); card.appendChild(readout); } return { root: card, input: primaryInput, auxiliary: secondaryInput, readout: readout, kind: parameter.kind, controlKind: controlKind, contract: contract, }; } function updateParameterCard(card, parameter) { const hasStagedValue = Object.prototype.hasOwnProperty.call( apiState.controlClient.pending.parameters, parameter.key ); const stagedValue = hasStagedValue ? apiState.controlClient.pending.parameters[parameter.key] : null; const displayValue = hasStagedValue ? stagedValue : parameter.value; card.root.classList.toggle("is-staged", hasStagedValue); if (card.controlKind === "scalar") { const fallback = parameterDefaultScalar(parameter, card.contract); const value = parameterScalarValue(displayValue, fallback); if (document.activeElement !== card.input) { card.input.value = String(value); } card.readout.textContent = value.toFixed(2); return; } if (card.controlKind === "toggle") { const fallback = parameterDefaultToggle(card.contract); const value = parameterToggleValue(displayValue, fallback); if (document.activeElement !== card.input) { card.input.checked = value; } card.readout.textContent = card.input.checked ? "On" : "Off"; return; } if (card.controlKind === "color") { const normalized = normalizeColorHex( parameterTextValue(displayValue, parameterDefaultText(card.contract, "#000000")) ); if (document.activeElement !== card.input) { card.input.value = normalized; } if (document.activeElement !== card.auxiliary) { card.auxiliary.value = normalized; } card.readout.textContent = normalized; return; } if (card.controlKind === "enum") { const options = card.contract && Array.isArray(card.contract.options) ? card.contract.options : []; const fallbackValue = parameterDefaultText(card.contract, options[0] ? options[0].value : ""); const value = card.contract && card.contract.value_kind === "scalar" ? String(parameterScalarValue(displayValue, Number(fallbackValue))) : parameterTextValue(displayValue, fallbackValue); if (document.activeElement !== card.input) { card.input.value = value; } card.readout.textContent = optionLabelForValue(card.contract, value) || value; return; } const textValue = parameterTextValue(displayValue, parameterDefaultText(card.contract, "")); if (card.auxiliary) { const normalized = normalizeColorHex(textValue); if (document.activeElement !== card.input) { card.input.value = normalized; } if (document.activeElement !== card.auxiliary) { card.auxiliary.value = normalized; } card.readout.textContent = normalized; return; } if (document.activeElement !== card.input) { card.input.value = textValue; } card.readout.textContent = textValue || "Text"; } function controlContractFor(key) { return LEGACY_PARAMETER_CONTRACT[key] || null; } function inferControlKind(parameter) { if (parameter.kind === "scalar") { return "scalar"; } if (parameter.kind === "toggle") { return "toggle"; } if (looksLikeColorParameter(parameter)) { return "color"; } return "text"; } function optionLabelForValue(contract, value) { if (!contract || !Array.isArray(contract.options)) { return null; } const found = contract.options.find((option) => option.value === value); return found ? found.label : null; } function parameterScalarValue(parameterValue, fallback) { if (parameterValue && typeof parameterValue.value === "number" && Number.isFinite(parameterValue.value)) { return parameterValue.value; } return fallback; } function parameterToggleValue(parameterValue, fallback) { if (parameterValue && typeof parameterValue.value === "boolean") { return parameterValue.value; } return fallback; } function parameterTextValue(parameterValue, fallback) { if (parameterValue && parameterValue.value != null) { return String(parameterValue.value); } return fallback; } function parameterDefaultScalar(parameter, contract) { if (contract && typeof contract.default_value === "number") { return contract.default_value; } if ( parameter.default_value && typeof parameter.default_value.value === "number" && Number.isFinite(parameter.default_value.value) ) { return parameter.default_value.value; } if (parameter.min_scalar != null) { return Number(parameter.min_scalar); } return 0; } function parameterDefaultToggle(contract) { return contract ? Boolean(contract.default_value) : false; } function parameterDefaultText(contract, fallback) { if (contract && contract.default_value != null) { return String(contract.default_value); } return fallback; } function renderPresets(state, force) { const presets = apiState.catalog ? apiState.catalog.presets || [] : []; const signature = presets.map((preset) => preset.preset_id).join("|"); if ( apiState.ui.selectedPresetId && !presets.some((preset) => preset.preset_id === apiState.ui.selectedPresetId) ) { apiState.ui.selectedPresetId = null; } if (force || signature !== apiState.ui.presetsSignature) { apiState.ui.presetsSignature = signature; dom.presetList.innerHTML = ""; if (!presets.length) { dom.presetList.innerHTML = '