Live KC test WS, sync clock fix, device card perf, camera icons, tab indicator

Key Colors test:
- New WS endpoint for live KC target test streaming (replaces REST polling)
- Auto-connect on lightbox open, auto-disconnect on close
- Uses same FPS/preview_width as CSS source test (no separate controls)
- Removed FPS selector, start/stop toggle, and updateAutoRefreshButton

Device cards:
- Fix full re-render on every poll caused by relative "Last seen" time in HTML
- Last seen label now patched in-place via data attribute (like FPS metrics)
- Remove overlay visualization button from LED target cards

Sync clocks:
- Fix card not updating start/stop icon: invalidate cache before reload

Other:
- Tab indicator respects bg-anim toggle (hidden when dynamic background off)
- Camera backend icon grid uses SVG icons instead of emoji
- Frontend context rule: no emoji in IconSelect items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 02:03:07 +03:00
parent bcba5f33fc
commit 00c9ad3a86
16 changed files with 430 additions and 85 deletions
@@ -5,6 +5,8 @@
import {
kcTestAutoRefresh, setKcTestAutoRefresh,
kcTestTargetId, setKcTestTargetId,
kcTestWs, setKcTestWs,
kcTestFps, setKcTestFps,
_kcNameManuallyEdited, set_kcNameManuallyEdited,
kcWebSockets,
PATTERN_RECT_BORDERS,
@@ -17,7 +19,7 @@ import { lockBody, showToast, showConfirm, formatUptime, formatCompact, desktopF
import { Modal } from '../core/modal.js';
import {
getValueSourceIcon, getPictureSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_PAUSE,
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP,
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
} from '../core/icons.js';
import * as P from '../core/icon-paths.js';
@@ -305,16 +307,54 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
// ===== 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);
function _openKCTestWs(targetId, fps, previewWidth = 480) {
// Close any existing WS
if (kcTestWs) {
try { kcTestWs.close(); } catch (_) {}
setKcTestWs(null);
}
return response.json();
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');
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) {
@@ -337,38 +377,19 @@ export async function testKCTarget(targetId) {
}
spinner.style.display = '';
// Show auto-refresh button
// Hide controls — KC test streams automatically
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) refreshBtn.style.display = '';
if (refreshBtn) refreshBtn.style.display = 'none';
const fpsSelect = document.getElementById('lightbox-fps-select');
if (fpsSelect) fpsSelect.style.display = 'none';
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);
}
// 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() {
@@ -376,20 +397,11 @@ export function stopKCTestAutoRefresh() {
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;
if (kcTestWs) {
try { kcTestWs.close(1000, 'lightbox closed'); } catch (_) {}
setKcTestWs(null);
}
setKcTestTargetId(null);
}
export function displayKCTestResults(result) {