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

View File

@@ -967,6 +967,31 @@
background: var(--primary-color);
}
.lightbox-fps-select {
position: absolute;
top: 16px;
right: 116px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
padding: 4px 6px;
font-size: 0.8rem;
cursor: pointer;
z-index: 1;
appearance: none;
-webkit-appearance: none;
text-align: center;
}
.lightbox-fps-select:hover {
background: rgba(255, 255, 255, 0.15);
}
.lightbox-fps-select:focus {
outline: 1px solid var(--primary-color);
}
.lightbox-stats {
position: absolute;
bottom: 8px;

View File

@@ -71,7 +71,7 @@ import {
expandAllStreamSections, collapseAllStreamSections,
} from './features/streams.js';
import {
createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh,
createKCTargetCard, testKCTarget,
showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor,
deleteKCTarget, disconnectAllKCWebSockets,
updateKCBrightnessLabel, saveKCBrightness,
@@ -338,7 +338,6 @@ Object.assign(window, {
// kc-targets
createKCTargetCard,
testKCTarget,
toggleKCTestAutoRefresh,
showKCEditor,
closeKCEditorModal,
forceCloseKCEditorModal,

View File

@@ -20,6 +20,12 @@ export function setKcTestAutoRefresh(v) { kcTestAutoRefresh = v; }
export let kcTestTargetId = null;
export function setKcTestTargetId(v) { kcTestTargetId = v; }
export let kcTestWs = null;
export function setKcTestWs(v) { kcTestWs = v; }
export let kcTestFps = 3;
export function setKcTestFps(v) { kcTestFps = v; }
export let _cachedDisplays = null;
export let _displayPickerCallback = null;

View File

@@ -29,6 +29,13 @@ export function updateTabIndicator(tabName) {
const svg = TAB_SVGS[tabName];
if (!svg) return;
// Respect the dynamic background toggle — hide when bg-anim is off
if (document.documentElement.getAttribute('data-bg-anim') !== 'on') {
const el = _ensureEl();
el.classList.remove('tab-indicator-visible');
return;
}
const el = _ensureEl();
// Trigger crossfade: set opacity 0, swap content, fade in
el.classList.remove('tab-indicator-visible');
@@ -40,6 +47,21 @@ export function updateTabIndicator(tabName) {
export function initTabIndicator() {
_ensureEl();
// Listen for bg-anim toggle to show/hide the indicator
new MutationObserver(() => {
const on = document.documentElement.getAttribute('data-bg-anim') === 'on';
const el = _ensureEl();
if (!on) {
el.classList.remove('tab-indicator-visible');
} else if (_currentTab) {
// Re-trigger show for the current tab
const prev = _currentTab;
_currentTab = null;
updateTabIndicator(prev);
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-bg-anim'] });
// Set initial tab from current active button
const active = document.querySelector('.tab-btn.active');
if (active) {

View File

@@ -2,9 +2,8 @@
* UI utilities — modal helpers, lightbox, toast, confirm.
*/
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, confirmResolve, setConfirmResolve } from './state.js';
import { kcTestAutoRefresh, setKcTestAutoRefresh, setKcTestTargetId, kcTestWs, setKcTestWs, confirmResolve, setConfirmResolve } from './state.js';
import { t } from './i18n.js';
import { ICON_PAUSE, ICON_START } from './icons.js';
/** Returns true on touch devices where auto-focus would pop up the virtual keyboard */
export function isTouchDevice() {
@@ -105,8 +104,8 @@ export function openLightbox(imageSrc, statsHtml) {
}
export function closeLightbox(event) {
if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return;
// Stop KC test auto-refresh if running
if (event && event.target && event.target.closest('.lightbox-content')) return;
// Stop KC test WS if running
stopKCTestAutoRefresh();
const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active');
@@ -117,8 +116,6 @@ export function closeLightbox(event) {
document.getElementById('lightbox-stats').style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none';
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); }
unlockBody();
}
@@ -127,20 +124,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(); } catch (_) {}
setKcTestWs(null);
}
setKcTestTargetId(null);
}
export function showToast(message, type = 'info') {

View File

@@ -95,7 +95,7 @@ class DeviceSettingsModal extends Modal {
const settingsModal = new DeviceSettingsModal();
function _formatRelativeTime(isoString) {
export function formatRelativeTime(isoString) {
if (!isoString) return null;
const then = new Date(isoString);
const diffMs = Date.now() - then.getTime();
@@ -140,7 +140,6 @@ export function createDeviceCard(device) {
}
const ledCount = state.device_led_count || device.led_count;
const lastSeenLabel = devLastChecked ? _formatRelativeTime(devLastChecked) : null;
// Parse zone names from OpenRGB URL for badge display
const openrgbZones = isOpenrgbDevice(device.device_type)
@@ -169,7 +168,7 @@ export function createDeviceCard(device) {
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
${lastSeenLabel ? `<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" title="${devLastChecked}">⏱ ${t('device.last_seen.label')}: ${lastSeenLabel}</span></div>` : ''}
<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" data-last-seen="${device.id}"></span></div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"

View File

@@ -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) {

View File

@@ -56,6 +56,9 @@ import {
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP,
} from '../core/icons.js';
import * as P from '../core/icon-paths.js';
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { IconSelect } from '../core/icon-select.js';
@@ -407,6 +410,19 @@ export async function onEngineChange() {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
camera_backend: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
@@ -436,6 +452,12 @@ export async function onEngineChange() {
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel, items: cfg.items, columns: cfg.columns });
}
}
configSection.style.display = 'block';

View File

@@ -157,6 +157,7 @@ export async function pauseSyncClock(clockId) {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
@@ -169,6 +170,7 @@ export async function resumeSyncClock(clockId) {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
@@ -181,6 +183,7 @@ export async function resetSyncClock(clockId) {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;

View File

@@ -16,7 +16,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from
import { t } from '../core/i18n.js';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache, formatRelativeTime } from './devices.js';
import { _splitOpenrgbZone } from './device-discovery.js';
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
import {
@@ -727,6 +727,17 @@ export async function loadTargetsTab() {
}
});
// Patch "Last seen" labels in-place (avoids full card re-render on relative time changes)
for (const device of devicesWithState) {
const el = container.querySelector(`[data-last-seen="${device.id}"]`);
if (el) {
const ts = device.state?.device_last_checked;
const label = ts ? formatRelativeTime(ts) : null;
el.textContent = label ? `\u23F1 ${t('device.last_seen.label')}: ${label}` : '';
if (ts) el.title = ts;
}
}
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
@@ -1021,15 +1032,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>
${overlayAvailable ? (state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
${ICON_OVERLAY}
</button>
` : `
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
${ICON_OVERLAY}
</button>
`) : ''}`,
`,
});
}

View File

@@ -80,6 +80,10 @@
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
"templates.config.camera_backend.auto": "Auto-detect best backend",
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.created": "Template created successfully",
"templates.updated": "Template updated successfully",
"templates.deleted": "Template deleted successfully",

View File

@@ -80,6 +80,10 @@
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
"templates.config.camera_backend.auto": "Автовыбор лучшего бэкенда",
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.created": "Шаблон успешно создан",
"templates.updated": "Шаблон успешно обновлён",
"templates.deleted": "Шаблон успешно удалён",

View File

@@ -80,6 +80,10 @@
"templates.config.show": "显示配置",
"templates.config.none": "无额外配置",
"templates.config.default": "默认",
"templates.config.camera_backend.auto": "自动检测最佳后端",
"templates.config.camera_backend.dshow": "Windows DirectShow",
"templates.config.camera_backend.msmf": "Windows Media Foundation",
"templates.config.camera_backend.v4l2": "Linux Video4Linux2",
"templates.created": "模板创建成功",
"templates.updated": "模板更新成功",
"templates.deleted": "模板删除成功",