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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`) : ''}`,
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user