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:
@@ -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`:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`) : ''}`,
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Шаблон успешно удалён",
|
||||
|
||||
@@ -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": "模板删除成功",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<!-- Image Lightbox -->
|
||||
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
|
||||
<button class="lightbox-close" onclick="closeLightbox()" title="Close">✕</button>
|
||||
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Auto-refresh" style="display:none">▶</button>
|
||||
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Stream live" style="display:none">▶</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>
|
||||
|
||||
Reference in New Issue
Block a user