) {
const state = target.state || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id!];
const deviceName = device ? device.name : (target.device_id || 'No device');
const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
const bvsId = target.brightness_value_source_id || '';
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Determine if overlay is available (picture-based CSS)
const css = cssId ? colorStripSourceMap[cssId] : null;
const overlayAvailable = !css || css.source_type === 'picture';
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
let healthClass = 'health-unknown';
let healthTitle = '';
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return wrapCard({
dataAttr: 'data-target-id',
id: target.id,
classes: isProcessing ? 'card-running' : '',
removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
${ICON_LED} ${escapeHtml(deviceName)}
${ICON_FPS} ${target.fps || 30}
${_protocolBadge(device, target)}
${ICON_FILM} ${cssSummary}
${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
${(target.min_brightness_threshold ?? 0) > 0 ? `${ICON_SUN_DIM} <${target.min_brightness_threshold} → off` : ''}
${renderTagChips(target.tags)}
${isProcessing ? `
${t('device.metrics.frames')}
---
${state.needs_keepalive !== false ? `
${t('device.metrics.keepalive')}
---
` : ''}
${t('device.metrics.errors')}
---
${t('device.metrics.uptime')}
---
` : ''}
${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`,
actions: `
${isProcessing ? `
` : `
`}
${isProcessing ? `
` : ''}
`,
});
}
async function _targetAction(action: any) {
_actionInFlight = true;
try {
await action();
} finally {
_actionInFlight = false;
_loadTargetsLock = false; // ensure next poll can run
loadTargetsTab();
}
}
export async function startTargetProcessing(targetId: any) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
method: 'POST',
});
if (response.ok) {
showToast(t('device.started'), 'success');
} else {
const error = await response.json();
showToast(error.detail || t('target.error.start_failed'), 'error');
}
});
}
export async function stopTargetProcessing(targetId: any) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
method: 'POST',
});
if (response.ok) {
showToast(t('device.stopped'), 'success');
} else {
const error = await response.json();
showToast(error.detail || t('target.error.stop_failed'), 'error');
}
});
}
export async function stopAllLedTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('led');
}
export async function stopAllKCTargets() {
const confirmed = await showConfirm(t('confirm.stop_all'));
if (!confirmed) return;
await _stopAllByType('key_colors');
}
async function _stopAllByType(targetType: any) {
try {
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
const states = statesData.states || {};
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
if (!running.length) {
showToast(t('targets.stop_all.none_running'), 'info');
return;
}
await Promise.all(running.map(t =>
fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
));
showToast(t('targets.stop_all.stopped', { count: running.length }), 'success');
loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
showToast(t('targets.stop_all.error'), 'error');
}
}
export async function startTargetOverlay(targetId: any) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/start`, {
method: 'POST',
});
if (response.ok) {
showToast(t('overlay.started'), 'success');
} else {
const error = await response.json();
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
}
});
}
export async function stopTargetOverlay(targetId: any) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/output-targets/${targetId}/overlay/stop`, {
method: 'POST',
});
if (response.ok) {
showToast(t('overlay.stopped'), 'success');
} else {
const error = await response.json();
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
}
});
}
export async function cloneTarget(targetId: any) {
try {
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
showTargetEditor(null, target);
} catch (error) {
console.error('Failed to clone target:', error);
showToast(t('target.error.clone_failed'), 'error');
}
}
export async function deleteTarget(targetId: any) {
const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return;
await _targetAction(async () => {
const response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('targets.deleted'), 'success');
outputTargetsCache.invalidate();
} else {
const error = await response.json();
showToast(error.detail || t('target.error.delete_failed'), 'error');
}
});
}
/* ── LED Strip Preview ────────────────────────────────────────── */
const _ledPreviewLastFrame = {};
/**
* Build the LED preview panel HTML for a target card.
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
* one canvas per zone with labels. Otherwise, a single canvas.
*/
function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) {
// Always render hidden — JS toggles visibility. This keeps card HTML stable
// so reconciliation doesn't replace the card when preview is toggled.
const visible = 'none';
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
// Check for per-zone preview
if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') {
const { baseUrl, zones } = _splitOpenrgbZone(device.url);
if (zones.length > 1) {
const zoneCanvases = zones.map(z =>
`` +
`` +
`${escapeHtml(z)}` +
`
`
).join('');
return `` +
zoneCanvases +
`` +
`
`;
}
}
// Check for composite source with per-layer preview
if (cssSource && cssSource.source_type === 'composite' && cssSource.layers && cssSource.layers.length > 1) {
const layerCanvases = cssSource.layers.filter(l => l.enabled !== false).map((l, i) => {
const layerSrc = colorStripSourceMap ? colorStripSourceMap[l.source_id] : null;
const layerName = layerSrc ? layerSrc.name : l.source_id;
return `` +
`` +
`${escapeHtml(layerName)}` +
`
`;
}).join('');
return `` +
`
` +
`` +
`${escapeHtml(cssSource.name || 'Composite')}` +
`
` +
layerCanvases +
`
` +
`
`;
}
// Default: single canvas
return `` +
`` +
`` +
`
`;
}
/**
* Resample an RGB byte array from srcCount pixels to dstCount pixels
* using linear interpolation (matches backend np.interp behavior).
*/
function _resampleStrip(srcBytes: any, srcCount: any, dstCount: any) {
if (dstCount === srcCount) return srcBytes;
const dst = new Uint8Array(dstCount * 3);
for (let i = 0; i < dstCount; i++) {
const t = dstCount > 1 ? i / (dstCount - 1) : 0;
const srcPos = t * (srcCount - 1);
const lo = Math.floor(srcPos);
const hi = Math.min(lo + 1, srcCount - 1);
const frac = srcPos - lo;
for (let ch = 0; ch < 3; ch++) {
dst[i * 3 + ch] = Math.round(
srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac
);
}
}
return dst;
}
/**
* Render per-zone LED previews: resample the full frame independently
* for each zone canvas (matching the backend's separate-mode behavior).
*/
function _renderLedStripZones(panel: any, rgbBytes: any) {
const baseUrl = panel.dataset.zoneBaseUrl;
const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
const srcCount = Math.floor(rgbBytes.length / 3);
if (srcCount < 1) return;
const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas');
if (!cache) {
// Zone sizes unknown — render full frame to all canvases
for (const canvas of zoneCanvases) {
_renderLedStrip(canvas, rgbBytes);
}
return;
}
// Separate mode: each zone gets the full source frame resampled to its LED count
// (matches backend OpenRGB separate-mode behavior)
for (const canvas of zoneCanvases) {
const zoneName = canvas.dataset.zoneName;
const zoneSize = cache[zoneName.toLowerCase()];
if (!zoneSize || zoneSize < 1) continue;
const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize);
_renderLedStrip(canvas, resampled);
}
}
function _renderLedStrip(canvas: any, rgbBytes: any) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
// Set canvas resolution to match LED count (1px per LED)
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
const di = i * 4;
data[di] = rgbBytes[si];
data[di + 1] = rgbBytes[si + 1];
data[di + 2] = rgbBytes[si + 2];
data[di + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
function connectLedPreviewWS(targetId: any) {
// Close existing WS without touching DOM (caller manages panel/button state)
const oldWs = ledPreviewWebSockets[targetId];
if (oldWs) {
oldWs.onclose = null;
oldWs.close();
delete ledPreviewWebSockets[targetId];
}
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}/led-preview/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const raw = new Uint8Array(event.data);
const brightness = raw[0];
const panel = document.getElementById(`led-preview-panel-${targetId}`);
// Detect composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
const isCompositeWire = raw.length > 4 && raw[1] === 0xFE;
if (isCompositeWire) {
const layerCount = raw[2];
const ledCount = (raw[3] << 8) | raw[4];
const rgbSize = ledCount * 3;
let offset = 5;
// Render per-layer canvases if panel supports it
if (panel && panel.dataset.composite === '1') {
const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]');
for (let i = 0; i < layerCount; i++) {
const layerRgb = raw.subarray(offset, offset + rgbSize);
offset += rgbSize;
// layer canvases: idx 0 = "composite", idx 1..N = individual layers
const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first
if (canvas) _renderLedStrip(canvas, layerRgb);
}
} else {
// Skip layer data (panel doesn't have layer canvases)
offset += layerCount * rgbSize;
}
// Final composite result
const compositeRgb = raw.subarray(offset, offset + rgbSize);
_ledPreviewLastFrame[targetId] = compositeRgb;
if (panel) {
if (panel.dataset.composite === '1') {
const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb);
} else if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, compositeRgb);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, compositeRgb);
}
}
} else {
// Standard wire format: [brightness_byte] [R G B R G B ...]
const frame = raw.subarray(1);
_ledPreviewLastFrame[targetId] = frame;
if (panel) {
if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, frame);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
}
}
}
// Show brightness label: always when a brightness source is set, otherwise only below 100%
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
if (bLabel) {
const pct = Math.round(brightness / 255 * 100);
if (pct < 100 || bLabel.dataset.hasBvs) {
bLabel.innerHTML = `${ICON_SUN_DIM} ${pct}%`;
bLabel.style.display = '';
} else {
bLabel.style.display = 'none';
}
}
}
};
ws.onclose = () => {
delete ledPreviewWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`LED preview WebSocket error for ${targetId}:`, error);
};
ledPreviewWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error);
}
}
function _setPreviewButtonState(targetId: any, active: boolean) {
const btn = document.querySelector(`[data-led-preview-btn="${CSS.escape(targetId)}"]`);
if (btn) {
btn.classList.toggle('btn-warning', active);
btn.classList.toggle('btn-secondary', !active);
}
}
/** Restore preview panel visibility, button state, and last frame after card replacement. */
function _restoreLedPreviewState(targetId: any) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) panel.style.display = '';
_setPreviewButtonState(targetId, true);
// Re-render cached frame onto the new canvas
const frame = _ledPreviewLastFrame[targetId];
if (frame && panel) {
if (panel.dataset.zoneMode === 'separate') {
_renderLedStripZones(panel, frame);
} else {
const canvas = panel.querySelector('.led-preview-canvas');
if (canvas) _renderLedStrip(canvas, frame);
}
}
}
function disconnectLedPreviewWS(targetId: any) {
const ws = ledPreviewWebSockets[targetId];
if (ws) {
ws.onclose = null;
ws.close();
delete ledPreviewWebSockets[targetId];
}
delete _ledPreviewLastFrame[targetId];
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (panel) panel.style.display = 'none';
_setPreviewButtonState(targetId, false);
}
export function disconnectAllLedPreviewWS() {
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
}
export function toggleLedPreview(targetId: any) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (!panel) return;
if (ledPreviewWebSockets[targetId]) {
disconnectLedPreviewWS(targetId);
} else {
panel.style.display = '';
_setPreviewButtonState(targetId, true);
connectLedPreviewWS(targetId);
}
}