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

@@ -78,6 +78,8 @@ Plain `<select>` dropdowns should be enhanced with visual selectors depending on
Both widgets hide the native `<select>` but keep it in the DOM with its value in sync. After programmatically changing the `<select>` value, call `.refresh()` (EntitySelect) or `.setValue(val)` (IconSelect) to update the trigger display. Call `.destroy()` when the modal closes.
**IMPORTANT:** For `IconSelect` item icons, use SVG icons from `js/core/icon-paths.js` (via `_icon(P.iconName)`) or styled `<span>` elements (e.g., `<span style="font-weight:bold">A</span>`). **Never use emoji** — they render inconsistently across platforms and themes.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:

View File

@@ -750,6 +750,252 @@ async def test_kc_target(
logger.error(f"Error cleaning up test stream: {e}")
@router.websocket("/api/v1/output-targets/{target_id}/test/ws")
async def test_kc_target_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
fps: int = Query(3),
preview_width: int = Query(480),
):
"""WebSocket for real-time KC target test preview. Auth via ?token=<api_key>.
Streams JSON frames: {"type": "frame", "image": "data:image/jpeg;base64,...",
"rectangles": [...], "pattern_template_name": "...", "interpolation_mode": "..."}
"""
import json as _json
from wled_controller.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
# Load stores
target_store_inst: OutputTargetStore = get_output_target_store()
source_store_inst: PictureSourceStore = get_picture_source_store()
template_store_inst: TemplateStore = get_template_store()
pattern_store_inst: PatternTemplateStore = get_pattern_template_store()
processor_manager_inst: ProcessorManager = get_processor_manager()
device_store_inst: DeviceStore = get_device_store()
pp_template_store_inst = get_pp_template_store()
# Validate target
try:
target = target_store_inst.get_target(target_id)
except ValueError as e:
await websocket.close(code=4004, reason=str(e))
return
if not isinstance(target, KeyColorsOutputTarget):
await websocket.close(code=4003, reason="Target is not a key_colors target")
return
settings = target.settings
if not settings.pattern_template_id:
await websocket.close(code=4003, reason="No pattern template configured")
return
try:
pattern_tmpl = pattern_store_inst.get_template(settings.pattern_template_id)
except ValueError:
await websocket.close(code=4003, reason=f"Pattern template not found: {settings.pattern_template_id}")
return
rectangles = pattern_tmpl.rectangles
if not rectangles:
await websocket.close(code=4003, reason="Pattern template has no rectangles")
return
if not target.picture_source_id:
await websocket.close(code=4003, reason="No picture source configured")
return
try:
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
except ValueError as e:
await websocket.close(code=4003, reason=str(e))
return
raw_stream = chain["raw_stream"]
# For screen capture sources, check display lock
if isinstance(raw_stream, ScreenCapturePictureSource):
display_index = raw_stream.display_index
locked_device_id = processor_manager_inst.get_display_lock_info(display_index)
if locked_device_id:
try:
device = device_store_inst.get_device(locked_device_id)
device_name = device.name
except Exception:
device_name = locked_device_id
await websocket.close(
code=4003,
reason=f"Display {display_index} is captured by '{device_name}'. Stop processing first.",
)
return
fps = max(1, min(30, fps))
preview_width = max(120, min(1920, preview_width))
frame_interval = 1.0 / fps
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
await websocket.accept()
logger.info(f"KC test WS connected for {target_id} (fps={fps})")
capture_stream = None
try:
while True:
loop_start = time.monotonic()
pil_image = None
capture_stream_local = None
try:
import httpx
# Reload chain each iteration for dynamic sources
chain = source_store_inst.resolve_stream_chain(target.picture_source_id)
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB")
else:
from pathlib import Path
path = Path(source)
if path.exists():
pil_image = Image.open(path).convert("RGB")
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
capture_tmpl = template_store_inst.get_template(raw_stream.capture_template_id)
except ValueError:
break
if capture_tmpl.engine_type not in EngineRegistry.get_available_engines():
break
capture_stream_local = EngineRegistry.create_stream(
capture_tmpl.engine_type, raw_stream.display_index, capture_tmpl.engine_config
)
capture_stream_local.initialize()
screen_capture = capture_stream_local.capture_frame()
if screen_capture is not None and isinstance(screen_capture.image, np.ndarray):
pil_image = Image.fromarray(screen_capture.image)
else:
# VideoCaptureSource or other — not directly supported in WS test
break
if pil_image is None:
await asyncio.sleep(frame_interval)
continue
# Apply postprocessing
pp_template_ids = chain.get("postprocessing_template_ids", [])
if pp_template_ids and pp_template_store_inst:
img_array = np.array(pil_image)
image_pool = ImagePool()
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
try:
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
result = f.process_image(img_array, image_pool)
if result is not None:
img_array = result
except ValueError:
pass
pil_image = Image.fromarray(img_array)
# Extract colors
img_array = np.array(pil_image)
h, w = img_array.shape[:2]
result_rects = []
for rect in rectangles:
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w]
r, g, b = calc_fn(sub_img)
result_rects.append({
"name": rect.name,
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"color": {"r": r, "g": g, "b": b, "hex": f"#{r:02x}{g:02x}{b:02x}"},
})
# Encode frame as JPEG
if preview_width and pil_image.width > preview_width:
ratio = preview_width / pil_image.width
thumb = pil_image.resize((preview_width, int(pil_image.height * ratio)), Image.LANCZOS)
else:
thumb = pil_image
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode()
await websocket.send_text(_json.dumps({
"type": "frame",
"image": f"data:image/jpeg;base64,{b64}",
"rectangles": result_rects,
"pattern_template_name": pattern_tmpl.name,
"interpolation_mode": settings.interpolation_mode,
}))
except (WebSocketDisconnect, Exception) as inner_e:
if isinstance(inner_e, WebSocketDisconnect):
raise
logger.warning(f"KC test WS frame error for {target_id}: {inner_e}")
finally:
if capture_stream_local:
try:
capture_stream_local.cleanup()
except Exception:
pass
elapsed = time.monotonic() - loop_start
sleep_time = frame_interval - elapsed
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except WebSocketDisconnect:
logger.info(f"KC test WS disconnected for {target_id}")
except Exception as e:
logger.error(f"KC test WS error for {target_id}: {e}", exc_info=True)
finally:
if capture_stream:
try:
capture_stream.cleanup()
except Exception:
pass
logger.info(f"KC test WS closed for {target_id}")
@router.websocket("/api/v1/output-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,

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": "模板删除成功",

View File

@@ -1,7 +1,13 @@
<!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Auto-refresh" style="display:none">&#x25B6;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">&#x25B6;</button>
<select id="lightbox-fps-select" class="lightbox-fps-select" onchange="onLightboxFpsChange(this.value)" style="display:none" title="Frames per second">
<option value="1">1 fps</option>
<option value="2">2 fps</option>
<option value="3" selected>3 fps</option>
<option value="5">5 fps</option>
</select>
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>