Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
603
server/src/wled_controller/static/js/features/kc-targets.js
Normal file
603
server/src/wled_controller/static/js/features/kc-targets.js
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* Key Colors targets — cards, test lightbox, editor, WebSocket live colors.
|
||||
*/
|
||||
|
||||
import {
|
||||
kcTestAutoRefresh, setKcTestAutoRefresh,
|
||||
kcTestTargetId, setKcTestTargetId,
|
||||
kcEditorInitialValues, setKcEditorInitialValues,
|
||||
_kcNameManuallyEdited, set_kcNameManuallyEdited,
|
||||
kcWebSockets,
|
||||
PATTERN_RECT_BORDERS,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
|
||||
|
||||
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 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')}">✕</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>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
||||
${swatchesHtml}
|
||||
</div>
|
||||
${isProcessing ? `
|
||||
<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.potential_fps')}</div>
|
||||
<div class="metric-value">${state.fps_potential?.toFixed(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>
|
||||
${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="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();
|
||||
}
|
||||
}, 1000));
|
||||
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 = '⏸'; // pause symbol
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.innerHTML = '▶'; // 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})`;
|
||||
}
|
||||
|
||||
export async function showKCEditor(targetId = null) {
|
||||
try {
|
||||
// Load sources and pattern templates in parallel
|
||||
const [sourcesResp, patResp] = await Promise.all([
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
]);
|
||||
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
||||
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
|
||||
|
||||
// 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 || '';
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.edit');
|
||||
} 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;
|
||||
document.getElementById('kc-editor-title').textContent = t('kc.add');
|
||||
}
|
||||
|
||||
// Auto-name
|
||||
set_kcNameManuallyEdited(!!targetId);
|
||||
document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); };
|
||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId) _autoGenerateKCName();
|
||||
|
||||
setKcEditorInitialValues({
|
||||
name: document.getElementById('kc-editor-name').value,
|
||||
source: sourceSelect.value,
|
||||
fps: document.getElementById('kc-editor-fps').value,
|
||||
interpolation: document.getElementById('kc-editor-interpolation').value,
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
patternTemplateId: patSelect.value,
|
||||
});
|
||||
|
||||
const modal = document.getElementById('kc-editor-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setupBackdropClose(modal, closeKCEditorModal);
|
||||
|
||||
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 (
|
||||
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
|
||||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
|
||||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
|
||||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
|
||||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
|
||||
document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeKCEditorModal() {
|
||||
if (isKCEditorDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceCloseKCEditorModal();
|
||||
}
|
||||
|
||||
export function forceCloseKCEditorModal() {
|
||||
document.getElementById('kc-editor-modal').style.display = 'none';
|
||||
document.getElementById('kc-editor-error').style.display = 'none';
|
||||
unlockBody();
|
||||
setKcEditorInitialValues({});
|
||||
}
|
||||
|
||||
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 errorEl = document.getElementById('kc-editor-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('kc.error.required');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!patternTemplateId) {
|
||||
errorEl.textContent = t('kc.error.no_pattern');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
key_colors_settings: {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing,
|
||||
pattern_template_id: patternTemplateId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'key_colors';
|
||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
|
||||
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');
|
||||
forceCloseKCEditorModal();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
console.error('Error saving KC target:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKCTarget(targetId) {
|
||||
const confirmed = await showConfirm(t('kc.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
disconnectKCWebSocket(targetId);
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
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) {
|
||||
showToast('Failed to delete key colors target', '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}/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('');
|
||||
}
|
||||
Reference in New Issue
Block a user