/** * Key Colors targets — cards, test lightbox, editor, WebSocket live colors. */ import { kcTestAutoRefresh, setKcTestAutoRefresh, kcTestTargetId, setKcTestTargetId, _kcNameManuallyEdited, set_kcNameManuallyEdited, kcWebSockets, PATTERN_RECT_BORDERS, _cachedValueSources, valueSourcesCache, streamsCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { lockBody, showToast, showConfirm, formatUptime } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { getValueSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_PAUSE, ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE, } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; let _kcTagsInput = null; class KCEditorModal extends Modal { constructor() { super('kc-editor-modal'); } snapshotValues() { return { name: document.getElementById('kc-editor-name').value, source: document.getElementById('kc-editor-source').value, fps: document.getElementById('kc-editor-fps').value, interpolation: document.getElementById('kc-editor-interpolation').value, smoothing: document.getElementById('kc-editor-smoothing').value, patternTemplateId: document.getElementById('kc-editor-pattern-template').value, brightness_vs: document.getElementById('kc-editor-brightness-vs').value, tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []), }; } } const kcEditorModal = new KCEditorModal(); /* ── Visual selectors ─────────────────────────────────────────── */ const _icon = (d) => `${d}`; let _kcColorModeIconSelect = null; let _kcSourceEntitySelect = null; let _kcPatternEntitySelect = null; let _kcBrightnessEntitySelect = null; // Inline SVG previews for color modes const _COLOR_MODE_SVG = { average: '', median: '', dominant: '', }; function _ensureColorModeIconSelect() { const sel = document.getElementById('kc-editor-interpolation'); if (!sel) return; const items = [ { value: 'average', icon: _COLOR_MODE_SVG.average, label: t('kc.interpolation.average'), desc: t('kc.interpolation.average.desc') }, { value: 'median', icon: _COLOR_MODE_SVG.median, label: t('kc.interpolation.median'), desc: t('kc.interpolation.median.desc') }, { value: 'dominant', icon: _COLOR_MODE_SVG.dominant, label: t('kc.interpolation.dominant'), desc: t('kc.interpolation.dominant.desc') }, ]; if (_kcColorModeIconSelect) { _kcColorModeIconSelect.updateItems(items); return; } _kcColorModeIconSelect = new IconSelect({ target: sel, items, columns: 3 }); } function _ensureSourceEntitySelect(sources) { const sel = document.getElementById('kc-editor-source'); if (!sel) return; if (_kcSourceEntitySelect) _kcSourceEntitySelect.destroy(); if (sources.length > 0) { _kcSourceEntitySelect = new EntitySelect({ target: sel, getItems: () => sources.map(s => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type, })), placeholder: t('palette.search'), }); } } function _ensurePatternEntitySelect(patTemplates) { const sel = document.getElementById('kc-editor-pattern-template'); if (!sel) return; if (_kcPatternEntitySelect) _kcPatternEntitySelect.destroy(); if (patTemplates.length > 0) { _kcPatternEntitySelect = new EntitySelect({ target: sel, getItems: () => patTemplates.map(pt => { const rectCount = (pt.rectangles || []).length; return { value: pt.id, label: pt.name, icon: _icon(P.fileText), desc: `${rectCount} rect${rectCount !== 1 ? 's' : ''}`, }; }), placeholder: t('palette.search'), }); } } function _ensureBrightnessEntitySelect() { const sel = document.getElementById('kc-editor-brightness-vs'); if (!sel) return; if (_kcBrightnessEntitySelect) _kcBrightnessEntitySelect.destroy(); if (_cachedValueSources.length > 0) { _kcBrightnessEntitySelect = new EntitySelect({ target: sel, getItems: () => { const items = [{ value: '', label: t('kc.brightness_vs.none'), icon: _icon(P.sunDim), desc: '' }]; return items.concat(_cachedValueSources.map(vs => ({ value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, }))); }, placeholder: t('palette.search'), }); } } export function patchKCTargetMetrics(target) { const card = document.querySelector(`[data-kc-target-id="${target.id}"]`); if (!card) return; const state = target.state || {}; const metrics = target.metrics || {}; const fpsActual = card.querySelector('[data-tm="fps-actual"]'); if (fpsActual) fpsActual.textContent = state.fps_actual?.toFixed(1) || '0.0'; const fpsCurrent = card.querySelector('[data-tm="fps-current"]'); if (fpsCurrent) fpsCurrent.textContent = state.fps_current ?? '-'; const fpsTarget = card.querySelector('[data-tm="fps-target"]'); if (fpsTarget) fpsTarget.textContent = state.fps_target || 0; const frames = card.querySelector('[data-tm="frames"]'); if (frames) frames.textContent = metrics.frames_processed || 0; const keepalive = card.querySelector('[data-tm="keepalive"]'); if (keepalive) keepalive.textContent = state.frames_keepalive ?? '-'; const errors = card.querySelector('[data-tm="errors"]'); if (errors) errors.textContent = metrics.errors_count || 0; const uptime = card.querySelector('[data-tm="uptime"]'); if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds); const timing = card.querySelector('[data-tm="timing"]'); if (timing && state.timing_total_ms != null) { timing.innerHTML = `
${t('device.metrics.timing')}
${state.timing_total_ms}ms
calc ${state.timing_calc_colors_ms}ms smooth ${state.timing_smooth_ms}ms broadcast ${state.timing_broadcast_ms}ms
`; } } export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueSourceMap) { const state = target.state || {}; const kcSettings = target.key_colors_settings || {}; const isProcessing = state.processing || false; const brightness = kcSettings.brightness ?? 1.0; const brightnessInt = Math.round(brightness * 255); const source = sourceMap[target.picture_source_id]; const sourceName = source ? source.name : (target.picture_source_id || 'No source'); const patTmpl = patternTemplateMap[kcSettings.pattern_template_id]; const patternName = patTmpl ? patTmpl.name : 'No pattern'; const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0; const bvsId = kcSettings.brightness_value_source_id || ''; const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null; // Render initial color swatches from pre-fetched REST data let swatchesHtml = ''; const latestColors = target.latestColors && target.latestColors.colors; if (isProcessing && latestColors && Object.keys(latestColors).length > 0) { swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
${escapeHtml(name)}
`).join(''); } else if (isProcessing) { swatchesHtml = `${t('kc.colors.none')}`; } return wrapCard({ dataAttr: 'data-kc-target-id', id: target.id, removeOnclick: `deleteKCTarget('${target.id}')`, removeTitle: t('common.delete'), content: `
${escapeHtml(target.name)}
${ICON_LINK_SOURCE} ${escapeHtml(sourceName)} ${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)} ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} ${ICON_FPS} ${kcSettings.fps ?? 10} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
${renderTagChips(target.tags)}
${swatchesHtml}
${isProcessing ? `
${t('device.metrics.actual_fps')}
---
${t('device.metrics.current_fps')}
---
${t('device.metrics.target_fps')}
---
${t('device.metrics.frames')}
---
${t('device.metrics.keepalive')}
---
${t('device.metrics.errors')}
---
${t('device.metrics.uptime')}
---
${state.timing_total_ms != null ? `
` : ''}
` : ''}`, actions: ` ${isProcessing ? ` ` : ` `} `, }); } // ===== KEY COLORS TEST ===== export async function fetchKCTest(targetId) { const response = await fetch(`${API_BASE}/output-targets/${targetId}/test`, { method: 'POST', headers: getHeaders(), }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || response.statusText); } return response.json(); } export async function testKCTarget(targetId) { setKcTestTargetId(targetId); // Show lightbox immediately with a spinner const lightbox = document.getElementById('image-lightbox'); const lbImg = document.getElementById('lightbox-image'); const statsEl = document.getElementById('lightbox-stats'); lbImg.style.display = 'none'; lbImg.src = ''; statsEl.style.display = 'none'; // Insert spinner if not already present let spinner = lightbox.querySelector('.lightbox-spinner'); if (!spinner) { spinner = document.createElement('div'); spinner.className = 'lightbox-spinner loading-spinner'; lightbox.querySelector('.lightbox-content').prepend(spinner); } spinner.style.display = ''; // Show auto-refresh button const refreshBtn = document.getElementById('lightbox-auto-refresh'); if (refreshBtn) refreshBtn.style.display = ''; lightbox.classList.add('active'); lockBody(); try { const result = await fetchKCTest(targetId); displayKCTestResults(result); } catch (e) { // Use window.closeLightbox to avoid importing from ui.js circular if (typeof window.closeLightbox === 'function') window.closeLightbox(); showToast(t('kc.test.error') + ': ' + e.message, 'error'); } } export function toggleKCTestAutoRefresh() { if (kcTestAutoRefresh) { stopKCTestAutoRefresh(); } else { setKcTestAutoRefresh(setInterval(async () => { if (!kcTestTargetId) return; try { const result = await fetchKCTest(kcTestTargetId); displayKCTestResults(result); } catch (e) { stopKCTestAutoRefresh(); } }, 2000)); updateAutoRefreshButton(true); } } export function stopKCTestAutoRefresh() { if (kcTestAutoRefresh) { clearInterval(kcTestAutoRefresh); setKcTestAutoRefresh(null); } setKcTestTargetId(null); updateAutoRefreshButton(false); } export function updateAutoRefreshButton(active) { const btn = document.getElementById('lightbox-auto-refresh'); if (!btn) return; if (active) { btn.classList.add('active'); btn.innerHTML = ICON_PAUSE; } else { btn.classList.remove('active'); btn.innerHTML = ICON_START; } } export function displayKCTestResults(result) { const srcImg = new window.Image(); srcImg.onload = () => { const canvas = document.createElement('canvas'); canvas.width = srcImg.width; canvas.height = srcImg.height; const ctx = canvas.getContext('2d'); // Draw captured frame ctx.drawImage(srcImg, 0, 0); const w = srcImg.width; const h = srcImg.height; // Draw each rectangle with extracted color overlay result.rectangles.forEach((rect, i) => { const px = rect.x * w; const py = rect.y * h; const pw = rect.width * w; const ph = rect.height * h; const color = rect.color; const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length]; // Semi-transparent fill with the extracted color ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`; ctx.fillRect(px, py, pw, ph); // Border using pattern colors for distinction ctx.strokeStyle = borderColor; ctx.lineWidth = 3; ctx.strokeRect(px, py, pw, ph); // Color swatch in top-left corner of rect const swatchSize = Math.max(16, Math.min(32, pw * 0.15)); ctx.fillStyle = color.hex; ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize); // Name label with shadow for readability const fontSize = Math.max(12, Math.min(18, pw * 0.06)); ctx.font = `bold ${fontSize}px sans-serif`; const labelX = px + swatchSize + 10; const labelY = py + 4 + swatchSize / 2 + fontSize / 3; ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 4; ctx.fillStyle = '#fff'; ctx.fillText(rect.name, labelX, labelY); // Hex label below name ctx.font = `${fontSize - 2}px monospace`; ctx.fillText(color.hex, labelX, labelY + fontSize + 2); ctx.shadowBlur = 0; }); const dataUrl = canvas.toDataURL('image/jpeg', 0.92); // Build stats HTML let statsHtml = `
`; statsHtml += `${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}`; result.rectangles.forEach((rect) => { const c = rect.color; statsHtml += `
`; statsHtml += `
`; statsHtml += `${escapeHtml(rect.name)} ${c.hex}`; statsHtml += `
`; }); statsHtml += `
`; // Hide spinner, show result in the already-open lightbox const spinner = document.querySelector('.lightbox-spinner'); if (spinner) spinner.style.display = 'none'; const lbImg = document.getElementById('lightbox-image'); const statsEl = document.getElementById('lightbox-stats'); lbImg.src = dataUrl; lbImg.style.display = ''; statsEl.innerHTML = statsHtml; statsEl.style.display = ''; }; srcImg.src = result.image; } // ===== KEY COLORS EDITOR ===== function _autoGenerateKCName() { if (_kcNameManuallyEdited) return; if (document.getElementById('kc-editor-id').value) return; const sourceSelect = document.getElementById('kc-editor-source'); const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; if (!sourceName) return; const mode = document.getElementById('kc-editor-interpolation').value || 'average'; const modeName = t(`kc.interpolation.${mode}`); const patSelect = document.getElementById('kc-editor-pattern-template'); const patName = patSelect.selectedOptions[0]?.dataset?.name || ''; document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`; } function _populateKCBrightnessVsDropdown(selectedId = '') { const sel = document.getElementById('kc-editor-brightness-vs'); // Keep the first "None" option, remove the rest while (sel.options.length > 1) sel.remove(1); _cachedValueSources.forEach(vs => { const opt = document.createElement('option'); opt.value = vs.id; opt.textContent = vs.name; sel.appendChild(opt); }); sel.value = selectedId || ''; _ensureBrightnessEntitySelect(); } export async function showKCEditor(targetId = null, cloneData = null) { try { // Load sources, pattern templates, and value sources in parallel const [sources, patTemplates, valueSources] = await Promise.all([ streamsCache.fetch().catch(() => []), patternTemplatesCache.fetch().catch(() => []), valueSourcesCache.fetch(), ]); // Populate source select const sourceSelect = document.getElementById('kc-editor-source'); sourceSelect.innerHTML = ''; sources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.dataset.name = s.name; opt.textContent = s.name; sourceSelect.appendChild(opt); }); // Populate pattern template select const patSelect = document.getElementById('kc-editor-pattern-template'); patSelect.innerHTML = ''; patTemplates.forEach(pt => { const opt = document.createElement('option'); opt.value = pt.id; opt.dataset.name = pt.name; const rectCount = (pt.rectangles || []).length; opt.textContent = `${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`; patSelect.appendChild(opt); }); // Set up visual selectors _ensureColorModeIconSelect(); _ensureSourceEntitySelect(sources); _ensurePatternEntitySelect(patTemplates); let _editorTags = []; if (targetId) { const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); _editorTags = target.tags || []; const kcSettings = target.key_colors_settings || {}; document.getElementById('kc-editor-id').value = target.id; document.getElementById('kc-editor-name').value = target.name; sourceSelect.value = target.picture_source_id || ''; document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10; document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10; document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average'); document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`; } else if (cloneData) { _editorTags = cloneData.tags || []; const kcSettings = cloneData.key_colors_settings || {}; document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)'; sourceSelect.value = cloneData.picture_source_id || ''; document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10; document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10; document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average'); document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`; } else { document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-name').value = ''; if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0; document.getElementById('kc-editor-fps').value = 10; document.getElementById('kc-editor-fps-value').textContent = '10'; document.getElementById('kc-editor-interpolation').value = 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue('average'); document.getElementById('kc-editor-smoothing').value = 0.3; document.getElementById('kc-editor-smoothing-value').textContent = '0.3'; if (patTemplates.length > 0) patSelect.value = patTemplates[0].id; _populateKCBrightnessVsDropdown(''); document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.add')}`; } // Auto-name set_kcNameManuallyEdited(!!(targetId || cloneData)); document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); }; sourceSelect.onchange = () => _autoGenerateKCName(); document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName(); patSelect.onchange = () => _autoGenerateKCName(); if (!targetId && !cloneData) _autoGenerateKCName(); // Tags if (_kcTagsInput) _kcTagsInput.destroy(); _kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), { placeholder: window.t ? t('tags.placeholder') : 'Add tag...' }); _kcTagsInput.setValue(_editorTags); kcEditorModal.snapshot(); kcEditorModal.open(); document.getElementById('kc-editor-error').style.display = 'none'; setTimeout(() => document.getElementById('kc-editor-name').focus(), 100); } catch (error) { console.error('Failed to open KC editor:', error); showToast(t('kc_target.error.editor_open_failed'), 'error'); } } export function isKCEditorDirty() { return kcEditorModal.isDirty(); } export async function closeKCEditorModal() { await kcEditorModal.close(); set_kcNameManuallyEdited(false); } export function forceCloseKCEditorModal() { if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; } kcEditorModal.forceClose(); set_kcNameManuallyEdited(false); } export async function saveKCEditor() { const targetId = document.getElementById('kc-editor-id').value; const name = document.getElementById('kc-editor-name').value.trim(); const sourceId = document.getElementById('kc-editor-source').value; const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10; const interpolation = document.getElementById('kc-editor-interpolation').value; const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value); const patternTemplateId = document.getElementById('kc-editor-pattern-template').value; const brightnessVsId = document.getElementById('kc-editor-brightness-vs').value; if (!name) { kcEditorModal.showError(t('kc.error.required')); return; } if (!patternTemplateId) { kcEditorModal.showError(t('kc.error.no_pattern')); return; } const payload = { name, picture_source_id: sourceId, tags: _kcTagsInput ? _kcTagsInput.getValue() : [], key_colors_settings: { fps, interpolation_mode: interpolation, smoothing, pattern_template_id: patternTemplateId, brightness_value_source_id: brightnessVsId, }, }; try { let response; if (targetId) { response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { payload.target_type = 'key_colors'; response = await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload), }); } if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Failed to save'); } showToast(targetId ? t('kc.updated') : t('kc.created'), 'success'); outputTargetsCache.invalidate(); kcEditorModal.forceClose(); // Use window.* to avoid circular import with targets.js if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } catch (error) { if (error.isAuth) return; console.error('Error saving KC target:', error); kcEditorModal.showError(error.message); } } export async function cloneKCTarget(targetId) { try { const targets = await outputTargetsCache.fetch(); const target = targets.find(t => t.id === targetId); if (!target) throw new Error('Target not found'); showKCEditor(null, target); } catch (error) { if (error.isAuth) return; showToast(t('kc_target.error.clone_failed'), 'error'); } } export async function deleteKCTarget(targetId) { const confirmed = await showConfirm(t('kc.delete.confirm')); if (!confirmed) return; try { disconnectKCWebSocket(targetId); const response = await fetchWithAuth(`/output-targets/${targetId}`, { method: 'DELETE', }); if (response.ok) { showToast(t('kc.deleted'), 'success'); outputTargetsCache.invalidate(); // Use window.* to avoid circular import with targets.js if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } else { const error = await response.json(); showToast(error.detail || t('kc_target.error.delete_failed'), 'error'); } } catch (error) { if (error.isAuth) return; showToast(t('kc_target.error.delete_failed'), 'error'); } } // ===== KC BRIGHTNESS ===== export function updateKCBrightnessLabel(targetId, value) { const slider = document.querySelector(`[data-kc-brightness="${targetId}"]`); if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; } export async function saveKCBrightness(targetId, value) { const brightness = parseInt(value) / 255; try { await fetch(`${API_BASE}/output-targets/${targetId}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ key_colors_settings: { brightness } }), }); } catch (err) { console.error('Failed to save KC brightness:', err); showToast(t('kc.error.brightness') || 'Failed to save brightness', 'error'); } } // ===== KEY COLORS WEBSOCKET ===== export function connectKCWebSocket(targetId) { // Disconnect existing connection if any disconnectKCWebSocket(targetId); const key = localStorage.getItem('wled_api_key'); if (!key) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/ws?token=${encodeURIComponent(key)}`; try { const ws = new WebSocket(wsUrl); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); updateKCColorSwatches(targetId, data.colors || {}); } catch (e) { console.error('Failed to parse KC WebSocket message:', e); } }; ws.onclose = () => { delete kcWebSockets[targetId]; }; ws.onerror = (error) => { console.error(`KC WebSocket error for ${targetId}:`, error); }; kcWebSockets[targetId] = ws; } catch (error) { console.error(`Failed to connect KC WebSocket for ${targetId}:`, error); } } export function disconnectKCWebSocket(targetId) { const ws = kcWebSockets[targetId]; if (ws) { ws.close(); delete kcWebSockets[targetId]; } } export function disconnectAllKCWebSockets() { Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId)); } export function updateKCColorSwatches(targetId, colors) { const container = document.getElementById(`kc-swatches-${targetId}`); if (!container) return; const entries = Object.entries(colors); if (entries.length === 0) { container.innerHTML = `${t('kc.colors.none')}`; return; } container.innerHTML = entries.map(([name, color]) => { const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`; return `
${escapeHtml(name)}
`; }).join(''); }