From 00c9ad3a865e39827e9cb9df0ba3bdde290ebc3c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 17 Mar 2026 02:03:07 +0300 Subject: [PATCH] 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) --- contexts/frontend.md | 2 + .../api/routes/output_targets.py | 246 ++++++++++++++++++ .../src/wled_controller/static/css/modal.css | 25 ++ server/src/wled_controller/static/js/app.js | 3 +- .../wled_controller/static/js/core/state.js | 6 + .../static/js/core/tab-indicator.js | 22 ++ .../src/wled_controller/static/js/core/ui.js | 26 +- .../static/js/features/devices.js | 5 +- .../static/js/features/kc-targets.js | 112 ++++---- .../static/js/features/streams.js | 22 ++ .../static/js/features/sync-clocks.js | 3 + .../static/js/features/targets.js | 23 +- .../wled_controller/static/locales/en.json | 4 + .../wled_controller/static/locales/ru.json | 4 + .../wled_controller/static/locales/zh.json | 4 + .../templates/partials/image-lightbox.html | 8 +- 16 files changed, 430 insertions(+), 85 deletions(-) diff --git a/contexts/frontend.md b/contexts/frontend.md index 11dad8f..7b8dd83 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -78,6 +78,8 @@ Plain `` but keep it in the DOM with its value in sync. After programmatically changing the ` ({})); - 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) { diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index ad4c0f9..f34867a 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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) => `${d}`; 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 += ''; 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'; diff --git a/server/src/wled_controller/static/js/features/sync-clocks.js b/server/src/wled_controller/static/js/features/sync-clocks.js index decddae..325704d 100644 --- a/server/src/wled_controller/static/js/features/sync-clocks.js +++ b/server/src/wled_controller/static/js/features/sync-clocks.js @@ -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; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 0cac3ba..c08003e 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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 - ${overlayAvailable ? (state.overlay_active ? ` - - ` : ` - - `) : ''}`, +`, }); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b509cb0..a31a1c7 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 4cc18e5..553933f 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Шаблон успешно удалён", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index e0264da..d645eed 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "模板删除成功", diff --git a/server/src/wled_controller/templates/partials/image-lightbox.html b/server/src/wled_controller/templates/partials/image-lightbox.html index 3c89adc..76c2206 100644 --- a/server/src/wled_controller/templates/partials/image-lightbox.html +++ b/server/src/wled_controller/templates/partials/image-lightbox.html @@ -1,7 +1,13 @@