Files
ledgrab/server/src/wled_controller/static/js/features/kc-targets.js
T
alexei.dolgolyov 48651f0a4e Show uptime in target cards, fix dashboard uptime stale after tab switch
Add uptime metric to both LED and KC target cards in the targets tab.
Move formatUptime() to shared ui.js module. Fix dashboard uptime freezing
when switching tabs by re-caching DOM element refs on early return paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:36:14 +03:00

674 lines
29 KiB
JavaScript

/**
* Key Colors targets — cards, test lightbox, editor, WebSocket live colors.
*/
import {
kcTestAutoRefresh, setKcTestAutoRefresh,
kcTestTargetId, setKcTestTargetId,
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
_cachedValueSources, set_cachedValueSources,
} 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';
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,
};
}
}
const kcEditorModal = new KCEditorModal();
export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
const state = target.state || {};
const metrics = target.metrics || {};
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;
// 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]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}">⚡ ${kcSettings.fps ?? 10} fps</span>
</div>
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${brightnessInt}" data-kc-brightness="${target.id}"
oninput="updateKCBrightnessLabel('${target.id}', this.value)"
onchange="saveKCBrightness('${target.id}', this.value)"
title="${Math.round(brightness * 100)}%">
</div>
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${isProcessing ? `
<div class="card-content">
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value">${formatUptime(metrics.uptime_seconds)}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_calc_colors_ms}" title="calc ${state.timing_calc_colors_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_broadcast_ms}" title="broadcast ${state.timing_broadcast_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>calc ${state.timing_calc_colors_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>broadcast ${state.timing_broadcast_ms}ms</span>
</div>
</div>
` : ''}
</div>
` : ''}
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneKCTarget('${target.id}')" title="${t('common.clone')}">
📋
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS TEST =====
export async function fetchKCTest(targetId) {
const response = await fetch(`${API_BASE}/picture-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 = '&#x23F8;'; // pause symbol
} else {
btn.classList.remove('active');
btn.innerHTML = '&#x25B6;'; // play symbol
}
}
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 = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
result.rectangles.forEach((rect) => {
const c = rect.color;
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
statsHtml += `</div>`;
});
statsHtml += `</div>`;
// 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 typeIcons = { static: '📊', animated: '🔄', audio: '🎵' };
const icon = typeIcons[vs.source_type] || '🔢';
const opt = document.createElement('option');
opt.value = vs.id;
opt.textContent = `${icon} ${vs.name}`;
sel.appendChild(opt);
});
sel.value = selectedId || '';
}
export async function showKCEditor(targetId = null, cloneData = null) {
try {
// Load sources, pattern templates, and value sources in parallel
const [sourcesResp, patResp, vsResp] = await Promise.all([
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
fetchWithAuth('/value-sources').catch(() => null),
]);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
const valueSources = (vsResp && vsResp.ok) ? (await vsResp.json()).sources || [] : [];
set_cachedValueSources(valueSources);
// 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;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${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);
});
if (targetId) {
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
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';
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').textContent = t('kc.edit');
} else if (cloneData) {
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';
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').textContent = 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';
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').textContent = 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();
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('Failed to open key colors editor', 'error');
}
}
export function isKCEditorDirty() {
return kcEditorModal.isDirty();
}
export async function closeKCEditorModal() {
await kcEditorModal.close();
set_kcNameManuallyEdited(false);
}
export function forceCloseKCEditorModal() {
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,
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(`/picture-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetchWithAuth('/picture-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');
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 resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
showKCEditor(null, target);
} catch (error) {
console.error('Failed to clone KC target:', error);
showToast('Failed to clone key colors target', 'error');
}
}
export async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('kc.deleted'), 'success');
// Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to delete key colors target', '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}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ key_colors_settings: { brightness } }),
});
} catch (err) {
console.error('Failed to save KC brightness:', err);
}
}
// ===== 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}/picture-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 = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
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 `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`;
}).join('');
}