Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/kc-targets.js
alexei.dolgolyov 30fa107ef7 Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:20:19 +03:00

829 lines
36 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, 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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _kcColorModeIconSelect = null;
let _kcSourceEntitySelect = null;
let _kcPatternEntitySelect = null;
let _kcBrightnessEntitySelect = null;
// Inline SVG previews for color modes
const _COLOR_MODE_SVG = {
average: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="52" height="16" rx="3" opacity="0.3" fill="currentColor"/><path d="M30 8v8" stroke-width="1.5"/><path d="M20 10v4" stroke-width="1.5"/><path d="M40 10v4" stroke-width="1.5"/></svg>',
median: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="14" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/><rect x="18" y="8" width="8" height="12" rx="1" fill="currentColor" opacity="0.5"/><rect x="30" y="4" width="8" height="16" rx="1" fill="currentColor" opacity="0.7"/><rect x="42" y="10" width="8" height="10" rx="1" fill="currentColor" opacity="0.4"/></svg>',
dominant: '<svg viewBox="0 0 60 24" width="60" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="20" cy="12" r="4" opacity="0.2" fill="currentColor"/><circle cx="30" cy="12" r="8" fill="currentColor" opacity="0.6"/><circle cx="42" cy="12" r="3" opacity="0.15" fill="currentColor"/></svg>',
};
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 = `
<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>`;
}
}
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]) => `
<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 wrapCard({
dataAttr: 'data-kc-target-id',
id: target.id,
removeOnclick: `deleteKCTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','key_colors','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_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')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
${renderTagChips(target.tags)}
<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" data-tm="fps-actual">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value" data-tm="fps-current">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value" data-tm="fps-target">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value" data-tm="frames">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value" data-tm="keepalive">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value" data-tm="errors">---</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.uptime')}</div>
<div class="metric-value" data-tm="uptime">---</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown" data-tm="timing"></div>
` : ''}
</div>
` : ''}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
${ICON_STOP}
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
${ICON_START}
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
${ICON_TEST}
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneKCTarget('${target.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>`,
});
}
// ===== 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 = `<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 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 = `<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('');
}