/** * Key Colors targets — cards, test lightbox, editor, WebSocket live colors. */ import { kcTestAutoRefresh, setKcTestAutoRefresh, kcTestTargetId, setKcTestTargetId, kcTestWs, setKcTestWs, kcTestFps, setKcTestFps, _kcNameManuallyEdited, set_kcNameManuallyEdited, kcWebSockets, PATTERN_RECT_BORDERS, _cachedValueSources, valueSourcesCache, streamsCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { getValueSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { IconSelect } from '../core/icon-select.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import type { OutputTarget } from '../types.ts'; let _kcTagsInput: any = null; class KCEditorModal extends Modal { constructor() { super('kc-editor-modal'); } snapshotValues() { return { name: (document.getElementById('kc-editor-name') as HTMLInputElement).value, source: (document.getElementById('kc-editor-source') as HTMLSelectElement).value, fps: (document.getElementById('kc-editor-fps') as HTMLInputElement).value, interpolation: (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value, smoothing: (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value, patternTemplateId: (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value, brightness_vs: (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value, tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []), }; } } const kcEditorModal = new KCEditorModal(); /* ── Visual selectors ─────────────────────────────────────────── */ const _icon = (d: any) => `${d}`; let _kcColorModeIconSelect: any = null; let _kcSourceEntitySelect: any = null; let _kcPatternEntitySelect: any = null; let _kcBrightnessEntitySelect: any = 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 } as any); } function _ensureSourceEntitySelect(sources: any) { 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: any) => ({ value: s.id, label: s.name, icon: getPictureSourceIcon(s.stream_type), desc: s.stream_type, })), placeholder: t('palette.search'), } as any); } } function _ensurePatternEntitySelect(patTemplates: any) { 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: any) => { 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'), } as any); } } 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: any) => ({ value: vs.id, label: vs.name, icon: getValueSourceIcon(vs.source_type), desc: vs.source_type, }))); }, placeholder: t('palette.search'), } as any); } } export function patchKCTargetMetrics(target: any) { const card = document.querySelector(`[data-kc-target-id="${CSS.escape(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"]') as HTMLElement; if (frames) { frames.textContent = formatCompact(metrics.frames_processed || 0); frames.title = String(metrics.frames_processed || 0); } const keepalive = card.querySelector('[data-tm="keepalive"]') as HTMLElement; if (keepalive) { keepalive.textContent = formatCompact(state.frames_keepalive ?? 0); keepalive.title = String(state.frames_keepalive ?? 0); } const errors = card.querySelector('[data-tm="errors"]') as HTMLElement; if (errors) { errors.textContent = formatCompact(metrics.errors_count || 0); errors.title = String(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: OutputTarget & { state?: any; metrics?: any; latestColors?: any }, sourceMap: Record, patternTemplateMap: Record, valueSourceMap: Record) { const state = target.state || {}; const kcSettings = target.key_colors_settings ?? {} as Partial; 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]: [string, any]) => `
${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 ===== function _openKCTestWs(targetId: any, fps: any, previewWidth = 480) { // Close any existing WS if (kcTestWs) { try { kcTestWs.close(); } catch (_) {} setKcTestWs(null); } 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}/test/ws?token=${encodeURIComponent(key)}&fps=${fps}&preview_width=${previewWidth}`; const ws = new WebSocket(wsUrl); ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'frame') { // Hide spinner on first frame const spinner = document.querySelector('.lightbox-spinner') as HTMLElement; if (spinner) spinner.style.display = 'none'; displayKCTestResults(data); } } catch (e) { console.error('KC test WS parse error:', e); } }; ws.onclose = (ev) => { setKcTestWs(null); // Only show error if closed unexpectedly (not a normal close) if (ev.code !== 1000 && ev.code !== 1001 && kcTestTargetId) { const reason = ev.reason || t('kc.test.ws_closed'); showToast(t('kc.test.error') + ': ' + reason, 'error'); // Close lightbox on fatal errors (auth, bad target, etc.) if (ev.code === 4001 || ev.code === 4003 || ev.code === 4004) { if (typeof window.closeLightbox === 'function') window.closeLightbox(); } } }; ws.onerror = () => { // onclose will fire after onerror; no need to handle here }; setKcTestWs(ws); setKcTestTargetId(targetId); } export async function testKCTarget(targetId: any) { setKcTestTargetId(targetId); // Show lightbox immediately with a spinner const lightbox = document.getElementById('image-lightbox')!; const lbImg = document.getElementById('lightbox-image') as HTMLImageElement; const statsEl = document.getElementById('lightbox-stats') as HTMLElement; lbImg.style.display = 'none'; lbImg.src = ''; statsEl.style.display = 'none'; // Insert spinner if not already present let spinner = lightbox.querySelector('.lightbox-spinner') as HTMLElement; if (!spinner) { spinner = document.createElement('div'); spinner.className = 'lightbox-spinner loading-spinner'; lightbox.querySelector('.lightbox-content')!.prepend(spinner); } spinner.style.display = ''; // Hide controls — KC test streams automatically const refreshBtn = document.getElementById('lightbox-auto-refresh') as HTMLElement; if (refreshBtn) refreshBtn.style.display = 'none'; const fpsSelect = document.getElementById('lightbox-fps-select') as HTMLElement; if (fpsSelect) fpsSelect.style.display = 'none'; lightbox.classList.add('active'); lockBody(); // Use same FPS from CSS test settings and dynamic preview resolution const fps = parseInt(localStorage.getItem('css_test_fps')!) || 15; const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2)); _openKCTestWs(targetId, fps, previewWidth); } export function stopKCTestAutoRefresh() { if (kcTestAutoRefresh) { clearInterval(kcTestAutoRefresh); setKcTestAutoRefresh(null); } if (kcTestWs) { try { kcTestWs.close(1000, 'lightbox closed'); } catch (_) {} setKcTestWs(null); } setKcTestTargetId(null); } export function displayKCTestResults(result: any) { 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: any, i: number) => { 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: any) => { 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') as HTMLElement; if (spinner) spinner.style.display = 'none'; const lbImg = document.getElementById('lightbox-image') as HTMLImageElement; const statsEl = document.getElementById('lightbox-stats') as HTMLElement; 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') as HTMLInputElement).value) return; const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement; const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; if (!sourceName) return; const mode = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value || 'average'; const modeName = t(`kc.interpolation.${mode}`); const patSelect = document.getElementById('kc-editor-pattern-template') as HTMLSelectElement; const patName = patSelect.selectedOptions[0]?.dataset?.name || ''; (document.getElementById('kc-editor-name') as HTMLInputElement).value = `${sourceName} \u00b7 ${patName} (${modeName})`; } function _populateKCBrightnessVsDropdown(selectedId = '') { const sel = document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement; // Keep the first "None" option, remove the rest while (sel.options.length > 1) sel.remove(1); _cachedValueSources.forEach((vs: any) => { 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: any = null, cloneData: any = null) { try { // Load sources, pattern templates, and value sources in parallel const [sources, patTemplates, valueSources] = await Promise.all([ streamsCache.fetch().catch((): any[] => []), patternTemplatesCache.fetch().catch((): any[] => []), valueSourcesCache.fetch(), ]); // Populate source select const sourceSelect = document.getElementById('kc-editor-source') as HTMLSelectElement; sourceSelect.innerHTML = ''; sources.forEach((s: any) => { 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') as HTMLSelectElement; patSelect.innerHTML = ''; patTemplates.forEach((pt: any) => { 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: any[] = []; 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') as HTMLInputElement).value = target.id; (document.getElementById('kc-editor-name') as HTMLInputElement).value = target.name; sourceSelect.value = target.picture_source_id || ''; (document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10; (document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10; (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average'); (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3; (document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); (document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`; } else if (cloneData) { _editorTags = cloneData.tags || []; const kcSettings = cloneData.key_colors_settings || {}; (document.getElementById('kc-editor-id') as HTMLInputElement).value = ''; (document.getElementById('kc-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)'; sourceSelect.value = cloneData.picture_source_id || ''; (document.getElementById('kc-editor-fps') as HTMLInputElement).value = kcSettings.fps ?? 10; (document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = kcSettings.fps ?? 10; (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = kcSettings.interpolation_mode ?? 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue(kcSettings.interpolation_mode ?? 'average'); (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = kcSettings.smoothing ?? 0.3; (document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); (document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`; } else { (document.getElementById('kc-editor-id') as HTMLInputElement).value = ''; (document.getElementById('kc-editor-name') as HTMLInputElement).value = ''; if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0; (document.getElementById('kc-editor-fps') as HTMLInputElement).value = '10' as any; (document.getElementById('kc-editor-fps-value') as HTMLElement).textContent = '10'; (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value = 'average'; if (_kcColorModeIconSelect) _kcColorModeIconSelect.setValue('average'); (document.getElementById('kc-editor-smoothing') as HTMLInputElement).value = '0.3' as any; (document.getElementById('kc-editor-smoothing-value') as HTMLElement).textContent = '0.3'; if (patTemplates.length > 0) patSelect.value = patTemplates[0].id; _populateKCBrightnessVsDropdown(''); (document.getElementById('kc-editor-title') as HTMLElement).innerHTML = `${ICON_PALETTE} ${t('kc.add')}`; } // Auto-name set_kcNameManuallyEdited(!!(targetId || cloneData)); (document.getElementById('kc-editor-name') as HTMLInputElement).oninput = () => { set_kcNameManuallyEdited(true); }; sourceSelect.onchange = () => _autoGenerateKCName(); (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).onchange = () => _autoGenerateKCName(); patSelect.onchange = () => _autoGenerateKCName(); if (!targetId && !cloneData) _autoGenerateKCName(); // Tags if (_kcTagsInput) _kcTagsInput.destroy(); _kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), { placeholder: t('tags.placeholder'), }); _kcTagsInput.setValue(_editorTags); kcEditorModal.snapshot(); kcEditorModal.open(); (document.getElementById('kc-editor-error') as HTMLElement).style.display = 'none'; setTimeout(() => desktopFocus(document.getElementById('kc-editor-name')), 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') as HTMLInputElement).value; const name = (document.getElementById('kc-editor-name') as HTMLInputElement).value.trim(); const sourceId = (document.getElementById('kc-editor-source') as HTMLSelectElement).value; const fps = parseInt((document.getElementById('kc-editor-fps') as HTMLInputElement).value) || 10; const interpolation = (document.getElementById('kc-editor-interpolation') as HTMLSelectElement).value; const smoothing = parseFloat((document.getElementById('kc-editor-smoothing') as HTMLInputElement).value); const patternTemplateId = (document.getElementById('kc-editor-pattern-template') as HTMLSelectElement).value; const brightnessVsId = (document.getElementById('kc-editor-brightness-vs') as HTMLSelectElement).value; if (!name) { kcEditorModal.showError(t('kc.error.required')); return; } if (!patternTemplateId) { kcEditorModal.showError(t('kc.error.no_pattern')); return; } const payload: any = { 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: any) { if (error.isAuth) return; console.error('Error saving KC target:', error); kcEditorModal.showError(error.message); } } export async function cloneKCTarget(targetId: any) { try { const targets = await outputTargetsCache.fetch(); const target = targets.find((t: any) => t.id === targetId); if (!target) throw new Error('Target not found'); showKCEditor(null, target); } catch (error: any) { if (error.isAuth) return; showToast(t('kc_target.error.clone_failed'), 'error'); } } export async function deleteKCTarget(targetId: any) { 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: any) { if (error.isAuth) return; showToast(t('kc_target.error.delete_failed'), 'error'); } } // ===== KC BRIGHTNESS ===== export function updateKCBrightnessLabel(targetId: any, value: any) { const slider = document.querySelector(`[data-kc-brightness="${CSS.escape(targetId)}"]`) as HTMLElement; if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%'; } export async function saveKCBrightness(targetId: any, value: any) { 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: any) { // 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: any) { 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: any, colors: any) { 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]: [string, any]) => { 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(''); }